Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion apps/mobile/e2e/flows/phase4a-work-rows.yaml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
154 changes: 154 additions & 0 deletions apps/mobile/src/screens/dev/work-row-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import type {
TimelineCommandWorkRow,
TimelineConversationRow,
TimelineDelegationWorkRow,
TimelineExtensionWorkRow,
TimelineFileChangeWorkRow,
TimelineFileReadWorkRow,
TimelineImageViewWorkRow,
TimelinePlanStepsWorkRow,
TimelineQuestionWorkRow,
TimelineRow,
TimelineSearchWorkRow,
TimelineToolWorkRow,
TimelineWebFetchWorkRow,
TimelineWebSearchWorkRow,
Expand Down Expand Up @@ -110,6 +114,100 @@ function tool(
};
}

function fileRead(
id: string,
overrides: Partial<TimelineFileReadWorkRow> &
Pick<TimelineFileReadWorkRow, "path">,
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<TimelineSearchWorkRow> &
Pick<TimelineSearchWorkRow, "mode" | "query">,
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<TimelinePlanStepsWorkRow> &
Pick<TimelinePlanStepsWorkRow, "steps">,
): 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<TimelineExtensionWorkRow> &
Pick<TimelineExtensionWorkRow, "extensionKind" | "payload" | "presentation">,
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<TimelineFileChangeWorkRow> &
Expand Down Expand Up @@ -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."),
],
},
];
}
18 changes: 7 additions & 11 deletions apps/mobile/src/screens/thread/timeline/item-kind-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,6 +81,7 @@ export function ExpandableRowHeader({
title,
titleContent,
leadingIcon,
leadingIconColor,
expandable,
expanded,
onToggle,
Expand Down Expand Up @@ -114,7 +117,7 @@ export function ExpandableRowHeader({
<Icon
name={leadingIcon}
size={ROW_LEADING_ICON_SIZE}
color={tokens.mutedForeground}
color={leadingIconColor ?? tokens.mutedForeground}
/>
) : null}
<View className="min-w-0 flex-1">
Expand Down
Loading
Loading