From 8cc9ea04b4d34ffeb66157c3581790e6ad59f62d Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 16:19:55 +0000 Subject: [PATCH] Render the grammar v3 kinds on mobile from their declarative base (WS3 layer 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile renders the declarative base for every kind and loads no plugin JS (docs/provider-plugin-api.md §5). The G4 kind map had five WS3 fallbacks; each now names a registered row renderer. - `work:file-read` / `work:search`: title-only rows (the title builder already speaks the bridge label + structured content). - `work:plan-steps`: the snapshot's steps in the agent's order, a status glyph each, the explanation and the presentation detail above. - `work:extension`: header from the presentation (label, glyph, tint), body = the presentation `detail` as Markdown; expandable only with one. - `work:delegation`: the v3 delegation item folds onto the existing delegation renderer (layer 1 projects it there). - Every work row's leading glyph is the bridge's glyph when the host knows it, tinted per theme mode from `presentation.tint` (colour grammar checked); tool rows show the presentation detail above the call card. - `/dev/work-rows` showcase gains a presentation section (reads, searches, tinted tool, plan, extension, pending variants) and the Maestro `phase4a-work-rows` flow asserts each, with screenshots. The flow runs on the macOS `mobile-e2e` runner; this Linux box has no simulator. `turbo test --filter=@bb/mobile`: 841 passed (G4 exhaustiveness, glyph/tint model tests). --- apps/mobile/e2e/flows/phase4a-work-rows.yaml | 56 ++++++- .../src/screens/dev/work-row-fixtures.ts | 154 ++++++++++++++++++ .../screens/thread/timeline/item-kind-map.ts | 18 +- .../renderers/shared/ExpandableRowHeader.tsx | 5 +- .../renderers/work/PresentationWorkRows.tsx | 152 +++++++++++++++++ .../timeline/renderers/work/ToolWorkRow.tsx | 2 + .../timeline/renderers/work/WorkRowShell.tsx | 4 +- .../thread/timeline/renderers/work/index.ts | 10 ++ .../renderers/work/work-row-model.test.ts | 55 +++++++ .../timeline/renderers/work/work-row-model.ts | 43 ++++- 10 files changed, 483 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/src/screens/thread/timeline/renderers/work/PresentationWorkRows.tsx diff --git a/apps/mobile/e2e/flows/phase4a-work-rows.yaml b/apps/mobile/e2e/flows/phase4a-work-rows.yaml index 2ac20d0420..94bdcaa0b8 100644 --- a/apps/mobile/e2e/flows/phase4a-work-rows.yaml +++ b/apps/mobile/e2e/flows/phase4a-work-rows.yaml @@ -1,6 +1,7 @@ # Phase 4a work-row renderers: the dev showcase (/dev/work-rows, Settings → # Developer) renders synthetic command / tool / file-change / web / image / -# approval / question / delegation / workflow rows through the real list model +# approval / question / delegation / workflow / file-read / search / plan-steps +# / extension rows through the real list model # (grouping, compact intents, auto-expand). Checks a few expand/collapse paths # and the bodies they reveal, then opens the seeded "Rich thread" and expands # its real tool row. @@ -219,3 +220,56 @@ env: - assertVisible: id: "timeline-workflow-usage" - takeScreenshot: phase4a-work-rows-workflow-failed +# Presentation-driven rows (grammar v3, WS3): reads and searches are +# title-only rows labelled by the bridge ("Read file index.ts"), a tinted +# tool row opens to its presentation detail above the call card, the plan +# snapshot lists its steps with a status glyph each, and a plugin extension +# row renders from its declarative base alone (label + detail, no plugin JS). +- scrollUntilVisible: + element: + text: "(?s).*Read file.*index.ts.*" + direction: DOWN + timeout: 30000 + speed: 40 +- assertVisible: "(?s).*Reading file.*long-file-name.ts.*" +- assertVisible: "(?s).*Searched files.*for TODO in src.*" +- assertVisible: "(?s).*Found files.*matching \*\*/\*.test.ts.*" +- tapOn: + text: "(?s).*Stamped receipt.*" +- extendedWaitUntil: + visible: + id: "timeline-presentation-detail" + timeout: 10000 +- assertVisible: "(?s).*Stamped by the echo provider.*" +- takeScreenshot: phase4a-work-rows-presentation-tool +- scrollUntilVisible: + element: + text: "(?s).*Updated plan.*Wire the renderer.*" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + text: "(?s).*Updated plan.*Wire the renderer.*" +- extendedWaitUntil: + visible: + id: "timeline-plan-steps-body" + timeout: 10000 +- assertVisible: + id: "timeline-plan-step-active" +- assertVisible: + id: "timeline-plan-step-failed" +- assertVisible: "(?s).*Four steps, one failed.*" +- takeScreenshot: phase4a-work-rows-plan-steps +- scrollUntilVisible: + element: + text: "(?s).*Wrote receipt.*hello world.*" + direction: DOWN + timeout: 30000 + speed: 40 +- tapOn: + text: "(?s).*Wrote receipt.*hello world.*" +- extendedWaitUntil: + visible: "(?s).*Echoed 2 items.*shout off.*" + timeout: 10000 +- assertVisible: "(?s).*Writing receipt.*still echoing.*" +- takeScreenshot: phase4a-work-rows-extension diff --git a/apps/mobile/src/screens/dev/work-row-fixtures.ts b/apps/mobile/src/screens/dev/work-row-fixtures.ts index 33672350b8..7b8724bd54 100644 --- a/apps/mobile/src/screens/dev/work-row-fixtures.ts +++ b/apps/mobile/src/screens/dev/work-row-fixtures.ts @@ -4,10 +4,14 @@ import type { TimelineCommandWorkRow, TimelineConversationRow, TimelineDelegationWorkRow, + TimelineExtensionWorkRow, TimelineFileChangeWorkRow, + TimelineFileReadWorkRow, TimelineImageViewWorkRow, + TimelinePlanStepsWorkRow, TimelineQuestionWorkRow, TimelineRow, + TimelineSearchWorkRow, TimelineToolWorkRow, TimelineWebFetchWorkRow, TimelineWebSearchWorkRow, @@ -110,6 +114,100 @@ function tool( }; } +function fileRead( + id: string, + overrides: Partial & + Pick, + durationMs: number | null = 400, +): TimelineFileReadWorkRow { + const row = base(id, 3_000, durationMs); + return { + ...row, + kind: "work", + workKind: "file-read", + status: durationMs === null ? "pending" : "completed", + callId: `call-${id}`, + cmd: null, + completedAt: durationMs === null ? null : row.startedAt + durationMs, + presentation: { + label: { pending: "Reading file", completed: "Read file" }, + icon: { glyph: "FileText" }, + title: overrides.path.split("/").pop() ?? overrides.path, + }, + ...overrides, + }; +} + +function search( + id: string, + overrides: Partial & + Pick, + durationMs: number | null = 600, +): TimelineSearchWorkRow { + const row = base(id, 3_000, durationMs); + return { + ...row, + kind: "work", + workKind: "search", + status: durationMs === null ? "pending" : "completed", + callId: `call-${id}`, + path: null, + cmd: null, + completedAt: durationMs === null ? null : row.startedAt + durationMs, + presentation: { + label: + overrides.mode === "content" + ? { pending: "Searching files", completed: "Searched files" } + : { pending: "Finding files", completed: "Found files" }, + icon: { glyph: overrides.mode === "content" ? "Search" : "FolderOpen" }, + title: overrides.query, + }, + ...overrides, + }; +} + +function planSteps( + id: string, + overrides: Partial & + Pick, +): TimelinePlanStepsWorkRow { + const row = base(id, 4_000, 0); + const active = overrides.steps.find((step) => step.status === "active"); + return { + ...row, + kind: "work", + workKind: "plan-steps", + status: "completed", + callId: `call-${id}`, + explanation: null, + completedAt: row.startedAt, + presentation: { + label: { pending: "Updating plan", completed: "Updated plan" }, + icon: { glyph: "ListTodo" }, + ...(active ? { title: active.step } : {}), + }, + ...overrides, + }; +} + +function extension( + id: string, + overrides: Partial & + Pick, + durationMs: number | null = 1_500, +): TimelineExtensionWorkRow { + const row = base(id, 5_000, durationMs); + return { + ...row, + kind: "work", + workKind: "extension", + status: durationMs === null ? "pending" : "completed", + callId: `call-${id}`, + completedAt: durationMs === null ? null : row.startedAt + durationMs, + ...overrides, + }; +} + function fileChange( id: string, overrides: Partial & @@ -813,5 +911,61 @@ export function buildWorkRowFixtureSections(): WorkRowFixtureSection[] { assistant("a-done", "All done."), ], }, + { + title: + "Presentation-driven rows (grammar v3): reads, searches, plan, extension, tinted tool", + rows: [ + user("u-v3", "Show me every new row kind"), + fileRead("read-v3", { path: "src/index.ts" }), + fileRead("read-v3-pending", { path: "src/long-file-name.ts" }, null), + search("grep-v3", { mode: "content", query: "TODO", path: "src" }), + search("glob-v3", { mode: "path", query: "**/*.test.ts" }), + tool("tool-v3", { + toolName: "echo_stamp", + toolArgs: { text: "hello" }, + output: "stamped", + presentation: { + label: { pending: "Stamping receipt", completed: "Stamped receipt" }, + icon: { glyph: "Check" }, + tint: { light: "#1d4ed8", dark: "#93c5fd" }, + detail: "Stamped by the **echo** provider.", + }, + }), + planSteps("plan-v3", { + steps: [ + { step: "Read the spec", status: "completed" }, + { step: "Wire the renderer", status: "active" }, + { step: "Write tests", status: "pending" }, + { step: "Ship it", status: "failed" }, + ], + explanation: "Four steps, one failed.", + }), + extension("ext-v3", { + extensionKind: "echo-provider/receipt", + payload: { prompt: "hello world", itemCount: 2, shouted: false }, + presentation: { + label: { pending: "Writing receipt", completed: "Wrote receipt" }, + icon: { glyph: "MessageSquare" }, + title: "hello world", + detail: "Echoed **2** items · shout off", + tint: { light: "#9333EA", dark: "#D8B4FE" }, + }, + }), + extension( + "ext-v3-pending", + { + extensionKind: "echo-provider/receipt", + payload: {}, + presentation: { + label: { pending: "Writing receipt", completed: "Wrote receipt" }, + icon: { glyph: "MessageSquare" }, + title: "still echoing", + }, + }, + null, + ), + assistant("a-v3", "Every kind rendered from its presentation."), + ], + }, ]; } diff --git a/apps/mobile/src/screens/thread/timeline/item-kind-map.ts b/apps/mobile/src/screens/thread/timeline/item-kind-map.ts index 58dec6d869..59b02c753a 100644 --- a/apps/mobile/src/screens/thread/timeline/item-kind-map.ts +++ b/apps/mobile/src/screens/thread/timeline/item-kind-map.ts @@ -30,8 +30,8 @@ export const MOBILE_ITEM_KIND_MAP = { agentMessage: { row: "conversation:assistant" }, commandExecution: { row: "work:command" }, fileChange: { row: "work:file-change" }, - fileRead: { fallback: "WS3 (projection + renderers): file-read row" }, - search: { fallback: "WS3 (projection + renderers): search row" }, + fileRead: { row: "work:file-read" }, + search: { row: "work:search" }, webSearch: { row: "work:web-search" }, webFetch: { row: "work:web-fetch" }, imageView: { row: "work:image-view" }, @@ -41,17 +41,13 @@ export const MOBILE_ITEM_KIND_MAP = { "folded into the assistant conversation row by the server projection", }, plan: { row: "conversation:assistant" }, - planSteps: { fallback: "WS3 (projection + renderers): plan-steps row" }, + planSteps: { row: "work:plan-steps" }, contextCompaction: { row: "system" }, backgroundTask: { row: "work:workflow" }, - delegation: { - fallback: - "WS3 (projection + renderers): the v3 delegation item folds onto work:delegation", - }, - extension: { - fallback: - "WS3 (projection + renderers): declarative base from presentation", - }, + delegation: { row: "work:delegation" }, + // A plugin-defined kind: the declarative base (label, glyph, tint, + // detail) is the whole renderer; mobile loads no plugin JS. + extension: { row: "work:extension" }, } as const satisfies Record< CoreItemKind | "extension", MobileItemKindRendering diff --git a/apps/mobile/src/screens/thread/timeline/renderers/shared/ExpandableRowHeader.tsx b/apps/mobile/src/screens/thread/timeline/renderers/shared/ExpandableRowHeader.tsx index adebf51128..a63f4f1aa9 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/shared/ExpandableRowHeader.tsx +++ b/apps/mobile/src/screens/thread/timeline/renderers/shared/ExpandableRowHeader.tsx @@ -51,6 +51,8 @@ interface ExpandableRowHeaderProps { /** Replaces the generic title renderer for a specialized header. */ titleContent?: ReactNode; leadingIcon?: IconName; + /** Accent for the leading glyph (a bridge's tint); defaults to muted. */ + leadingIconColor?: string; expandable: boolean; expanded: boolean; onToggle: () => void; @@ -79,6 +81,7 @@ export function ExpandableRowHeader({ title, titleContent, leadingIcon, + leadingIconColor, expandable, expanded, onToggle, @@ -114,7 +117,7 @@ export function ExpandableRowHeader({ ) : null} diff --git a/apps/mobile/src/screens/thread/timeline/renderers/work/PresentationWorkRows.tsx b/apps/mobile/src/screens/thread/timeline/renderers/work/PresentationWorkRows.tsx new file mode 100644 index 0000000000..b960d1d44b --- /dev/null +++ b/apps/mobile/src/screens/thread/timeline/renderers/work/PresentationWorkRows.tsx @@ -0,0 +1,152 @@ +import type { ThreadEventPlanStep } from "@bb/domain"; +import type { TimelineRowPresentation } from "@bb/server-contract"; +import { View } from "react-native"; +import { Markdown } from "@/markdown"; +import { useTheme } from "@/theme"; +import { Icon, Text, type IconName } from "@/ui"; +import type { TimelineRowRendererProps } from "../../renderers"; +import { PAST_ROW_DIM_OPACITY } from "../shared/row-dim"; +import { WorkRowShell } from "./WorkRowShell"; +import { workRowPresentation } from "./work-row-model"; + +/** + * The declarative base for a row's body: the bridge's short Markdown + * `detail` (docs/provider-plugin-api.md §3), rendered read-only. Mobile loads + * no plugin JS, so this is every extension row's body and the lead-in of a + * presented tool/workflow body. + */ +export function PresentationDetail({ + presentation, +}: { + presentation: TimelineRowPresentation | undefined; +}) { + const detail = presentation?.detail; + if (detail === undefined || detail.trim().length === 0) { + return null; + } + return ( + + + + ); +} + +/** `work:file-read`: title-only, like the legacy Read intent rows. */ +export function FileReadWorkRow({ + item, + expanded, + onToggle, +}: TimelineRowRendererProps<"work:file-read">) { + return ( + + ); +} + +/** `work:search`: title-only, like the legacy Grep/Glob intent rows. */ +export function SearchWorkRow({ + item, + expanded, + onToggle, +}: TimelineRowRendererProps<"work:search">) { + return ( + + ); +} + +type PlanStepStatus = NonNullable; + +const PLAN_STEP_ICON: Record = { + pending: "Square", + active: "Square", + completed: "Check", + failed: "X", +}; + +/** + * `work:plan-steps`: the snapshot's steps in the agent's order with a status + * glyph each (the todo banner re-sorts; the row is the historical record). + */ +export function PlanStepsWorkRow({ + item, + expanded, + onToggle, +}: TimelineRowRendererProps<"work:plan-steps">) { + const { tokens } = useTheme(); + const row = item.row; + return ( + + + + {row.explanation ? ( + {row.explanation} + ) : null} + {row.steps.map((step, index) => { + const status = step.status ?? "pending"; + const settled = status === "completed" || status === "failed"; + return ( + + + + {step.step} + + + ); + })} + + + ); +} + +/** + * `work:extension`: a plugin-defined item rendered from its declarative base + * alone — label, glyph and tint in the header, the detail as the body. No + * plugin code runs on mobile, by design. + */ +export function ExtensionWorkRow({ + item, + expanded, + onToggle, +}: TimelineRowRendererProps<"work:extension">) { + return ( + + + + ); +} diff --git a/apps/mobile/src/screens/thread/timeline/renderers/work/ToolWorkRow.tsx b/apps/mobile/src/screens/thread/timeline/renderers/work/ToolWorkRow.tsx index 84dd82f2d6..901d08c16a 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/work/ToolWorkRow.tsx +++ b/apps/mobile/src/screens/thread/timeline/renderers/work/ToolWorkRow.tsx @@ -1,4 +1,5 @@ import type { TimelineRowRendererProps } from "../../renderers"; +import { PresentationDetail } from "./PresentationWorkRows"; import { ToolCallDetailBlock } from "./ToolCallDetailBlock"; import { WorkRowShell } from "./WorkRowShell"; @@ -20,6 +21,7 @@ export function ToolWorkRow({ expanded={expanded} onToggle={onToggle} > + { }); }); +describe("presentation-driven glyph and tint", () => { + const presentation = { + label: { pending: "Stamping receipt", completed: "Stamped receipt" }, + icon: { glyph: "Check" }, + tint: { light: "#1d4ed8", dark: "#93c5fd" }, + }; + + it("prefers the bridge's glyph when the host knows it, else the per-kind glyph", () => { + expect(leadingIconForWorkRow(toolRow("t", { presentation }))).toBe("Check"); + expect( + leadingIconForWorkRow( + toolRow("t", { + presentation: { ...presentation, icon: { glyph: "NotAGlyph" } }, + }), + ), + ).toBe("Terminal"); + // A skill read keeps its Zap even with a presentation glyph. + expect( + leadingIconForWorkRow( + toolRow("t", { + presentation, + activityIntents: [ + { + type: "read", + command: "Read", + name: "SKILL.md", + path: "/x/skills/deploy/SKILL.md", + }, + ], + }), + ), + ).toBe("Zap"); + }); + + it("picks the tint for the theme mode and refuses non-colour values", () => { + const row = toolRow("t", { presentation }); + expect(leadingIconTintForWorkRow(row, "light")).toBe("#1d4ed8"); + expect(leadingIconTintForWorkRow(row, "dark")).toBe("#93c5fd"); + expect(leadingIconTintForWorkRow(toolRow("t"), "light")).toBeUndefined(); + expect( + leadingIconTintForWorkRow( + toolRow("t", { + presentation: { + ...presentation, + tint: { light: "url(evil)", dark: "#fff" }, + }, + }), + "light", + ), + ).toBeUndefined(); + expect(leadingIconTintForWorkRow(approvalRow({}), "light")).toBeUndefined(); + }); +}); + describe("isPastWorkRow", () => { it("dims only completed rows", () => { expect(isPastWorkRow(commandRow("c", "ls"))).toBe(true); diff --git a/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts b/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts index e0d2c5f64d..7009548a8f 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts +++ b/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts @@ -15,6 +15,7 @@ import type { TimelineApprovalWorkRow, TimelineCommandWorkRow, TimelineQuestionWorkRow, + TimelineRowPresentation, TimelineToolArgs, TimelineWorkflowWorkRow, } from "@bb/server-contract"; @@ -25,7 +26,7 @@ import { type TimelineActivityIntentTitle, type TimelineViewWorkRow, } from "@bb/thread-view"; -import type { IconName } from "@/ui/icon-map"; +import { isIconName, type IconName } from "@/ui/icon-map"; import type { TimelineRowKind } from "../../rows"; /** @@ -69,13 +70,51 @@ export function leadingIconForActivityIntentTitle( } /** - * A leading glyph for every work row, keyed by kind so edits, explores and + * The bridge's persisted presentation for a work row (grammar v3), or + * undefined for rows bb authors itself and for rows persisted before + * presentation existed. The declarative base every client renders from. + */ +export function workRowPresentation( + row: TimelineViewWorkRow, +): TimelineRowPresentation | undefined { + if (row.workKind === "approval" || row.workKind === "question") { + return undefined; + } + return row.presentation; +} + +// A conservative CSS grammar (hex, functional notations, names); +// plugin data never reaches a style prop unchecked. +const TINT_COLOR_PATTERN = + /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\([-+.%\w\s,/]*\)|[a-z]{3,20})$/iu; + +/** + * The bridge's accent for the row's leading glyph in the current theme mode + * (`presentation.tint`), or undefined for the neutral row colour. + */ +export function leadingIconTintForWorkRow( + row: TimelineViewWorkRow, + mode: "light" | "dark", +): string | undefined { + const tint = workRowPresentation(row)?.tint; + if (tint === undefined) return undefined; + const value = tint[mode].trim(); + return TINT_COLOR_PATTERN.test(value) ? value : undefined; +} + +/** + * A leading glyph for every work row: the bridge's glyph when the host knows + * it (web `presentationIconName`), else keyed by kind so edits, explores and * commands read apart at a glance (web `leadingIconForWorkRow`). */ export function leadingIconForWorkRow(row: TimelineViewWorkRow): IconName { if ("activityIntents" in row && row.activityIntents.some(isSkillReadIntent)) { return "Zap"; } + const presentedGlyph = workRowPresentation(row)?.icon.glyph; + if (presentedGlyph !== undefined && isIconName(presentedGlyph)) { + return presentedGlyph; + } if ( row.workKind === "command" || row.workKind === "tool" ||