@@ -6884,12 +6974,14 @@ function ChatViewContent(props: ChatViewProps) {
onAddFiles={addFilesSurface}
onAddPullRequest={addPullRequestSurface}
onAddAgents={addAgentsSurface}
+ onAddPlan={addPlanSurface}
browserAvailable={isPreviewSupportedInRuntime()}
terminalAvailable={activeProject !== null}
diffAvailable={isServerThread && isGitRepo}
filesAvailable={activeProject !== null}
pullRequestAvailable={pullRequestSurfaceAvailable}
agentsAvailable
+ planAvailable={activeThreadRef !== null}
pullRequestStatuses={pullRequestTabStatuses}
liveAgentCount={agentPanelModel.liveCount}
>
diff --git a/apps/web/src/components/PlanPanel.test.tsx b/apps/web/src/components/PlanPanel.test.tsx
new file mode 100644
index 000000000000..f401c5a7444d
--- /dev/null
+++ b/apps/web/src/components/PlanPanel.test.tsx
@@ -0,0 +1,44 @@
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+
+import type { ActivePlanState } from "../session-logic";
+import { PlanPanel } from "./PlanPanel";
+
+const threadRef = scopeThreadRef("environment-1" as EnvironmentId, ThreadId.make("thread-1"));
+
+function renderPlan(activePlan: ActivePlanState | null) {
+ return renderToStaticMarkup(
+
,
+ );
+}
+
+describe("PlanPanel", () => {
+ it("shows current progress, step status, and completed-step duration", () => {
+ const html = renderPlan({
+ createdAt: "2026-08-23T00:00:00.000Z",
+ turnId: null,
+ explanation: "Implementing the remote flow",
+ steps: [
+ { step: "Update the contract", status: "completed", durationMs: 12_000 },
+ { step: "Handle reconnects", status: "inProgress" },
+ { step: "Add integration coverage", status: "pending" },
+ ],
+ });
+
+ expect(html).toContain('data-plan-panel="true"');
+ expect(html).toContain("Working · 1/3");
+ expect(html).toContain("Update the contract");
+ expect(html).toContain("12s");
+ expect(html).toContain("Handle reconnects");
+ expect(html).toContain("Implementing the remote flow");
+ });
+
+ it("explains when a thread has no plan yet", () => {
+ const html = renderPlan(null);
+
+ expect(html).toContain("No active plan");
+ expect(html).toContain("Plans and progress updates from the agent will appear here.");
+ });
+});
diff --git a/apps/web/src/components/PlanPanel.tsx b/apps/web/src/components/PlanPanel.tsx
new file mode 100644
index 000000000000..edf2c2d88354
--- /dev/null
+++ b/apps/web/src/components/PlanPanel.tsx
@@ -0,0 +1,180 @@
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { CheckIcon, CircleIcon, ListTodoIcon } from "lucide-react";
+
+import { proposedPlanTitle } from "../proposedPlan";
+import type { ActivePlanState, LatestProposedPlanState } from "../session-logic";
+import ChatMarkdown from "./ChatMarkdown";
+import { cn } from "~/lib/utils";
+import { ScrollArea } from "./ui/scroll-area";
+
+interface PlanPanelProps {
+ activePlan: ActivePlanState | null;
+ proposedPlan: LatestProposedPlanState | null;
+ cwd: string | undefined;
+ threadRef: ScopedThreadRef;
+}
+
+function formatDuration(durationMs: number): string {
+ const seconds = Math.max(1, Math.round(durationMs / 1000));
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return remainingSeconds === 0 ? `${minutes}m` : `${minutes}m ${remainingSeconds}s`;
+}
+
+function PlanStep({ step }: { step: ActivePlanState["steps"][number] }) {
+ const completed = step.status === "completed";
+ const current = step.status === "inProgress";
+ return (
+
+
+ {completed ? (
+
+ ) : current ? (
+
+ ) : (
+
+ )}
+
+ {step.step}
+ {step.durationMs !== undefined ? (
+
+ {formatDuration(step.durationMs)}
+
+ ) : null}
+
+ );
+}
+
+function EmptyPlanState() {
+ return (
+
+
+
+
No active plan
+
+ Plans and progress updates from the agent will appear here.
+
+
+
+ );
+}
+
+export function PlanPanel({ activePlan, proposedPlan, cwd, threadRef }: PlanPanelProps) {
+ if (!activePlan && !proposedPlan) {
+ return
;
+ }
+
+ const planTitle = proposedPlan
+ ? (proposedPlanTitle(proposedPlan.planMarkdown) ?? "Implementation plan")
+ : "Current plan";
+
+ if (!activePlan && proposedPlan) {
+ return (
+
+
+
+
+
+
{planTitle}
+
Proposed plan · not started
+
+
+
+
+
+ );
+ }
+
+ if (!activePlan) {
+ return
;
+ }
+
+ const completedCount = activePlan.steps.filter((step) => step.status === "completed").length;
+ const currentStep =
+ activePlan.steps.find((step) => step.status === "inProgress") ??
+ activePlan.steps.find((step) => step.status === "pending");
+ const allComplete = activePlan.steps.length > 0 && completedCount === activePlan.steps.length;
+ const status = allComplete
+ ? "Complete"
+ : currentStep
+ ? `Working · ${completedCount}/${activePlan.steps.length}`
+ : "Waiting for the agent";
+ const occurrences = new Map
();
+ const keyedSteps = activePlan.steps.map((step) => {
+ const occurrence = occurrences.get(step.step) ?? 0;
+ occurrences.set(step.step, occurrence + 1);
+ return { key: `${step.step}:${occurrence}`, step };
+ });
+
+ return (
+
+
+
+
+
+
{planTitle}
+
+
+ {status}
+
+
+
+
+
+
+ {keyedSteps.map(({ key, step }) => (
+
+ ))}
+
+
+
+ {currentStep ? currentStep.step : allComplete ? "All steps complete" : "Plan ready"}
+
+
+ {completedCount}/{activePlan.steps.length}
+
+
+
+
+
+ {keyedSteps.map(({ key, step }) => (
+
+ ))}
+
+
+ {activePlan.explanation ? (
+
+ {activePlan.explanation}
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx
index 7b0ae9b4c201..480b8f9b1548 100644
--- a/apps/web/src/components/RightPanelTabs.test.tsx
+++ b/apps/web/src/components/RightPanelTabs.test.tsx
@@ -109,6 +109,7 @@ function renderTabs(
onAddDiff={() => undefined}
onAddFiles={() => undefined}
onAddAgents={() => undefined}
+ onAddPlan={() => undefined}
liveAgentCount={0}
browserAvailable
terminalAvailable={false}
@@ -116,6 +117,7 @@ function renderTabs(
filesAvailable={false}
pullRequestAvailable={false}
agentsAvailable={false}
+ planAvailable={false}
>
content
,
diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx
index 5cc421db3542..34e655686091 100644
--- a/apps/web/src/components/RightPanelTabs.tsx
+++ b/apps/web/src/components/RightPanelTabs.tsx
@@ -6,6 +6,7 @@ import {
Files,
GitPullRequest,
Globe2,
+ ListTodo,
Plus,
TerminalSquare,
Volume2,
@@ -74,12 +75,14 @@ interface RightPanelTabsProps {
onAddFiles: () => void;
onAddPullRequest: () => void;
onAddAgents: () => void;
+ onAddPlan: () => void;
browserAvailable: boolean;
terminalAvailable: boolean;
diffAvailable: boolean;
filesAvailable: boolean;
pullRequestAvailable: boolean;
agentsAvailable: boolean;
+ planAvailable: boolean;
pullRequestStatuses?: Readonly>;
/** Running + waiting subagents; badges the Agents card in the empty state. */
liveAgentCount: number;
@@ -101,6 +104,7 @@ const SURFACE_DISABLED_REASONS = {
diff: "Diff is only available for server threads in Git repositories.",
pullRequest: "This thread's branch has no pull request yet.",
agents: "Agents are only available from a thread.",
+ plan: "Plans are only available from a thread.",
} as const;
/** Overlays that must win over the launcher's letter shortcuts. */
@@ -123,6 +127,7 @@ const SURFACE_UNAVAILABLE_HINTS = {
diff: "Available for Git repositories.",
pullRequest: "No pull request on this branch yet.",
agents: "Available from a thread.",
+ plan: "Available from a thread.",
} as const;
type TabContextMenuAction =
@@ -252,12 +257,14 @@ function RightPanelEmptyState(props: {
onAddFiles: () => void;
onAddPullRequest: () => void;
onAddAgents: () => void;
+ onAddPlan: () => void;
browserAvailable: boolean;
terminalAvailable: boolean;
diffAvailable: boolean;
filesAvailable: boolean;
pullRequestAvailable: boolean;
agentsAvailable: boolean;
+ planAvailable: boolean;
liveAgentCount: number;
}) {
// -1 means no highlight: it only appears on hover or arrow use.
@@ -324,6 +331,16 @@ function RightPanelEmptyState(props: {
onClick: props.onAddAgents,
badgeCount: props.liveAgentCount,
},
+ {
+ label: "Plan",
+ description: "Track the agent's current plan.",
+ icon: ListTodo,
+ shortcut: "L",
+ available: props.planAvailable,
+ disabledReason: SURFACE_UNAVAILABLE_HINTS.plan,
+ onClick: props.onAddPlan,
+ badgeCount: 0,
+ },
] as const;
type SurfaceAction = (typeof actions)[number];
@@ -508,6 +525,8 @@ function surfaceTitle(
return `#${surface.number}`;
case "agents":
return "Agents";
+ case "plan":
+ return "Plan";
case "preview": {
const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";
@@ -593,6 +612,8 @@ function SurfaceIcon({
}
case "agents":
return ;
+ case "plan":
+ return ;
}
}
@@ -651,6 +672,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
disabledReason: SURFACE_DISABLED_REASONS.agents,
onClick: props.onAddAgents,
},
+ {
+ label: "Plan",
+ icon: ListTodo,
+ shortcut: "L",
+ available: props.planAvailable,
+ disabledReason: SURFACE_DISABLED_REASONS.plan,
+ onClick: props.onAddPlan,
+ },
] as const;
const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => {
@@ -943,12 +972,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
onAddFiles={props.onAddFiles}
onAddPullRequest={props.onAddPullRequest}
onAddAgents={props.onAddAgents}
+ onAddPlan={props.onAddPlan}
browserAvailable={props.browserAvailable}
terminalAvailable={props.terminalAvailable}
diffAvailable={props.diffAvailable}
filesAvailable={props.filesAvailable}
pullRequestAvailable={props.pullRequestAvailable}
agentsAvailable={props.agentsAvailable}
+ planAvailable={props.planAvailable}
liveAgentCount={props.liveAgentCount}
/>
) : (
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index af7cdc8a94a2..ecf154edda6b 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -435,19 +435,19 @@ function SnoozePopoverButton(props: {
);
}
-// Subset of useSortable applied to a pinned card's root . Listeners go
-// on the whole card (no dedicated handle): the pointer sensor's distance
+// Subset of useSortable applied to a thread row's root . Listeners go
+// on the whole row (no dedicated handle): the pointer sensor's distance
// constraint keeps plain clicks working, and we skip dnd-kit's aria
// attributes since there is no keyboard sensor and the card body already
// carries its own button semantics.
-type SortablePinnedRowBag = Pick<
+type SortableThreadRowBag = Pick<
ReturnType,
"listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
>;
-function SortablePinnedThreadRow(props: {
+function SortableThreadRow(props: {
id: string;
- children: (bag: SortablePinnedRowBag) => ReactNode;
+ children: (bag: SortableThreadRowBag) => ReactNode;
}) {
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
@@ -710,10 +710,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// the descriptor is not loaded. Pinning itself lives in the context menu.
pinningSupported: boolean;
isPinned: boolean;
- // Present only on pinned cards whose server supports reordering: dnd-kit
- // sortable bag applied to the card root so the whole card drags (the
- // pointer sensor's distance constraint keeps plain clicks working).
- sortable?: SortablePinnedRowBag | undefined;
+ // Present only on rows that can be reordered: dnd-kit applies the sortable
+ // bag to the card root so the whole card drags (the pointer sensor's
+ // distance constraint keeps plain clicks working).
+ sortable?: SortableThreadRowBag | undefined;
// Compact wake countdown ("2h") for rows in the snoozed shelf.
snoozeWakeLabelText: string | null;
// When a snooze ended (timer or early wake); drives the Woke pill until
@@ -1710,6 +1710,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
export default function Sidebar() {
const projects = useProjects();
const projectOrder = useUiStateStore((store) => store.projectOrder);
+ const threadOrder = useUiStateStore((store) => store.threadOrder);
+ const reorderThreadOrder = useUiStateStore((store) => store.reorderThreads);
const threads = useThreadShells();
const router = useRouter();
const { isMobile, setOpenMobile } = useSidebar();
@@ -2576,6 +2578,29 @@ export default function Sidebar() {
override holds until all of them appear in canonical state. */
readonly assignedKeys: ReadonlyMap;
} | null>(null);
+ const activeThreadKeys = useMemo(
+ () =>
+ activeThreads.map((thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ ),
+ [activeThreads],
+ );
+ const currentActiveThreadOrder = useMemo(() => {
+ const activeKeys = new Set(activeThreadKeys);
+ const savedOrder = threadOrder.filter((threadKey) => activeKeys.has(threadKey));
+ const savedKeys = new Set(savedOrder);
+ const newThreadKeys = activeThreadKeys.filter((threadKey) => !savedKeys.has(threadKey));
+ return [...newThreadKeys, ...savedOrder];
+ }, [activeThreadKeys, threadOrder]);
+ const orderedActiveThreads = useMemo(
+ () =>
+ orderItemsByPreferredIds({
+ items: activeThreads,
+ preferredIds: currentActiveThreadOrder,
+ getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ }),
+ [activeThreads, currentActiveThreadOrder],
+ );
const orderedPinnedThreads = useMemo(() => {
if (optimisticPinnedOrder === null) return pinnedThreads;
return orderItemsByPreferredIds({
@@ -2729,6 +2754,24 @@ export default function Sidebar() {
},
[orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
);
+ const threadDndSensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
+ );
+ const handleActiveThreadDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const activeKey = String(event.active.id);
+ const overKey = event.over === null ? null : String(event.over.id);
+ if (overKey === null || activeKey === overKey) return;
+ if (
+ !currentActiveThreadOrder.includes(activeKey) ||
+ !currentActiveThreadOrder.includes(overKey)
+ ) {
+ return;
+ }
+ reorderThreadOrder(currentActiveThreadOrder, [activeKey], [overKey]);
+ },
+ [currentActiveThreadOrder, reorderThreadOrder],
+ );
// One snooze per thread at a time — same double-dispatch guard as settle.
const snoozingThreadKeysRef = useRef(new Set());
const performSnooze = useCallback(
@@ -3652,7 +3695,7 @@ export default function Sidebar() {
const renderThreadRow = (
thread: EnvironmentThreadShell,
section: "pinned" | "active" | "snoozed" | "settled",
- sortable?: SortablePinnedRowBag,
+ sortable?: SortableThreadRowBag,
) => {
const threadKey = scopedThreadKey(
scopeThreadRef(thread.environmentId, thread.id),
@@ -3800,9 +3843,9 @@ export default function Sidebar() {
return renderThreadRow(thread, "pinned");
}
return (
-
+
{(bag) => renderThreadRow(thread, "pinned", bag)}
-
+
);
})}
@@ -3821,8 +3864,39 @@ export default function Sidebar() {
/>,
);
}
- for (const thread of activeThreads) {
- items.push(renderThreadRow(thread, "active"));
+ if (orderedActiveThreads.length > 0) {
+ items.push(
+
+
+
+
+ {orderedActiveThreads.map((thread) => {
+ const threadKey = scopedThreadKey(
+ scopeThreadRef(thread.environmentId, thread.id),
+ );
+ return (
+
+ {(bag) => renderThreadRow(thread, "active", bag)}
+
+ );
+ })}
+
+
+
+ ,
+ );
}
// Snoozed shelf: between the inbox and Settled — out of the
// way, never gone. The header always renders while anything
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index f06a9658225f..7525a3274f9b 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -227,6 +227,7 @@ import { toastManager } from "../ui/toast";
import {
BotIcon,
CircleAlertIcon,
+ PaperclipIcon,
PencilRulerIcon,
type LucideIcon,
LockIcon,
@@ -1030,6 +1031,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
* next draft.
*/
const pendingImageCompressionsRef = useRef