Skip to content
Open
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
94 changes: 94 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -881,6 +886,18 @@ function renderFeedEntry(
);
}

if (entry.type === "proposed-plan") {
return (
<ProposedPlanCard
planMarkdown={entry.proposedPlan.planMarkdown}
iconSubtleColor={iconSubtleColor}
markdownStyles={markdownStyles.assistant}
onMarkdownLinkPress={props.onMarkdownLinkPress}
skills={props.skills}
/>
);
}

if (entry.type === "message") {
const { message } = entry;
const isUser = message.role === "user";
Expand Down Expand Up @@ -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<SelectableMarkdownSkill>;
}) {
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 (
<View className="mb-4 overflow-hidden rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900">
<View className="mb-3 flex-row items-center gap-2">
<View className="rounded-md bg-neutral-200 px-2 py-1 dark:bg-neutral-800">
<Text className="font-t3-medium text-xs text-neutral-700 dark:text-neutral-300">
Plan
</Text>
</View>
<Text
className="min-w-0 flex-1 font-t3-medium text-sm text-neutral-950 dark:text-neutral-50"
numberOfLines={1}
>
{title}
</Text>
<CopyTextButton
accessibilityLabel="Copy plan"
text={props.planMarkdown}
tintColor={props.iconSubtleColor}
buttonSize={32}
iconSize={15}
/>
</View>
{hasNativeSelectableMarkdownText() ? (
<SelectableMarkdownText
markdown={displayedMarkdown}
skills={props.skills}
textStyle={props.markdownStyles.nativeTextStyle}
onLinkPress={props.onMarkdownLinkPress}
/>
) : (
<Markdown
options={{ gfm: true }}
renderers={props.markdownStyles.renderers}
styles={props.markdownStyles.styles}
theme={props.markdownStyles.theme}
>
{displayedMarkdown}
</Markdown>
)}
{canCollapse ? (
<View className="mt-2 items-center">
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
accessibilityLabel={expanded ? "Collapse plan" : "Expand plan"}
hitSlop={6}
onPress={() => setExpanded((value) => !value)}
className="min-h-10 justify-center rounded-lg border border-neutral-300 px-3 dark:border-white/10"
>
<Text className="font-t3-medium text-sm text-neutral-800 dark:text-neutral-200">
{expanded ? "Collapse plan" : "Expand plan"}
</Text>
</Pressable>
</View>
) : null}
</View>
);
});

const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) {
const [nowMs, setNowMs] = useState(() => Date.now());

Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/features/threads/proposedPlan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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("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();
});

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...");
});
});
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/proposedPlan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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];
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();
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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");
}
68 changes: 68 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RawThreadFeedEntry, { type: "message" }>
| Extract<RawThreadFeedEntry, { type: "proposed-plan" }>
| {
readonly type: "working";
readonly id: string;
Expand Down Expand Up @@ -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<RawThreadFeedEntry>((proposedPlan) => ({
type: "proposed-plan",
id: proposedPlan.id,
createdAt: proposedPlan.createdAt,
proposedPlan,
})),
...workLogEntries
.filter((entry) => {
if (options?.loadedMessages === undefined) {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/proposedPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/proposedPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
11 changes: 11 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading