From a840ad49098df1c4b94a5a1b1fcdf32407f0b796 Mon Sep 17 00:00:00 2001 From: Tom Healy Date: Sun, 16 Aug 2026 21:52:48 +0100 Subject: [PATCH 1/2] fix(mobile): display proposed plans in thread feed --- .../src/features/threads/ThreadFeed.tsx | 94 +++++++++++++++++++ .../src/features/threads/proposedPlan.test.ts | 35 +++++++ .../src/features/threads/proposedPlan.ts | 62 ++++++++++++ apps/mobile/src/lib/threadActivity.test.ts | 68 ++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 23 +++++ docs/user/composer.md | 11 +++ 6 files changed, 293 insertions(+) create mode 100644 apps/mobile/src/features/threads/proposedPlan.test.ts create mode 100644 apps/mobile/src/features/threads/proposedPlan.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c5edb822ae5..df54ae2bc39 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -104,6 +104,11 @@ import { import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { useAssetUrl } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { + buildCollapsedProposedPlanPreviewMarkdown, + proposedPlanTitle, + stripDisplayedPlanMarkdown, +} from "./proposedPlan"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", @@ -881,6 +886,18 @@ function renderFeedEntry( ); } + if (entry.type === "proposed-plan") { + return ( + + ); + } + if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; @@ -1034,6 +1051,83 @@ function renderFeedEntry( ); } +const ProposedPlanCard = memo(function ProposedPlanCard(props: { + readonly planMarkdown: string; + readonly iconSubtleColor: ColorValue; + readonly markdownStyles: MarkdownStyleSet; + readonly onMarkdownLinkPress: (href: string) => void; + readonly skills?: ReadonlyArray; +}) { + const [expanded, setExpanded] = useState(false); + const title = proposedPlanTitle(props.planMarkdown) ?? "Proposed plan"; + const lineCount = props.planMarkdown.split("\n").length; + const canCollapse = props.planMarkdown.length > 900 || lineCount > 20; + const displayedPlanMarkdown = stripDisplayedPlanMarkdown(props.planMarkdown); + const collapsedPreview = canCollapse + ? buildCollapsedProposedPlanPreviewMarkdown(props.planMarkdown, { maxLines: 10 }) + : null; + const displayedMarkdown = + canCollapse && !expanded ? (collapsedPreview ?? "") : displayedPlanMarkdown; + + return ( + + + + + Plan + + + + {title} + + + + {hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {displayedMarkdown} + + )} + {canCollapse ? ( + + setExpanded((value) => !value)} + className="min-h-10 justify-center rounded-lg border border-neutral-300 px-3 dark:border-white/10" + > + + {expanded ? "Collapse plan" : "Expand plan"} + + + + ) : null} + + ); +}); + const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) { const [nowMs, setNowMs] = useState(() => Date.now()); diff --git a/apps/mobile/src/features/threads/proposedPlan.test.ts b/apps/mobile/src/features/threads/proposedPlan.test.ts new file mode 100644 index 00000000000..eae47344050 --- /dev/null +++ b/apps/mobile/src/features/threads/proposedPlan.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildCollapsedProposedPlanPreviewMarkdown, + proposedPlanTitle, + stripDisplayedPlanMarkdown, +} from "./proposedPlan"; + +describe("mobile proposed-plan presentation", () => { + it("separates the title from the displayed plan body", () => { + const markdown = "# Update Index Greeting\n\n## Summary\n\nReplace the old content."; + + expect(proposedPlanTitle(markdown)).toBe("Update Index Greeting"); + expect(stripDisplayedPlanMarkdown(markdown)).toBe("Replace the old content."); + }); + + it("falls back when the plan has no heading", () => { + expect(proposedPlanTitle("- inspect\n- update")).toBeNull(); + }); + + it("preserves a non-summary heading in the displayed body", () => { + expect( + stripDisplayedPlanMarkdown("# Update Index Greeting\n\n## Scope\n\n- Update the page."), + ).toBe("## Scope\n\n- Update the page."); + }); + + it("builds a bounded preview for long plans", () => { + expect( + buildCollapsedProposedPlanPreviewMarkdown( + "# Update Index Greeting\n\n- inspect\n- update\n- verify", + { maxLines: 2 }, + ), + ).toBe("- inspect\n- update\n\n..."); + }); +}); diff --git a/apps/mobile/src/features/threads/proposedPlan.ts b/apps/mobile/src/features/threads/proposedPlan.ts new file mode 100644 index 00000000000..6d81d7825ce --- /dev/null +++ b/apps/mobile/src/features/threads/proposedPlan.ts @@ -0,0 +1,62 @@ +// Keep these presentation rules aligned with apps/web/src/proposedPlan.ts; +// rendering and plan actions remain platform-specific. +export function proposedPlanTitle(planMarkdown: string): string | null { + const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); + return heading && heading.length > 0 ? heading : null; +} + +export function stripDisplayedPlanMarkdown(planMarkdown: string): string { + const lines = planMarkdown.trimEnd().split(/\r?\n/); + const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/); + if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") { + sourceLines.shift(); + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + } + return sourceLines.join("\n"); +} + +export function buildCollapsedProposedPlanPreviewMarkdown( + planMarkdown: string, + options?: { readonly maxLines?: number }, +): string { + const maxLines = options?.maxLines ?? 8; + const lines = stripDisplayedPlanMarkdown(planMarkdown) + .trimEnd() + .split(/\r?\n/) + .map((line) => line.trimEnd()); + const previewLines: string[] = []; + let visibleLineCount = 0; + let hasMoreContent = false; + + for (const line of lines) { + const isVisibleLine = line.trim().length > 0; + if (isVisibleLine && visibleLineCount >= maxLines) { + hasMoreContent = true; + break; + } + previewLines.push(line); + if (isVisibleLine) { + visibleLineCount += 1; + } + } + + while (previewLines.length > 0 && previewLines.at(-1)?.trim().length === 0) { + previewLines.pop(); + } + + if (previewLines.length === 0) { + return proposedPlanTitle(planMarkdown) ?? "Plan preview unavailable."; + } + + if (hasMoreContent) { + previewLines.push("", "..."); + } + + return previewLines.join("\n"); +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e..a47feb6114e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -151,6 +151,74 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps proposed plans visible when their settled turn is folded", () => { + const turnId = TurnId.make("turn-plan"); + const thread = makeThread({ + id: ThreadId.make("thread-plan"), + projectId: ProjectId.make("project-1"), + title: "Plan thread", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:18.000Z", + assistantMessageId: MessageId.make("assistant-commentary"), + }, + messages: [ + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "I will inspect the current implementation.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:03.000Z", + }, + ], + proposedPlans: [ + { + id: "plan-1", + turnId, + planMarkdown: "# Update Index Greeting\n\n- Update the page.\n- Verify the build.", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:17.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("tool-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { title: "Read files", itemType: "file_read", status: "completed" }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + expect(feed.map((entry) => entry.id)).toEqual([ + "assistant-commentary", + "tool-completed", + "plan-1", + ]); + expect(feed.at(-1)).toMatchObject({ + type: "proposed-plan", + proposedPlan: { planMarkdown: expect.stringContaining("Update Index Greeting") }, + }); + + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.map((entry) => entry.id)).toEqual([ + "turn-fold:turn-plan", + "assistant-commentary", + "plan-1", + ]); + }); + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2..78b039a3c95 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -97,10 +97,17 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; + } + | { + readonly type: "proposed-plan"; + readonly id: string; + readonly createdAt: string; + readonly proposedPlan: OrchestrationThread["proposedPlans"][number]; }; export type ThreadFeedEntry = | Extract + | Extract | { readonly type: "working"; readonly id: string; @@ -1529,6 +1536,22 @@ export function buildThreadFeed( createdAt: message.createdAt, message, })), + ...thread.proposedPlans + .filter((proposedPlan) => { + if (options?.loadedMessages === undefined) { + return true; + } + return ( + oldestLoadedMessageCreatedAt === null || + proposedPlan.createdAt >= oldestLoadedMessageCreatedAt + ); + }) + .map((proposedPlan) => ({ + type: "proposed-plan", + id: proposedPlan.id, + createdAt: proposedPlan.createdAt, + proposedPlan, + })), ...workLogEntries .filter((entry) => { if (options?.loadedMessages === undefined) { diff --git a/docs/user/composer.md b/docs/user/composer.md index d2e49db247b..c0545224e38 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -3,3 +3,14 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. + +## Plan Mode + +Plan Mode asks the agent to investigate and propose a complete implementation plan without changing +the project. Enable the legacy **Plan Mode** setting to restore the **Build/Plan** control, then choose +**Plan** for a new or existing thread. When the plan is ready, T3 Code displays it as a dedicated card +in the conversation. Long plans can be expanded or collapsed, and the card includes an action to copy +the complete Markdown plan. + +Switch the thread back to **Build** when you want the agent to make changes. Plan Mode is available +only for providers that advertise support for it. From a98254660f32bda2ca6bbea496cd5241f484ffc6 Mon Sep 17 00:00:00 2001 From: Tom Healy Date: Sun, 16 Aug 2026 23:21:52 +0100 Subject: [PATCH 2/2] fix(clients): handle leading plan whitespace --- apps/mobile/src/features/threads/proposedPlan.test.ts | 4 ++++ apps/mobile/src/features/threads/proposedPlan.ts | 8 +++++++- apps/web/src/proposedPlan.test.ts | 4 ++++ apps/web/src/proposedPlan.ts | 8 +++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/proposedPlan.test.ts b/apps/mobile/src/features/threads/proposedPlan.test.ts index eae47344050..31da21e99e1 100644 --- a/apps/mobile/src/features/threads/proposedPlan.test.ts +++ b/apps/mobile/src/features/threads/proposedPlan.test.ts @@ -14,6 +14,10 @@ describe("mobile proposed-plan presentation", () => { expect(stripDisplayedPlanMarkdown(markdown)).toBe("Replace the old content."); }); + it("ignores leading blank lines before removing the title and summary headings", () => { + expect(stripDisplayedPlanMarkdown("\n# Update Index\n\n## Summary\nBody")).toBe("Body"); + }); + it("falls back when the plan has no heading", () => { expect(proposedPlanTitle("- inspect\n- update")).toBeNull(); }); diff --git a/apps/mobile/src/features/threads/proposedPlan.ts b/apps/mobile/src/features/threads/proposedPlan.ts index 6d81d7825ce..7b6d31639cd 100644 --- a/apps/mobile/src/features/threads/proposedPlan.ts +++ b/apps/mobile/src/features/threads/proposedPlan.ts @@ -7,7 +7,13 @@ export function proposedPlanTitle(planMarkdown: string): string | null { export function stripDisplayedPlanMarkdown(planMarkdown: string): string { const lines = planMarkdown.trimEnd().split(/\r?\n/); - const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; + const sourceLines = [...lines]; + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + if (sourceLines[0] && /^\s{0,3}#{1,6}\s+/.test(sourceLines[0])) { + sourceLines.shift(); + } while (sourceLines[0]?.trim().length === 0) { sourceLines.shift(); } diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index 99732b5761c..3b0faf709d0 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -56,6 +56,10 @@ describe("stripDisplayedPlanMarkdown", () => { ); }); + it("ignores leading blank lines before removing the title and summary headings", () => { + expect(stripDisplayedPlanMarkdown("\n# Update Index\n\n## Summary\nBody")).toBe("Body"); + }); + it("preserves non-summary headings after dropping the title heading", () => { expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe( "## Scope\n\n- step 1", diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 48186392e8a..bc9602d2b58 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -5,7 +5,13 @@ export function proposedPlanTitle(planMarkdown: string): string | null { export function stripDisplayedPlanMarkdown(planMarkdown: string): string { const lines = planMarkdown.trimEnd().split(/\r?\n/); - const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; + const sourceLines = [...lines]; + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + if (sourceLines[0] && /^\s{0,3}#{1,6}\s+/.test(sourceLines[0])) { + sourceLines.shift(); + } while (sourceLines[0]?.trim().length === 0) { sourceLines.shift(); }