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..ca3cb153 --- /dev/null +++ b/src/lib/conversation-activity.test.ts @@ -0,0 +1,233 @@ +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("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", + ); + expect(summarizeActivity([])).toBe("Worked"); + }); +}); diff --git a/src/lib/conversation-activity.ts b/src/lib/conversation-activity.ts new file mode 100644 index 00000000..a066d6ed --- /dev/null +++ b/src/lib/conversation-activity.ts @@ -0,0 +1,127 @@ +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`; + 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 { + 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..b94e218c --- /dev/null +++ b/src/lib/media-kind.test.ts @@ -0,0 +1,50 @@ +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("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"); + }); + + 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..0d3ac447 --- /dev/null +++ b/src/lib/media-kind.ts @@ -0,0 +1,127 @@ +/** 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", +]); + +/** + * 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[], +): readonly (readonly [string, MediaKind])[] { + return extensions.map((extension) => [extension, kind] as const); +} + +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"; + 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. 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 { + const essence = essenceOf(mediaType); + if (TRANSPORT_STREAM_TYPES.has(essence)) return kindOfName(name) ?? "video"; + return kindOfMediaType(essence) ?? 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