From 0b2c25f0bf383c7c9597327d8ba57a1828ae7298 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 21 Oct 2026 23:43:18 +0530 Subject: [PATCH 1/3] feat(conversation): add the chat data contract and its helpers Add src/lib/conversation.ts, the message model and part union every chat component reads: a tool part carrying an approval record beside the call, parallel tool batches, generated images, sources, a streaming status, replies and reactions. It reuses the chat-parts.ts members unchanged. Add groupActivity and summarizeActivity to fold thinking and tool steps into one run, mediaKindOf, and checkAttachments for count, size and duplicate limits. Add the --attention-wash token, mixed from --primary. --- src/index.ts | 4 + src/lib/attachment-limits.test.ts | 115 +++++++++++++ src/lib/attachment-limits.ts | 65 ++++++++ src/lib/conversation-activity.test.ts | 227 ++++++++++++++++++++++++++ src/lib/conversation-activity.ts | 123 ++++++++++++++ src/lib/conversation.ts | 167 +++++++++++++++++++ src/lib/media-kind.test.ts | 42 +++++ src/lib/media-kind.ts | 111 +++++++++++++ src/theme.css | 9 + 9 files changed, 863 insertions(+) create mode 100644 src/lib/attachment-limits.test.ts create mode 100644 src/lib/attachment-limits.ts create mode 100644 src/lib/conversation-activity.test.ts create mode 100644 src/lib/conversation-activity.ts create mode 100644 src/lib/conversation.ts create mode 100644 src/lib/media-kind.test.ts create mode 100644 src/lib/media-kind.ts diff --git a/src/index.ts b/src/index.ts index 8883b2f3..09beeda9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,10 @@ export * from "./lib/workflow-display-flow.js"; export * from "./lib/workflow-run.js"; export * from "./lib/workflow-run-error.js"; export * from "./lib/workflow-run-progress.js"; +export * from "./lib/conversation.js"; +export * from "./lib/conversation-activity.js"; +export * from "./lib/media-kind.js"; +export * from "./lib/attachment-limits.js"; export * from "./ui/agent-turn.js"; export * from "./ui/animated-number.js"; export * from "./ui/approval-card.js"; diff --git a/src/lib/attachment-limits.test.ts b/src/lib/attachment-limits.test.ts new file mode 100644 index 00000000..77d7f325 --- /dev/null +++ b/src/lib/attachment-limits.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; + +import { checkAttachments } from "./attachment-limits.js"; + +const file = (name: string, size = 10) => ({ name, size }); + +describe("checkAttachments", () => { + test("accepts everything when no limit applies", () => { + const incoming = [file("a"), file("b")]; + + expect(checkAttachments([], incoming, {})).toEqual({ + accepted: incoming, + rejected: [], + }); + }); + + test("rejects past the count limit, counting what is already attached", () => { + const result = checkAttachments( + [file("held")], + [file("a"), file("b"), file("c")], + { maxFiles: 3 }, + ); + + expect(result.accepted).toEqual([file("a"), file("b")]); + expect(result.rejected).toEqual([{ file: file("c"), reason: "too-many" }]); + }); + + test("rejects everything when the draft is already over the count limit", () => { + const result = checkAttachments([file("x"), file("y")], [file("a")], { + maxFiles: 1, + }); + + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([{ file: file("a"), reason: "too-many" }]); + }); + + test("rejects past the size limit", () => { + const result = checkAttachments( + [], + [file("small", 100), file("exact", 200), file("big", 201)], + { maxFileBytes: 200 }, + ); + + expect(result.accepted).toEqual([file("small", 100), file("exact", 200)]); + expect(result.rejected).toEqual([ + { file: file("big", 201), reason: "too-large" }, + ]); + }); + + test("rejects duplicates by name plus size, against held and incoming files", () => { + const result = checkAttachments( + [file("held", 5)], + [file("held", 5), file("held", 6), file("new"), file("new")], + {}, + ); + + expect(result.accepted).toEqual([file("held", 6), file("new")]); + expect(result.rejected).toEqual([ + { file: file("held", 5), reason: "duplicate" }, + { file: file("new"), reason: "duplicate" }, + ]); + }); + + test("a rejected file does not use up room or count as seen", () => { + const result = checkAttachments( + [], + [file("big", 999), file("a"), file("big", 999)], + { maxFiles: 1, maxFileBytes: 100 }, + ); + + expect(result.accepted).toEqual([file("a")]); + expect(result.rejected.map((rejection) => rejection.reason)).toEqual([ + "too-large", + "too-large", + ]); + }); + + test("keeps incoming order in both lists", () => { + const incoming = [ + file("one"), + file("huge", 500), + file("two"), + file("one"), + file("three"), + file("four"), + ]; + const result = checkAttachments([], incoming, { + maxFiles: 3, + maxFileBytes: 100, + }); + + expect(result.accepted.map((accepted) => accepted.name)).toEqual([ + "one", + "two", + "three", + ]); + expect( + result.rejected.map(({ file: rejected, reason }) => [ + rejected.name, + reason, + ]), + ).toEqual([ + ["huge", "too-large"], + ["one", "duplicate"], + ["four", "too-many"], + ]); + }); + + test("returns the caller's own file objects", () => { + const blob = new File(["hi"], "note.txt", { type: "text/plain" }); + const result = checkAttachments([], [blob], {}); + + expect(result.accepted[0]).toBe(blob); + }); +}); diff --git a/src/lib/attachment-limits.ts b/src/lib/attachment-limits.ts new file mode 100644 index 00000000..15ffc3c7 --- /dev/null +++ b/src/lib/attachment-limits.ts @@ -0,0 +1,65 @@ +/** What an attachment check needs to know about a file. `File` satisfies it. */ +export type AttachmentCandidate = { + readonly name: string; + readonly size: number; +}; + +/** An absent limit does not apply. */ +export type AttachmentLimits = { + /** Most files held at once, counting those already attached. */ + readonly maxFiles?: number; + /** Largest single file, in bytes. */ + readonly maxFileBytes?: number; +}; + +export type AttachmentRejectionReason = "too-many" | "too-large" | "duplicate"; + +export type AttachmentRejection = { + readonly file: T; + readonly reason: AttachmentRejectionReason; +}; + +export type AttachmentCheck = { + readonly accepted: readonly T[]; + readonly rejected: readonly AttachmentRejection[]; +}; + +function identity(file: AttachmentCandidate): string { + return `${file.size}\u0000${file.name}`; +} + +/** + * Which of `incoming` may join the `current` attachments. Each file is checked + * in turn: too large, then already attached (same name and size, against + * `current` and the files accepted before it), then past the count limit. + * Both lists keep the order `incoming` arrived in. + */ +export function checkAttachments( + current: readonly AttachmentCandidate[], + incoming: readonly T[], + limits: AttachmentLimits, +): AttachmentCheck { + const seen = new Set(current.map(identity)); + const room = + limits.maxFiles === undefined + ? Number.POSITIVE_INFINITY + : Math.max(0, limits.maxFiles - current.length); + const accepted: T[] = []; + const rejected: AttachmentRejection[] = []; + + for (const file of incoming) { + const key = identity(file); + if (limits.maxFileBytes !== undefined && file.size > limits.maxFileBytes) { + rejected.push({ file, reason: "too-large" }); + } else if (seen.has(key)) { + rejected.push({ file, reason: "duplicate" }); + } else if (accepted.length >= room) { + rejected.push({ file, reason: "too-many" }); + } else { + seen.add(key); + accepted.push(file); + } + } + + return { accepted, rejected }; +} diff --git a/src/lib/conversation-activity.test.ts b/src/lib/conversation-activity.test.ts new file mode 100644 index 00000000..ca37ce3e --- /dev/null +++ b/src/lib/conversation-activity.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from "bun:test"; + +import type { PartReasoning, PartText } from "./chat-parts.js"; +import type { + ConversationPart, + PartTool, + PartToolBatch, + ToolApprovalRecord, +} from "./conversation.js"; +import { + groupActivity, + isAwaitingApproval, + summarizeActivity, +} from "./conversation-activity.js"; + +const text = (value: string): PartText => ({ kind: "text", text: value }); + +const thought = (durationMs?: number): PartReasoning => + durationMs === undefined + ? { kind: "reasoning", text: "thinking" } + : { kind: "reasoning", text: "thinking", durationMs }; + +const tool = ( + id: string, + status: PartTool["status"] = "output-available", + approval?: ToolApprovalRecord, +): PartTool => + approval === undefined + ? { kind: "tool-trace", toolCallId: id, name: "search", status } + : { kind: "tool-trace", toolCallId: id, name: "search", status, approval }; + +const batch = (id: string, tools: readonly PartTool[]): PartToolBatch => ({ + kind: "tool-batch", + id, + tools, +}); + +const approval = ( + status: ToolApprovalRecord["status"], +): ToolApprovalRecord => ({ + id: "approval-1", + status, + scope: null, + timeoutAt: null, +}); + +describe("isAwaitingApproval", () => { + test("a pending record waits whatever the call's status says", () => { + expect(isAwaitingApproval(tool("a", "running", approval("pending")))).toBe( + true, + ); + }); + + test("a settled record does not wait even if the call still says approval-requested", () => { + expect( + isAwaitingApproval(tool("a", "approval-requested", approval("approved"))), + ).toBe(false); + }); + + test("without a record, approval-requested waits and other statuses do not", () => { + expect(isAwaitingApproval(tool("a", "approval-requested"))).toBe(true); + expect(isAwaitingApproval(tool("a", "running"))).toBe(false); + }); +}); + +describe("groupActivity", () => { + test("reasoning, tool and tool-batch parts between texts form one run", () => { + const steps = [ + thought(1000), + tool("a"), + batch("b", [tool("b1"), tool("b2")]), + ]; + const parts: ConversationPart[] = [text("before"), ...steps, text("after")]; + + expect(groupActivity(parts)).toEqual([ + { kind: "part", key: "text-0", part: text("before") }, + { kind: "activity", key: "activity-1", steps }, + { kind: "part", key: "text-4", part: text("after") }, + ]); + }); + + test("a text part ends a run", () => { + const groups = groupActivity([ + tool("a"), + text("mid"), + thought(), + tool("b"), + ]); + + expect(groups.map((group) => group.kind)).toEqual([ + "activity", + "part", + "activity", + ]); + expect(groups[0]).toEqual({ + kind: "activity", + key: "activity-0", + steps: [tool("a")], + }); + expect(groups[2]).toEqual({ + kind: "activity", + key: "activity-2", + steps: [thought(), tool("b")], + }); + }); + + test("a tool with a pending approval record stays out of the fold", () => { + const waiting = tool("w", "pending", approval("pending")); + const groups = groupActivity([thought(), waiting, tool("b")]); + + expect(groups).toEqual([ + { kind: "activity", key: "activity-0", steps: [thought()] }, + { kind: "part", key: "tool-trace-1", part: waiting }, + { kind: "activity", key: "activity-2", steps: [tool("b")] }, + ]); + }); + + test("a tool with no record and approval-requested status stays out of the fold", () => { + const waiting = tool("w", "approval-requested"); + const groups = groupActivity([tool("a"), waiting]); + + expect(groups).toEqual([ + { kind: "activity", key: "activity-0", steps: [tool("a")] }, + { kind: "part", key: "tool-trace-1", part: waiting }, + ]); + }); + + test("a resolved approval record folds even if the call still says approval-requested", () => { + const decided = tool("d", "approval-requested", approval("approved")); + + expect(groupActivity([decided])).toEqual([ + { kind: "activity", key: "activity-0", steps: [decided] }, + ]); + }); + + test("a batch holding a call that waits stays out of the fold", () => { + const held = batch("b", [tool("b1"), tool("b2", "approval-requested")]); + + expect(groupActivity([held])).toEqual([ + { kind: "part", key: "tool-batch-0", part: held }, + ]); + }); + + test("non-activity parts each stand alone", () => { + const attached: ConversationPart = { + kind: "file", + name: "a.txt", + mediaType: "text/plain", + }; + const cited: ConversationPart = { + kind: "sources", + sources: [{ title: "Doc", url: "https://example.com" }], + }; + + expect(groupActivity([attached, cited])).toEqual([ + { kind: "part", key: "file-0", part: attached }, + { kind: "part", key: "sources-1", part: cited }, + ]); + }); + + test("a resolved approval joins the runs on either side of it", () => { + const waiting = tool("w", "pending", approval("pending")); + const resolved = tool("w", "running", approval("approved")); + + expect(groupActivity([tool("a"), waiting, tool("b")])).toHaveLength(3); + expect(groupActivity([tool("a"), resolved, tool("b")])).toEqual([ + { + kind: "activity", + key: "activity-0", + steps: [tool("a"), resolved, tool("b")], + }, + ]); + }); + + test("a group's key stays put as a streaming run grows", () => { + const first = groupActivity([text("hi"), thought()]); + const later = groupActivity([text("hi"), thought(), tool("a"), tool("b")]); + + expect(first[1]?.key).toBe("activity-1"); + expect(later[1]?.key).toBe("activity-1"); + }); +}); + +describe("summarizeActivity", () => { + test("sums every known reasoning duration", () => { + expect(summarizeActivity([thought(1500), tool("a"), thought(2500)])).toBe( + "Thought for 4s · 1 step", + ); + }); + + test("counts each call in a batch as a step", () => { + expect( + summarizeActivity([ + thought(4000), + tool("a"), + batch("b", [tool("b1"), tool("b2")]), + ]), + ).toBe("Thought for 4s · 3 steps"); + }); + + test("omits the time when no duration is known", () => { + expect(summarizeActivity([thought(), tool("a"), tool("b")])).toBe( + "Thought · 2 steps", + ); + }); + + test("ignores reasoning with no duration when another has one", () => { + expect(summarizeActivity([thought(), thought(3000)])).toBe( + "Thought for 3s", + ); + }); + + test("rounds short thinking up to a second", () => { + expect(summarizeActivity([thought(200)])).toBe("Thought for 1s"); + }); + + test("formats durations past a minute", () => { + expect(summarizeActivity([thought(65_000)])).toBe("Thought for 1m 05s"); + }); + + test("reads as work when nothing was thought", () => { + expect(summarizeActivity([batch("b", [tool("b1"), tool("b2")])])).toBe( + "Worked through 2 steps", + ); + expect(summarizeActivity([])).toBe("Worked"); + }); +}); diff --git a/src/lib/conversation-activity.ts b/src/lib/conversation-activity.ts new file mode 100644 index 00000000..56a2c296 --- /dev/null +++ b/src/lib/conversation-activity.ts @@ -0,0 +1,123 @@ +import type { PartReasoning } from "./chat-parts.js"; +import type { + ConversationPart, + PartTool, + PartToolBatch, +} from "./conversation.js"; + +/** A part that folds into an activity run: thinking or tool work. */ +export type ActivityStep = PartReasoning | PartTool | PartToolBatch; + +/** + * A message's parts as they render: each stretch of thinking and tool work as + * one `activity` group, everything else as its own `part`. `key` is derived + * from the position the group starts at, so it stays put while a streaming + * message appends parts. A change that splits or joins runs (a call starting + * or stopping to wait on a person) re-keys the runs after it. + */ +export type ConversationPartGroup = + | { + readonly kind: "activity"; + readonly key: string; + readonly steps: readonly ActivityStep[]; + } + | { + readonly kind: "part"; + readonly key: string; + readonly part: ConversationPart; + }; + +/** + * Whether a tool call is waiting on a person. The approval record decides + * when there is one; without it, the call's own `approval-requested` status + * does. + */ +export function isAwaitingApproval(part: PartTool): boolean { + if (part.approval !== undefined) return part.approval.status === "pending"; + return part.status === "approval-requested"; +} + +function foldable(part: ConversationPart): part is ActivityStep { + switch (part.kind) { + case "reasoning": + return true; + case "tool-trace": + return !isAwaitingApproval(part); + case "tool-batch": + return !part.tools.some(isAwaitingApproval); + default: + return false; + } +} + +/** + * Folds consecutive thinking and tool parts into one activity group. Any other + * part ends the run. A call waiting on a person, alone or inside a batch, + * never folds: it stands as its own part so the question is never hidden. + */ +export function groupActivity( + parts: readonly ConversationPart[], +): readonly ConversationPartGroup[] { + const groups: ConversationPartGroup[] = []; + // The open run's steps; the group pushed for it holds this same array. + let run: ActivityStep[] | null = null; + + parts.forEach((part, index) => { + if (!foldable(part)) { + run = null; + groups.push({ kind: "part", key: `${part.kind}-${index}`, part }); + return; + } + if (run === null) { + run = []; + groups.push({ kind: "activity", key: `activity-${index}`, steps: run }); + } + run.push(part); + }); + + return groups; +} + +function formatDuration(ms: number): string { + const seconds = Math.max(1, Math.round(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`; +} + +function countSteps(steps: readonly ActivityStep[]): number { + return steps.reduce((total, step) => { + if (step.kind === "tool-batch") return total + step.tools.length; + if (step.kind === "tool-trace") return total + 1; + return total; + }, 0); +} + +/** + * The one line a folded run shows: "Thought for 4s · 3 steps". Thinking time + * sums every known reasoning duration and is left out when none is known; + * each call inside a batch counts as its own step. + */ +export function summarizeActivity(steps: readonly ActivityStep[]): string { + const reasoning = steps.filter( + (step): step is PartReasoning => step.kind === "reasoning", + ); + const known = reasoning.flatMap((step) => + step.durationMs === undefined ? [] : [step.durationMs], + ); + const thinking = + reasoning.length === 0 + ? null + : known.length === 0 + ? "Thought" + : `Thought for ${formatDuration(known.reduce((sum, ms) => sum + ms, 0))}`; + + const stepCount = countSteps(steps); + const work = + stepCount === 0 + ? null + : `${stepCount} ${stepCount === 1 ? "step" : "steps"}`; + + if (thinking === null) + return work === null ? "Worked" : `Worked through ${work}`; + return work === null ? thinking : `${thinking} · ${work}`; +} diff --git a/src/lib/conversation.ts b/src/lib/conversation.ts new file mode 100644 index 00000000..08132f74 --- /dev/null +++ b/src/lib/conversation.ts @@ -0,0 +1,167 @@ +/** + * The conversation model every chat component reads: who is talking, what a + * message holds, and how a tool call that waited on a person records that + * wait. + * + * A message is an ordered list of parts. The text, reasoning, block, file and + * event members are the ones `lib/chat-parts.ts` already defines; this module + * adds what a conversation needs beyond them: a tool call carrying its + * approval record, a batch of calls that ran at the same time, generated + * images and sources. + * + * Types only. Parsing untrusted data into this shape is the host's job, at its + * own trust boundary. + */ + +import type { + PartBlock, + PartEvent, + PartFile, + PartReasoning, + PartText, + PartToolTrace, +} from "./chat-parts.js"; + +/** A person or an agent taking part in a conversation. */ +export type ConversationParticipant = { + readonly id: string; + readonly kind: "person" | "agent"; + readonly name: string; + /** One line under an agent's name, e.g. "Research". */ + readonly role?: string; +}; + +/** + * `sending` and `failed` describe your own sends; `streaming` is an agent turn + * still arriving; everything delivered is `sent`. + */ +export type ConversationMessageStatus = + | "sending" + | "sent" + | "failed" + | "streaming"; + +export type ToolApprovalStatus = + | "pending" + | "approved" + | "rejected" + | "timeout" + | "expired"; + +/** How far a decision reaches: this call only, or every later call of the tool. */ +export type ToolApprovalScope = "once" | "always"; + +/** + * A person's decision on a tool call, kept beside the call rather than folded + * into its status: `ToolTraceStatus` describes the call's execution, this + * record describes the wait for a person. When both are present this record + * is authoritative for anything about approval. + */ +export type ToolApprovalRecord = { + readonly id: string; + readonly status: ToolApprovalStatus; + /** `null` until a decision records one. */ + readonly scope: ToolApprovalScope | null; + /** ISO deadline. `null` waits indefinitely. */ + readonly timeoutAt: string | null; +}; + +/** + * A tool call, with its approval record when it waited on a person. + * + * `status` describes only the call's execution. When `approval` is present it + * alone answers every approval question: a call whose record is `pending` is + * waiting on a person whatever `status` says, and one whose record is settled + * is not, even if `status` still reads `approval-requested`. Without a record, + * `status === "approval-requested"` is the only sign of a wait. + */ +export type PartTool = PartToolTrace & { + readonly approval?: ToolApprovalRecord; +}; + +/** Tool calls that ran at the same time. */ +export type PartToolBatch = { + readonly kind: "tool-batch"; + readonly id: string; + readonly tools: readonly PartTool[]; +}; + +/** + * One generated image, stored behind a URL or inline as base64. Exactly one of + * the two is set; `base64` is the bare payload, without a `data:` prefix + * (`mediaType` supplies it). + */ +export type ConversationImage = { + readonly id: string; + readonly mediaType: string; + readonly width: number; + readonly height: number; + readonly alt?: string; +} & ( + | { readonly url: string; readonly base64?: never } + | { readonly base64: string; readonly url?: never } +); + +/** Images an agent made. */ +export type PartImage = { + readonly kind: "image"; + readonly id: string; + readonly prompt: string; + readonly state: "generating" | "done" | "error"; + readonly images: readonly ConversationImage[]; + /** While generating: how many are coming and their size, so placeholders hold the space. */ + readonly expected?: { + readonly count: number; + readonly width: number; + readonly height: number; + }; +}; + +export type ConversationSource = { + readonly title: string; + readonly url: string; +}; + +/** Where an answer's facts came from. */ +export type PartSources = { + readonly kind: "sources"; + readonly sources: readonly ConversationSource[]; +}; + +export type ConversationPart = + | PartText + | PartReasoning + | PartTool + | PartToolBatch + | PartBlock + | PartFile + | PartEvent + | PartImage + | PartSources; + +export type ConversationReaction = { + readonly emoji: string; + readonly count: number; + /** Whether the viewer is one of `count`. */ + readonly mine: boolean; +}; + +/** What a message with replies shows about them. */ +export type ConversationRepliesSummary = { + readonly count: number; + /** ISO timestamp of the latest reply. */ + readonly lastAt: string; +}; + +export type ConversationMessage = { + readonly id: string; + readonly author: ConversationParticipant; + /** ISO timestamp. */ + readonly at: string; + readonly status: ConversationMessageStatus; + readonly parts: readonly ConversationPart[]; + /** Why a `failed` message failed, in words a person can act on. */ + readonly error?: string; + readonly replies?: ConversationRepliesSummary; + readonly reactions?: readonly ConversationReaction[]; +}; diff --git a/src/lib/media-kind.test.ts b/src/lib/media-kind.test.ts new file mode 100644 index 00000000..d4dfda00 --- /dev/null +++ b/src/lib/media-kind.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; + +import { mediaKindOf } from "./media-kind.js"; + +describe("mediaKindOf", () => { + test("reads each kind from its media type", () => { + expect(mediaKindOf("a", "image/png")).toBe("image"); + expect(mediaKindOf("a", "video/mp4")).toBe("video"); + expect(mediaKindOf("a", "audio/mpeg")).toBe("audio"); + expect(mediaKindOf("a", "application/pdf")).toBe("pdf"); + expect(mediaKindOf("a", "text/csv")).toBe("text"); + expect(mediaKindOf("a", "application/json")).toBe("text"); + }); + + test("the media type wins over the extension", () => { + expect(mediaKindOf("photo.txt", "image/png")).toBe("image"); + expect(mediaKindOf("clip.pdf", "video/webm")).toBe("video"); + }); + + test("ignores media type parameters and case", () => { + expect(mediaKindOf("a", "Text/Plain; charset=utf-8")).toBe("text"); + }); + + test("a blank or generic media type defers to the extension", () => { + expect(mediaKindOf("notes.md", "")).toBe("text"); + expect(mediaKindOf("song.mp3", "application/octet-stream")).toBe("audio"); + expect(mediaKindOf("report.pdf", "")).toBe("pdf"); + }); + + test("extensions match case-insensitively", () => { + expect(mediaKindOf("PHOTO.JPG", "")).toBe("image"); + expect(mediaKindOf("Clip.MoV", "")).toBe("video"); + }); + + test("unknown falls back to other", () => { + expect(mediaKindOf("archive.zip", "application/zip")).toBe("other"); + expect(mediaKindOf("README", "")).toBe("other"); + expect(mediaKindOf(".png", "")).toBe("other"); + expect(mediaKindOf("constructor", "")).toBe("other"); + expect(mediaKindOf("a.constructor", "")).toBe("other"); + }); +}); diff --git a/src/lib/media-kind.ts b/src/lib/media-kind.ts new file mode 100644 index 00000000..0f3c5cea --- /dev/null +++ b/src/lib/media-kind.ts @@ -0,0 +1,111 @@ +/** What a file is, for how it previews. */ +export type MediaKind = "image" | "video" | "audio" | "pdf" | "text" | "other"; + +const EXTENSION_KINDS: ReadonlyMap = new Map([ + ...table("image", [ + "apng", + "avif", + "bmp", + "gif", + "heic", + "heif", + "ico", + "jpeg", + "jpg", + "png", + "svg", + "tif", + "tiff", + "webp", + ]), + ...table("video", ["avi", "m4v", "mkv", "mov", "mp4", "mpeg", "ogv", "webm"]), + ...table("audio", [ + "aac", + "flac", + "m4a", + "mp3", + "oga", + "ogg", + "opus", + "wav", + "weba", + ]), + ["pdf", "pdf"] as const, + ...table("text", [ + "c", + "cpp", + "css", + "csv", + "go", + "h", + "html", + "ini", + "java", + "js", + "json", + "jsx", + "log", + "md", + "markdown", + "py", + "rb", + "rs", + "sh", + "sql", + "toml", + "ts", + "tsv", + "tsx", + "txt", + "xml", + "yaml", + "yml", + ]), +]); + +/** Text media types outside `text/*`. */ +const TEXT_MEDIA_TYPES: ReadonlySet = new Set([ + "application/javascript", + "application/json", + "application/ld+json", + "application/toml", + "application/x-sh", + "application/xml", + "application/yaml", +]); + +function table( + kind: MediaKind, + extensions: readonly string[], +): readonly (readonly [string, MediaKind])[] { + return extensions.map((extension) => [extension, kind] as const); +} + +function kindOfMediaType(mediaType: string): MediaKind | null { + const essence = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""; + if (essence.startsWith("image/")) return "image"; + if (essence.startsWith("video/")) return "video"; + if (essence.startsWith("audio/")) return "audio"; + if (essence === "application/pdf") return "pdf"; + if (essence.startsWith("text/") || TEXT_MEDIA_TYPES.has(essence)) { + return "text"; + } + return null; +} + +function kindOfName(name: string): MediaKind | null { + const dot = name.lastIndexOf("."); + if (dot <= 0) return null; + const extension = name.slice(dot + 1).toLowerCase(); + return EXTENSION_KINDS.get(extension) ?? null; +} + +/** + * A file's kind from its name and media type. A media type that names a kind + * wins; a blank or generic one (`application/octet-stream`) defers to the + * name's extension, matched case-insensitively. Anything unrecognised is + * `other`. + */ +export function mediaKindOf(name: string, mediaType: string): MediaKind { + return kindOfMediaType(mediaType) ?? kindOfName(name) ?? "other"; +} diff --git a/src/theme.css b/src/theme.css index 37b7e322..ebf0bebb 100644 --- a/src/theme.css +++ b/src/theme.css @@ -281,6 +281,14 @@ ); } +/* The ground under something waiting on the person, such as a tool call + asking for approval. Derived from --primary like the sidebar wash above, so + every theme and mode follows its own orange; 12% reads as a tint on both the + light and the dark ground. */ +:root { + --attention-wash: color-mix(in srgb, var(--primary) 12%, transparent); +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -309,6 +317,7 @@ --color-ring: var(--ring); --color-ok: var(--ok); --color-warn: var(--warn); + --color-attention-wash: var(--attention-wash); --shadow-surface: var(--shadow); /* The brand faces are named directly and are not bundled — the package ships From cb5170addd10e4153e59e8a8b2cb7939e9dabee3 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 18:36:33 +0545 Subject: [PATCH 2/3] fix(conversation): format thinking past an hour in hours summarizeActivity only knew seconds and minutes, so an hour of thinking read "Thought for 60m 00s". Durations of an hour or more now read as hours and minutes ("1h 30m"); shorter ones are unchanged. --- src/lib/conversation-activity.test.ts | 6 ++++++ src/lib/conversation-activity.ts | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/conversation-activity.test.ts b/src/lib/conversation-activity.test.ts index ca37ce3e..ca3cb153 100644 --- a/src/lib/conversation-activity.test.ts +++ b/src/lib/conversation-activity.test.ts @@ -218,6 +218,12 @@ describe("summarizeActivity", () => { expect(summarizeActivity([thought(65_000)])).toBe("Thought for 1m 05s"); }); + test("formats durations past an hour in hours and minutes", () => { + expect(summarizeActivity([thought(3_599_000)])).toBe("Thought for 59m 59s"); + expect(summarizeActivity([thought(3_600_000)])).toBe("Thought for 1h 00m"); + expect(summarizeActivity([thought(5_400_000)])).toBe("Thought for 1h 30m"); + }); + test("reads as work when nothing was thought", () => { expect(summarizeActivity([batch("b", [tool("b1"), tool("b2")])])).toBe( "Worked through 2 steps", diff --git a/src/lib/conversation-activity.ts b/src/lib/conversation-activity.ts index 56a2c296..a066d6ed 100644 --- a/src/lib/conversation-activity.ts +++ b/src/lib/conversation-activity.ts @@ -81,7 +81,11 @@ export function groupActivity( function formatDuration(ms: number): string { const seconds = Math.max(1, Math.round(ms / 1000)); if (seconds < 60) return `${seconds}s`; - return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; + } + return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`; } function countSteps(steps: readonly ActivityStep[]): number { From b98472e9ef23f0b006ba2415944bc2f253cdb924 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:35:10 +0545 Subject: [PATCH 3/3] fix(conversation): read a .ts file sent as a transport stream as text mediaKindOf let any video/* media type win over the extension. Servers and desktop browsers report a TypeScript file as an MPEG transport stream (video/mp2t, or video/vnd.dlna.mpeg-tts on Windows), since both use .ts, so index.ts read as video and previewed as a broken player. Those two types now defer to the extension, and stay video when the name has no known extension. --- src/lib/media-kind.test.ts | 8 ++++++++ src/lib/media-kind.ts | 26 +++++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/lib/media-kind.test.ts b/src/lib/media-kind.test.ts index d4dfda00..b94e218c 100644 --- a/src/lib/media-kind.test.ts +++ b/src/lib/media-kind.test.ts @@ -17,6 +17,14 @@ describe("mediaKindOf", () => { expect(mediaKindOf("clip.pdf", "video/webm")).toBe("video"); }); + test("a transport stream media type defers to the extension", () => { + expect(mediaKindOf("index.ts", "video/mp2t")).toBe("text"); + expect(mediaKindOf("index.ts", "Video/MP2T; x=1")).toBe("text"); + expect(mediaKindOf("index.ts", "video/vnd.dlna.mpeg-tts")).toBe("text"); + expect(mediaKindOf("capture", "video/mp2t")).toBe("video"); + expect(mediaKindOf("capture.m2ts", "video/mp2t")).toBe("video"); + }); + test("ignores media type parameters and case", () => { expect(mediaKindOf("a", "Text/Plain; charset=utf-8")).toBe("text"); }); diff --git a/src/lib/media-kind.ts b/src/lib/media-kind.ts index 0f3c5cea..0d3ac447 100644 --- a/src/lib/media-kind.ts +++ b/src/lib/media-kind.ts @@ -74,6 +74,15 @@ const TEXT_MEDIA_TYPES: ReadonlySet = new Set([ "application/yaml", ]); +/** + * MPEG transport stream types. They share `.ts` with TypeScript, and servers + * and desktop browsers report them for a TypeScript file, so the name decides. + */ +const TRANSPORT_STREAM_TYPES: ReadonlySet = new Set([ + "video/mp2t", + "video/vnd.dlna.mpeg-tts", +]); + function table( kind: MediaKind, extensions: readonly string[], @@ -81,8 +90,11 @@ function table( return extensions.map((extension) => [extension, kind] as const); } -function kindOfMediaType(mediaType: string): MediaKind | null { - const essence = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""; +function essenceOf(mediaType: string): string { + return mediaType.split(";")[0]?.trim().toLowerCase() ?? ""; +} + +function kindOfMediaType(essence: string): MediaKind | null { if (essence.startsWith("image/")) return "image"; if (essence.startsWith("video/")) return "video"; if (essence.startsWith("audio/")) return "audio"; @@ -103,9 +115,13 @@ function kindOfName(name: string): MediaKind | null { /** * A file's kind from its name and media type. A media type that names a kind * wins; a blank or generic one (`application/octet-stream`) defers to the - * name's extension, matched case-insensitively. Anything unrecognised is - * `other`. + * name's extension, matched case-insensitively. An MPEG transport stream + * type (`video/mp2t`) also defers to the extension, since a TypeScript file + * shares `.ts` and often arrives with it, and is `video` when the extension + * says nothing. Anything unrecognised is `other`. */ export function mediaKindOf(name: string, mediaType: string): MediaKind { - return kindOfMediaType(mediaType) ?? kindOfName(name) ?? "other"; + const essence = essenceOf(mediaType); + if (TRANSPORT_STREAM_TYPES.has(essence)) return kindOfName(name) ?? "video"; + return kindOfMediaType(essence) ?? kindOfName(name) ?? "other"; }