mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
Merge pull request #14155 from Kilo-Org/fascinated-baryonyx
feat(vscode): show chat message timestamps on hover
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Show message timestamps on hover in the chat transcript, including the turn's finish time and duration.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:99d02dae9682fc929d2a34f05feca309237e957d634cea07d3ef80dec983d865
|
||||
size 26036
|
||||
oid sha256:1e81dc5c2df088319fbe25fcb33b45680d03235f7ab7e8753f5c276eed140d70
|
||||
size 27128
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* The whole assistant action row (copy, feedback, tokens/s, time) follows
|
||||
the same rule as the user row: hidden at rest, revealed on hover or
|
||||
focus. Opacity keeps the row height so the transcript never shifts. */
|
||||
[data-slot="assistant-copy-wrapper"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
/* Throughput badge sits to the right of the copy/feedback buttons,
|
||||
beside them rather than beneath the message. */
|
||||
@@ -29,6 +34,22 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Turn finish time and duration sit beside the actions, in the same
|
||||
text style as the tokens/s value. */
|
||||
[data-slot="assistant-turn-meta"] {
|
||||
margin-left: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover [data-slot="assistant-copy-wrapper"],
|
||||
&:focus-within [data-slot="assistant-copy-wrapper"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,12 +383,17 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
margin-right: 2px;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* While queued, the action row holds the queued controls and must stay
|
||||
visible and interactive without hover. */
|
||||
[data-slot="user-message-copy-wrapper"][data-queued] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body.vscode-high-contrast [data-component="user-message"] [data-slot="user-message-text"],
|
||||
@@ -458,7 +484,10 @@ html[data-theme="kilo-vscode"] [data-component="todos"] {
|
||||
height: 20px;
|
||||
margin-top: 0;
|
||||
|
||||
[data-slot="user-message-meta-wrap"] {
|
||||
/* The hover action row keeps only the send time from the message meta:
|
||||
the agent/model head is hidden so the row stays compact. */
|
||||
[data-slot="user-message-meta"],
|
||||
[data-slot="user-message-meta-sep"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,9 @@ export interface MessagePartProps {
|
||||
working?: boolean
|
||||
feedback?: MessageFeedbackControls
|
||||
throughput?: JSX.Element
|
||||
/** Finish time and duration for the turn, rendered inline in the assistant
|
||||
* copy/feedback action row rather than on its own line. */
|
||||
turnMeta?: JSX.Element
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
@@ -816,11 +819,7 @@ export function UserMessageDisplay(props: {
|
||||
const stamp = createMemo(() => {
|
||||
const created = props.message.time?.created
|
||||
if (typeof created !== "number") return ""
|
||||
const date = new Date(created)
|
||||
const hours = date.getHours()
|
||||
const hour12 = hours % 12 || 12
|
||||
const minute = String(date.getMinutes()).padStart(2, "0")
|
||||
return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}`
|
||||
return new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }).format(new Date(created))
|
||||
})
|
||||
|
||||
const metaHead = createMemo(() => {
|
||||
@@ -942,17 +941,23 @@ export function UserMessageDisplay(props: {
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
</div>
|
||||
</Show>
|
||||
<GrowBox animate={!!props.animate} open={!!props.queued}>
|
||||
</div>
|
||||
|
||||
{/* Queued controls live in the same reserved action row as the
|
||||
hover actions, so unqueueing swaps content without a height change. */}
|
||||
<div
|
||||
data-slot="user-message-copy-wrapper"
|
||||
data-interrupted={props.interrupted ? "" : undefined}
|
||||
data-queued={props.queued ? "" : undefined}
|
||||
>
|
||||
<Show when={props.queued}>
|
||||
<div data-slot="user-message-queued-indicator">
|
||||
<TextShimmer text={i18n.t("ui.message.queued")} />
|
||||
<Edit />
|
||||
<Delete />
|
||||
</div>
|
||||
</GrowBox>
|
||||
</div>
|
||||
|
||||
<div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
|
||||
<Show when={metaHead() || metaTail()}>
|
||||
</Show>
|
||||
<Show when={!props.queued && (metaHead() || metaTail())}>
|
||||
<span data-slot="user-message-meta-wrap">
|
||||
<Show when={metaHead()}>
|
||||
<span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
|
||||
@@ -1002,23 +1007,25 @@ export function UserMessageDisplay(props: {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
placement="right"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show when={!props.queued}>
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
placement="right"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
@@ -1093,6 +1100,7 @@ export function Part(props: MessagePartProps) {
|
||||
working={props.working}
|
||||
feedback={props.feedback}
|
||||
throughput={props.throughput}
|
||||
turnMeta={props.turnMeta}
|
||||
readonly={props.readonly}
|
||||
/>
|
||||
</Show>
|
||||
@@ -1826,6 +1834,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.throughput}>{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}</Show>
|
||||
<Show when={props.turnMeta}>
|
||||
{(el) => (
|
||||
<span data-slot="assistant-turn-meta" class="cursor-default">
|
||||
{el()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary()}>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { formatClock, formatDuration } from "../../webview-ui/src/utils/message-time"
|
||||
import { LOCALES, localeToBcp47 } from "../../webview-ui/src/context/language-utils"
|
||||
|
||||
describe("message-time", () => {
|
||||
it("resolves every Kilo UI language to a real Intl locale", () => {
|
||||
for (const locale of LOCALES) {
|
||||
const tag = localeToBcp47(locale)
|
||||
const resolved = new Intl.DateTimeFormat(tag, { timeStyle: "short" }).resolvedOptions().locale
|
||||
expect(resolved.split("-")[0]).toBe(tag.split("-")[0])
|
||||
}
|
||||
})
|
||||
|
||||
it("formats the clock in the UI language's convention", () => {
|
||||
const at = Date.UTC(2026, 0, 1, 13, 5)
|
||||
// 12-hour convention for English, 24-hour for German, regardless of the
|
||||
// local timezone the test runs in.
|
||||
expect(formatClock(at, "en")).toMatch(/^\d{1,2}:\d{2}\s?[AP]M$/)
|
||||
expect(formatClock(at, "de")).toMatch(/^\d{1,2}:\d{2}$/)
|
||||
expect(formatClock(at, "de")).not.toMatch(/AM|PM/)
|
||||
})
|
||||
|
||||
it("formats sub-minute durations as seconds", () => {
|
||||
expect(formatDuration(0)).toBe("0s")
|
||||
expect(formatDuration(1500)).toBe("2s")
|
||||
expect(formatDuration(45_000)).toBe("45s")
|
||||
})
|
||||
|
||||
it("formats minute-scale durations with seconds", () => {
|
||||
expect(formatDuration(60_000)).toBe("1m 0s")
|
||||
expect(formatDuration(125_000)).toBe("2m 5s")
|
||||
})
|
||||
|
||||
it("formats hour-scale durations with minutes", () => {
|
||||
expect(formatDuration(3_600_000)).toBe("1h 0m")
|
||||
expect(formatDuration(3_780_000)).toBe("1h 3m")
|
||||
})
|
||||
|
||||
it("clamps negative durations to zero", () => {
|
||||
expect(formatDuration(-5000)).toBe("0s")
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,14 @@ const assistant = (id: string, parentID: string, opts: Partial<Message> = {}): M
|
||||
...opts,
|
||||
})
|
||||
const part = (id: string, messageID: string): Part => ({ id, messageID, type: "text", text: id })
|
||||
const tool = (id: string, messageID: string): Part => ({
|
||||
id,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: id,
|
||||
tool: "bash",
|
||||
state: { status: "completed", input: {}, output: "" },
|
||||
})
|
||||
const lookup = (values: Record<string, Part[]>) => (id: string) => values[id] ?? []
|
||||
|
||||
describe("transcriptRows", () => {
|
||||
@@ -270,6 +278,58 @@ describe("transcriptRows", () => {
|
||||
expect(live[0]).not.toBe(second[0])
|
||||
expect(live[1]).not.toBe(second[1])
|
||||
})
|
||||
|
||||
it("attaches finish time and duration to the row that carries the copy part", () => {
|
||||
const u1 = user("u1", { time: { created: 1_000 } })
|
||||
const a1 = assistant("a1", "u1", { finish: "stop", time: { created: 1_500, completed: 3_500 } })
|
||||
const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] }))
|
||||
|
||||
expect(rows.find((row) => row.type === "assistant")).toMatchObject({
|
||||
timing: { completedAt: 3_500, durationMs: 2_500 },
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps timing on the copy row when non-text parts follow in a later chunk", () => {
|
||||
const u1 = user("u1", { time: { created: 1_000 } })
|
||||
const a1 = assistant("a1", "u1", { finish: "stop", time: { created: 1_500, completed: 3_500 } })
|
||||
const rows = transcriptRows(
|
||||
messageTurns([u1, a1]),
|
||||
lookup({ a1: [part("p0", "a1"), part("p1", "a1"), tool("t2", "a1"), tool("t3", "a1")] }),
|
||||
{ size: 2 },
|
||||
)
|
||||
const assistants = rows.filter((row) => row.type === "assistant")
|
||||
|
||||
expect(assistants).toHaveLength(2)
|
||||
expect(assistants[0]?.timing).toEqual({ completedAt: 3_500, durationMs: 2_500 })
|
||||
expect(assistants[1]?.timing).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps timing on the copy row when a later assistant message has no visible parts", () => {
|
||||
const u1 = user("u1", { time: { created: 1_000 } })
|
||||
const a1 = assistant("a1", "u1")
|
||||
const a2 = assistant("a2", "u1", { finish: "stop", time: { created: 1_600, completed: 3_600 } })
|
||||
const rows = transcriptRows(messageTurns([u1, a1, a2]), lookup({ a1: [part("p1", "a1")] }))
|
||||
const assistants = rows.filter((row) => row.type === "assistant")
|
||||
|
||||
expect(assistants[0]?.timing).toEqual({ completedAt: 3_600, durationMs: 2_600 })
|
||||
expect(assistants[1]?.timing).toBeUndefined()
|
||||
})
|
||||
|
||||
it("omits turn timing while the last assistant message is still running", () => {
|
||||
const u1 = user("u1", { time: { created: 1_000 } })
|
||||
const a1 = assistant("a1", "u1", { time: { created: 1_500 } })
|
||||
const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] }))
|
||||
|
||||
expect(rows.find((row) => row.type === "assistant")?.timing).toBeUndefined()
|
||||
})
|
||||
|
||||
it("omits turn timing when the turn ends on a tool call", () => {
|
||||
const u1 = user("u1", { time: { created: 1_000 } })
|
||||
const a1 = assistant("a1", "u1", { finish: "tool-calls", time: { created: 1_500, completed: 3_500 } })
|
||||
const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] }))
|
||||
|
||||
expect(rows.find((row) => row.type === "assistant")?.timing).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("retainTurn", () => {
|
||||
|
||||
@@ -33,6 +33,8 @@ import { useServer } from "../../context/server"
|
||||
import { planDisplayPath } from "../../utils/plan-path"
|
||||
import { isRenderable, UPSTREAM_SUPPRESSED_TOOLS } from "../../utils/transcript-parts"
|
||||
import { messageThroughput, formatTG } from "../../context/session-utils"
|
||||
import { formatClock, formatDuration } from "../../utils/message-time"
|
||||
import type { TurnTiming } from "../../context/transcript-rows"
|
||||
import { color as timelineColor } from "../../utils/timeline/colors"
|
||||
import type { Part as TimelinePart } from "../../types/messages"
|
||||
import type { TimelineHighlight } from "../../utils/timeline/highlight"
|
||||
@@ -105,6 +107,9 @@ interface AssistantMessageProps {
|
||||
message: SDKAssistantMessage
|
||||
parts?: SDKPart[]
|
||||
showAssistantCopyPartID?: string | null
|
||||
/** Finish time and duration for the turn, shown inline in the assistant
|
||||
* action row once the turn settles. */
|
||||
timing?: TurnTiming
|
||||
feedback?: MessageFeedbackControls
|
||||
/** id of the part containing the current chat-search match, if any — forces
|
||||
* that part's collapsed tool/reasoning content open so the user can see
|
||||
@@ -320,6 +325,24 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
return <ThroughputBadge metrics={metrics} />
|
||||
})
|
||||
|
||||
// Turn finish time and duration render inline in the same action row
|
||||
// as the copy/feedback buttons, on the trailing side, so the turn's
|
||||
// timing never introduces a second line. Only the copy-carrying part
|
||||
// builds it, which keeps it to one row per settled turn.
|
||||
const turnMetaEl = createMemo<JSX.Element | undefined>(() => {
|
||||
const timing = props.timing
|
||||
if (!timing) return undefined
|
||||
if (part.id !== props.showAssistantCopyPartID) return undefined
|
||||
return (
|
||||
<span data-component="message-time">
|
||||
{formatClock(timing.completedAt, language.locale())}
|
||||
<Show when={timing.durationMs}>
|
||||
{(ms) => <span data-slot="message-time-duration"> · {formatDuration(ms())}</span>}
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={
|
||||
@@ -367,6 +390,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
settled={settled()}
|
||||
feedback={props.feedback}
|
||||
throughput={throughputEl()}
|
||||
turnMeta={turnMetaEl()}
|
||||
readonly={props.readonly}
|
||||
animate={
|
||||
part.type === "tool" &&
|
||||
|
||||
@@ -108,6 +108,7 @@ export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
|
||||
message={row().message as unknown as SDKAssistantMessage}
|
||||
parts={row().parts as unknown as SDKPart[]}
|
||||
showAssistantCopyPartID={row().copy}
|
||||
timing={row().timing}
|
||||
forceOpenPartID={props.activeSearchPartID}
|
||||
forceOpenFile={props.activeSearchPartFile}
|
||||
highlight={props.highlight}
|
||||
|
||||
@@ -217,7 +217,9 @@ export const LanguageProvider: ParentComponent<LanguageProviderProps> = (props)
|
||||
<LanguageContext.Provider
|
||||
value={{ locale, setLocale, userOverride, t: t as (key: string, params?: UiI18nParams) => string }}
|
||||
>
|
||||
<I18nProvider value={{ locale: () => locale(), t, plural }}>{props.children}</I18nProvider>
|
||||
{/* Shared UI formats dates and numbers with Intl from this value, so it
|
||||
must be a BCP-47 tag (Kilo's "zht" is not one). */}
|
||||
<I18nProvider value={{ locale: () => localeToBcp47(locale()), t, plural }}>{props.children}</I18nProvider>
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,12 +17,22 @@ export interface TranscriptUserRow extends TranscriptMeta {
|
||||
answered: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock finish time and duration for a completed turn, shown as the
|
||||
* turn's chat-line timestamp.
|
||||
*/
|
||||
export interface TurnTiming {
|
||||
completedAt: number
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface TranscriptAssistantRow extends TranscriptMeta {
|
||||
type: "assistant"
|
||||
key: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
copy?: string
|
||||
timing?: TurnTiming
|
||||
}
|
||||
|
||||
export interface TranscriptDiffRow extends TranscriptMeta {
|
||||
@@ -84,6 +94,10 @@ function meta(a: TranscriptRow, b: TranscriptRow) {
|
||||
return a.turn === b.turn && a.partial === b.partial && a.queued === b.queued && a.live === b.live
|
||||
}
|
||||
|
||||
function sameTiming(a?: TurnTiming, b?: TurnTiming) {
|
||||
return a?.completedAt === b?.completedAt && a?.durationMs === b?.durationMs
|
||||
}
|
||||
|
||||
function equal(a: TranscriptRow, b: TranscriptRow) {
|
||||
if (a.type !== b.type || !meta(a, b)) return false
|
||||
if (a.type === "user" && b.type === "user") {
|
||||
@@ -92,7 +106,7 @@ function equal(a: TranscriptRow, b: TranscriptRow) {
|
||||
)
|
||||
}
|
||||
if (a.type === "assistant" && b.type === "assistant") {
|
||||
return a.message === b.message && same(a.parts, b.parts) && a.copy === b.copy
|
||||
return a.message === b.message && same(a.parts, b.parts) && a.copy === b.copy && sameTiming(a.timing, b.timing)
|
||||
}
|
||||
if (a.type === "diff" && b.type === "diff") {
|
||||
return a.message === b.message && same(a.diffs, b.diffs)
|
||||
@@ -108,6 +122,39 @@ function diffs(msg: Message) {
|
||||
return msg.summary.diffs ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish time and duration for a settled turn, derived the same way as the
|
||||
* TUI session view: the last assistant message's completion time minus the
|
||||
* user prompt's creation time. Mirrors the TUI's finish guard, because
|
||||
* `time.completed` is also set on `tool-calls` steps, and skips partial
|
||||
* (not-yet-parented) turns whose synthetic user message reuses an assistant
|
||||
* timestamp.
|
||||
*/
|
||||
function turnTiming(turn: MessageTurn) {
|
||||
if (turn.partial) return undefined
|
||||
const last = turn.assistant.at(-1)
|
||||
if (!last?.finish || last.finish === "tool-calls" || last.finish === "unknown") return undefined
|
||||
const end = last.time?.completed
|
||||
if (typeof end !== "number") return undefined
|
||||
const start = turn.user.time?.created
|
||||
if (typeof start !== "number") return { completedAt: end }
|
||||
return { completedAt: end, durationMs: Math.max(0, end - start) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach turn timing to the row whose action row carries the copy button,
|
||||
* because that is where it renders rather than on the turn's last row: the
|
||||
* copy part can sit in an earlier chunk, and a tool-only or empty trailing
|
||||
* message has no action row at all. Only the current turn's assistant rows are
|
||||
* scanned, and the lookup is skipped when there is no timing to show, so the
|
||||
* per-part streaming path stays cheap.
|
||||
*/
|
||||
function attachTiming(rows: TranscriptAssistantRow[], copied: string | undefined, timing: TurnTiming | undefined) {
|
||||
if (!timing || !copied) return
|
||||
const owner = rows.find((row) => row.parts.some((part) => part.id === copied))
|
||||
if (owner) owner.timing = timing
|
||||
}
|
||||
|
||||
function content(parts: Part[]) {
|
||||
const text = parts.find((part) => part.type === "text" && !part.synthetic)
|
||||
if (text?.type === "text" && text.text.trim()) return true
|
||||
@@ -165,6 +212,7 @@ export function transcriptRows(
|
||||
})
|
||||
}
|
||||
|
||||
const assistant: TranscriptAssistantRow[] = []
|
||||
for (const msg of turn.assistant) {
|
||||
const visible = parts(msg.id)
|
||||
if (visible.length === 0) {
|
||||
@@ -180,17 +228,21 @@ export function transcriptRows(
|
||||
}
|
||||
for (let start = 0; start < visible.length; start += size) {
|
||||
const chunk = visible.slice(start, start + size)
|
||||
rows.push({
|
||||
const row: TranscriptAssistantRow = {
|
||||
...meta,
|
||||
type: "assistant",
|
||||
key: `${turn.id}:assistant:${msg.id}:${chunk[0]!.id}`,
|
||||
message: msg,
|
||||
parts: chunk,
|
||||
copy: copied,
|
||||
})
|
||||
}
|
||||
assistant.push(row)
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
attachTiming(assistant, copied, turnTiming(turn))
|
||||
|
||||
const changes = diffs(turn.user)
|
||||
if (changes.length > 0) {
|
||||
rows.push({ ...meta, type: "diff", key: `${turn.id}:diff`, message: turn.user, diffs: changes })
|
||||
|
||||
@@ -469,11 +469,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="assistant-throughput"] {
|
||||
[data-component="assistant-throughput"],
|
||||
[data-slot="assistant-turn-meta"],
|
||||
[data-component="user-message"] [data-slot="user-message-meta-tail"] {
|
||||
/* Plain-text generation-speed value shown beside the copy/feedback buttons
|
||||
* on an assistant message. Renders as muted metadata — no icon, no
|
||||
* background, no border — so it reads as tertiary info that never
|
||||
* competes with the action row for visual weight. */
|
||||
* competes with the action row for visual weight. The hover timestamps on
|
||||
* both message types share this exact style so the action rows match. */
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--kilo-font-size-11);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { localeToBcp47, type Locale } from "../context/language-utils"
|
||||
|
||||
/**
|
||||
* Wall-clock time for chat-line metadata in the user's timezone and the UI
|
||||
* language's conventions (12h or 24h). Same Intl call as the user-message
|
||||
* stamp in kilo-ui and the TUI session view.
|
||||
*/
|
||||
export function formatClock(ms: number, locale: Locale): string {
|
||||
return new Intl.DateTimeFormat(localeToBcp47(locale), { timeStyle: "short" }).format(new Date(ms))
|
||||
}
|
||||
|
||||
/** Compact duration for chat-line metadata, for example "45s", "2m 5s", "1h 3m". */
|
||||
export function formatDuration(ms: number): string {
|
||||
const total = Math.max(0, Math.round(ms / 1000))
|
||||
const seconds = total % 60
|
||||
const minutes = Math.floor(total / 60) % 60
|
||||
const hours = Math.floor(total / 3600)
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`
|
||||
return `${seconds}s`
|
||||
}
|
||||
Reference in New Issue
Block a user