diff --git a/apps/web/src/addons/terminal-shell/index.test.tsx b/apps/web/src/addons/terminal-shell/index.test.tsx new file mode 100644 index 000000000000..4db53fda219c --- /dev/null +++ b/apps/web/src/addons/terminal-shell/index.test.tsx @@ -0,0 +1,32 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { TerminalShellStatus, terminalShellRootProps } from "."; + +describe("terminal shell addon", () => { + it("exposes a stable host hook for its scoped styles", () => { + expect(terminalShellRootProps["data-terminal-shell"]).toBe("true"); + expect(terminalShellRootProps.className).toContain("terminal-shell"); + }); + + it("renders ready status without working-only controls", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Ready"); + expect(markup).not.toContain("esc to interrupt"); + expect(markup).not.toContain("terminal active"); + }); + + it("renders working status and pluralizes active terminals", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-terminal-shell-status-state="working"'); + expect(markup).toContain("Running command"); + expect(markup).toContain("esc to interrupt"); + expect(markup).toContain("2 terminals active"); + }); +}); diff --git a/apps/web/src/addons/terminal-shell/index.tsx b/apps/web/src/addons/terminal-shell/index.tsx new file mode 100644 index 000000000000..a797aa14c9e3 --- /dev/null +++ b/apps/web/src/addons/terminal-shell/index.tsx @@ -0,0 +1,34 @@ +export const terminalShellRootProps = { + className: "terminal-shell relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background", + "data-terminal-shell": "true", +} as const; + +export type TerminalShellStatusProps = { + readonly isWorking: boolean; + readonly workingStepLabel: string | null; + readonly activeTerminalCount: number; +}; + +export function TerminalShellStatus({ + isWorking, + workingStepLabel, + activeTerminalCount, +}: TerminalShellStatusProps) { + return ( +
+ + {isWorking ? (workingStepLabel ?? "Working") : "Ready"} + + {isWorking ? : null} + {activeTerminalCount > 0 ? ( + + · {activeTerminalCount} terminal{activeTerminalCount === 1 ? "" : "s"} active + + ) : null} +
+ ); +} diff --git a/apps/web/src/addons/terminal-shell/terminal-shell.css b/apps/web/src/addons/terminal-shell/terminal-shell.css new file mode 100644 index 000000000000..63be7c12c8e7 --- /dev/null +++ b/apps/web/src/addons/terminal-shell/terminal-shell.css @@ -0,0 +1,198 @@ +/* Fork-local presentation addon. Keep its selectors scoped to the host hook so + upstream chat markup remains the source of truth. */ +[data-terminal-shell="true"] { + --terminal-shell-background: #11161d; + --terminal-shell-surface: #1b2230; + --terminal-shell-border: rgb(255 255 255 / 9%); + --terminal-shell-muted: #8f9aa8; + --terminal-shell-foreground: #e6e9ed; + --terminal-shell-accent: #52e7d1; + --terminal-shell-selection: rgb(82 231 209 / 18%); + background: var(--terminal-shell-background); + color: var(--terminal-shell-foreground); + font-family: var(--font-mono); +} + +[data-terminal-shell="true"] [data-chat-header] { + min-height: 2.75rem; + border-bottom: 1px solid var(--terminal-shell-border); + background: var(--terminal-shell-background); + color: var(--terminal-shell-muted); + font-family: var(--font-mono); +} + +[data-terminal-shell="true"] [data-chat-header] h2, +[data-terminal-shell="true"] [data-chat-header] button, +[data-terminal-shell="true"] [data-chat-header] [data-project-favicon] { + font-family: var(--font-mono); +} + +[data-terminal-shell="true"] [data-chat-workspace-drop-target="true"] { + background: var(--terminal-shell-background); +} + +[data-terminal-shell="true"] [data-timeline-root="true"] { + max-width: none; + padding-inline: clamp(1rem, 4vw, 4rem); + font-family: var(--font-mono); + font-size: 0.9375rem; + line-height: 1.55; +} + +[data-terminal-shell="true"] [data-timeline-row-kind="message"] { + padding-block: 0.35rem; +} + +[data-terminal-shell="true"] [data-message-role="assistant"] > div { + padding-inline: 0; +} + +[data-terminal-shell="true"] [data-message-role="user"] > .group { + align-items: flex-start; + gap: 0; +} + +[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child { + max-width: none; + border: 0; + border-radius: 0; + background: var(--terminal-shell-surface); + padding: 0.5rem 0.625rem; + color: var(--terminal-shell-foreground); +} + +[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child::before { + margin-inline-end: 0.375rem; + color: var(--terminal-shell-accent); + content: ">"; + line-height: 1.55; +} + +[data-terminal-shell="true"] + [data-message-role="user"] + > .group + > div:first-child + > div:first-child { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; +} + +[data-terminal-shell="true"] [data-message-role="user"] [data-user-message-footer="true"] { + max-width: none; + justify-content: flex-start; + padding-inline-start: 1.5rem; + color: var(--terminal-shell-muted); +} + +[data-terminal-shell="true"] [data-message-role="assistant"] a, +[data-terminal-shell="true"] [data-message-role="assistant"] code, +[data-terminal-shell="true"] [data-message-role="user"] code { + color: var(--terminal-shell-accent); +} + +[data-terminal-shell="true"] [data-message-role="assistant"] a { + text-decoration-color: color-mix(in srgb, var(--terminal-shell-accent) 65%, transparent); +} + +[data-terminal-shell="true"] [data-message-role="assistant"] pre, +[data-terminal-shell="true"] [data-message-role="user"] pre { + border-color: var(--terminal-shell-border); + border-radius: 2px; + background: rgb(0 0 0 / 22%); +} + +[data-terminal-shell="true"] .chat-composer-glass-shell { + --chat-composer-glass-surface: var(--terminal-shell-surface); + --chat-composer-outline: var(--terminal-shell-border); + max-width: none; +} + +[data-terminal-shell="true"] .chat-composer-glass-shell::before, +[data-terminal-shell="true"] .chat-composer-glass-host::after { + border-radius: 3px; +} + +[data-terminal-shell="true"] .chat-composer-glass-shell::before { + background: var(--terminal-shell-surface); + box-shadow: 0 -14px 34px -28px rgb(0 0 0 / 80%); +} + +[data-terminal-shell="true"] .chat-composer-glass-host, +[data-terminal-shell="true"] [data-chat-composer-main-surface="true"], +[data-terminal-shell="true"] [data-chat-composer-surface="true"] { + border-radius: 3px; +} + +[data-terminal-shell="true"] [data-chat-composer-form="true"] { + max-width: none; +} + +[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] { + border: 1px solid var(--terminal-shell-border); + background: var(--terminal-shell-surface); + box-shadow: none; +} + +[data-terminal-shell="true"] [data-chat-composer-surface="true"] { + background: transparent; +} + +[data-terminal-shell="true"] [data-testid="composer-editor"] { + min-height: 3.5rem; + padding-block: 0.15rem; + font-family: var(--font-mono); + font-size: 0.9375rem; + line-height: 1.55; + caret-color: var(--terminal-shell-accent); +} + +[data-terminal-shell="true"] [data-testid="composer-editor"]::selection { + background: var(--terminal-shell-selection); +} + +[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] button:hover { + background: rgb(255 255 255 / 7%); +} + +[data-terminal-shell="true"] [data-chat-composer-overlay] { + background: linear-gradient(to top, var(--terminal-shell-background) 0%, transparent 100%); + padding-top: 1.5rem; +} + +[data-terminal-shell="true"] [data-terminal-shell-status="true"] { + font-family: var(--font-mono); + letter-spacing: 0.01em; +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state] { + color: var(--terminal-shell-muted); +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state]::before { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + margin-inline-end: 0.5rem; + border-radius: 999px; + background: var(--terminal-shell-muted); + content: ""; + vertical-align: 0.03em; +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state="working"] { + color: var(--terminal-shell-accent); +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state="working"]::before { + background: var(--terminal-shell-accent); + box-shadow: 0 0 0 3px rgb(82 231 209 / 10%); +} + +@media (max-width: 639px) { + [data-terminal-shell="true"] [data-timeline-root="true"] { + padding-inline: 1rem; + font-size: 0.875rem; + } +} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11fbe..bbc636131a95 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -469,7 +469,7 @@ export const BranchToolbar = memo(function BranchToolbar({
{isMobile && showGitControls ? ( ) : ( -
+
{showEnvironmentIndicator && availableEnvironments && ( <> > = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -1442,7 +1447,44 @@ function areMarkdownFileLinkPropsEqual( ); } -function ChatMarkdown({ +const StreamingChatMarkdown = memo(function StreamingChatMarkdown({ + text, + className, +}: StreamingChatMarkdownProps) { + const [displayText, setDisplayText] = useState(text); + + useEffect(() => { + if (typeof window === "undefined") { + setDisplayText(text); + return; + } + + const frame = window.requestAnimationFrame(() => setDisplayText(text)); + return () => window.cancelAnimationFrame(frame); + }, [text]); + + return ( +
+
{displayText}
+
+ ); +}); + +function ChatMarkdown(props: ChatMarkdownProps) { + if (props.isStreaming) { + return ; + } + + return ; +} + +function SettledChatMarkdown({ text, cwd, threadRef, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 0b7838afa86e..26251437f046 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -46,6 +46,10 @@ function renderWithoutThread(markdown: string): string { return renderToStaticMarkup(); } +function renderStreaming(markdown: string): string { + return renderToStaticMarkup(); +} + describe("ChatMarkdown workspace images", () => { beforeEach(() => { testState.resources = []; @@ -132,4 +136,13 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("max-h-[30rem]"); expect(html).not.toContain("Image unavailable"); }); + + it("uses lightweight text while a response is streaming", () => { + const html = renderStreaming("**not yet rich**\n```ts\nconst value = 1;\n```"); + + expect(html).toContain('data-streaming-markdown="true"'); + expect(html).toContain("**not yet rich**"); + expect(html).not.toContain(""); + expect(html).not.toContain("chat-markdown-codeblock"); + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 20966deb0c4f..06c6d36d262e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -59,6 +59,7 @@ import { memo, Suspense, useCallback, + useDeferredValue, useEffect, useLayoutEffect, useMemo, @@ -158,6 +159,7 @@ import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; +import { PlanPanel } from "./PlanPanel"; import { AgentsPanel } from "./AgentsPanel"; import { deriveAgentPanelModel, @@ -248,6 +250,7 @@ import { } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { TerminalShellStatus, terminalShellRootProps } from "../addons/terminal-shell"; import { requestOlderThreadTurns, threadHasOlderTurns, @@ -1562,6 +1565,16 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = activeServerThread !== null; const activeThread = activeServerThread ?? localDraftThread; + // Thread detail snapshots arrive on the stream for every message/activity + // update. Keep the composer and command surfaces current, but let the + // expensive timeline projection consume those snapshots at background + // priority so input, scrolling, and shell controls remain responsive. + const deferredTimelineThread = useDeferredValue(activeThread); + const timelineThread = + deferredTimelineThread?.id === activeThread?.id && + deferredTimelineThread?.environmentId === activeThread?.environmentId + ? deferredTimelineThread + : activeThread; const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; @@ -2261,8 +2274,12 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; - const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); - const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); + const timelineActivities = timelineThread?.activities ?? EMPTY_ACTIVITIES; + const workLogEntries = useMemo( + () => deriveWorkLogEntries(timelineActivities), + [timelineActivities], + ); + const turnPlans = useMemo(() => deriveTurnPlans(timelineActivities), [timelineActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). @@ -2275,6 +2292,9 @@ function ChatViewContent(props: ChatViewProps) { }), [agentSessionLive, threadActivities], ); + const deferredAgentPanelModel = useDeferredValue(agentPanelModel); + const timelineAgentPanelModel = + timelineThread === activeThread ? agentPanelModel : deferredAgentPanelModel; const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -2477,6 +2497,9 @@ function ChatViewContent(props: ChatViewProps) { }; }); }, [serverAttachmentUrlById, serverMessages]); + const deferredDisplayServerMessages = useDeferredValue(displayServerMessages); + const timelineDisplayServerMessages = + timelineThread === activeThread ? displayServerMessages : deferredDisplayServerMessages; useEffect(() => { if (typeof Image === "undefined" || displayServerMessages.length === 0) { return; @@ -2562,7 +2585,7 @@ function ChatViewContent(props: ChatViewProps) { }; }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, displayServerMessages]); const timelineMessages = useMemo(() => { - const messages = displayServerMessages; + const messages = timelineDisplayServerMessages; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 ? messages @@ -2612,16 +2635,16 @@ function ChatViewContent(props: ChatViewProps) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); + }, [attachmentPreviewHandoffByMessageId, optimisticUserMessages, timelineDisplayServerMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries( timelineMessages, - activeThread?.proposedPlans ?? [], + timelineThread?.proposedPlans ?? [], workLogEntries, turnPlans, ), - [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], + [timelineMessages, timelineThread?.proposedPlans, turnPlans, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -3353,6 +3376,10 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().open(activeThreadRef, "agents"); }, [activeThreadRef]); + const addPlanSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().open(activeThreadRef, "plan"); + }, [activeThreadRef]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -4105,6 +4132,20 @@ function ChatViewContent(props: ChatViewProps) { }; }, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]); + useEffect(() => { + if (!activeThread?.id || !latestTurnSettled) { + return; + } + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } + + // The extra tail is only useful while the first response is streaming. + // Once that turn settles, remove it and return to the real end of the chat + // so a long thread cannot leave a large empty region below its last row. + scrollToEnd(); + }, [activeThread?.id, latestTurnSettled, scrollToEnd]); + useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; @@ -4791,6 +4832,21 @@ function ChatViewContent(props: ChatViewProps) { ); }, [activeThreadId, terminalUiState.terminalOpen]); + const interruptActiveTurn = useCallback(async () => { + if (!activeThread) return; + const result = await interruptThreadTurn({ + environmentId, + input: buildThreadTurnInterruptInput(activeThread), + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to interrupt the current turn.", + ); + } + }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); + useEffect(() => { if (!activeThreadKey) return; const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; @@ -4839,6 +4895,22 @@ function ChatViewContent(props: ChatViewProps) { modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; + // Providers treat a follow-up sent during a running turn as a queued + // steer. Interrupting the active turn lets that follow-up run next; + // without one, this is the normal stop action. + if ( + (event.key === "Escape" || (event.ctrlKey && event.key.toLowerCase() === "c")) && + isWorking && + !shortcutContext.terminalFocus && + !shortcutContext.modelPickerOpen && + (event.key !== "Escape" || !document.querySelector('[data-composer-drawer-layer="true"]')) + ) { + event.preventDefault(); + event.stopPropagation(); + void interruptActiveTurn(); + return; + } + if ( !shortcutContext.terminalFocus && !shortcutContext.modelPickerOpen && @@ -4959,6 +5031,7 @@ function ChatViewContent(props: ChatViewProps) { activeProject, activeRightPanelSurface, addTerminalSurface, + isWorking, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, @@ -4970,6 +5043,7 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + interruptActiveTurn, onToggleDiff, toggleRightPanel, toggleRightPanelMaximized, @@ -6377,6 +6451,13 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeThreadRef?.environmentId ?? null} threadId={activeThreadRef?.threadId ?? null} /> + ) : activeRightPanelSurface?.kind === "plan" ? ( + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( @@ -6410,7 +6491,7 @@ function ChatViewContent(props: ChatViewProps) { composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); return ( -
+
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
{/* Messages — LegendList handles virtualization and scrolling internally */}
+ {!isDraftHeroState ? ( + + ) : null} {isDraftHeroState ? (
@@ -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>(new Map()); + const imageFileInputRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -3301,6 +3303,40 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} >
    + { + const files = Array.from(event.currentTarget.files ?? []); + // Clear the input so choosing the same image again still + // emits a change event after it has been removed. + event.currentTarget.value = ""; + if (files.length === 0) return; + void addComposerImages(files); + focusComposer(); + }} + /> + 0 + } + onClick={() => imageFileInputRef.current?.click()} + > + + Attach + + {noProviderAvailable ? ( - ) : ( -
    - {image.name} -
    - )} -
    - ))} -
    - )} - {previewAnnotations.map((annotation, index) => ( - +
    +
    + {regularImages.length > 0 && ( +
    + {regularImages.map((image: NonNullable[number]) => ( +
    + {image.previewUrl ? ( + + ) : ( +
    + {image.name} +
    + )} +
    + ))} +
    + )} + {previewAnnotations.map((annotation, index) => ( + + ))} + {elementContexts.length > 0 ? ( +
    + {elementContexts.map((context) => ( + + ))} +
    + ) : null} + - ))} - {elementContexts.length > 0 ? ( -
    - {elementContexts.map((context) => ( - - ))} -
    - ) : null} - +
    -
    +
    }> diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 6768d2dc61ef..12664e1fcff6 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -353,6 +353,17 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); }, []); + const [projectTitle, setProjectTitle] = useState(group.displayName); + const [isSavingProjectTitle, setIsSavingProjectTitle] = useState(false); + const projectTitleSaveInFlightRef = useRef(false); + + // Keep the field in sync with changes from another client, but do not let a + // shell update replace the text while this field's save is in flight. + useEffect(() => { + if (projectTitleSaveInFlightRef.current) return; + setProjectTitle(group.displayName); + }, [group.displayName]); + // Group-shared fields live on each physical project record, so a // group-level edit fans out to every member. const updateAllMembers = useCallback( @@ -395,11 +406,35 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const title = nextTitle.trim(); if (!title) { toastManager.add({ type: "warning", title: "Project title cannot be empty" }); + setProjectTitle(group.displayName); + return; + } + if (title === group.displayName) { + setProjectTitle(title); + return; + } + if (group.memberProjects.every((member) => member.title === title)) { + setProjectTitle(group.displayName); return; } - if (title === group.displayName) return; - if (group.memberProjects.every((member) => member.title === title)) return; - await updateAllMembers({ title }, "Failed to rename project"); + + // onBlur can be followed by another focus/blur cycle while the remote + // command is still settling. Serialize the group rename so a stale + // second blur cannot overwrite the first value. + if (projectTitleSaveInFlightRef.current) return; + projectTitleSaveInFlightRef.current = true; + setIsSavingProjectTitle(true); + try { + const result = await updateAllMembers({ title }, "Failed to rename project"); + if (result._tag === "Success") { + setProjectTitle(title); + } else { + setProjectTitle(group.displayName); + } + } finally { + projectTitleSaveInFlightRef.current = false; + setIsSavingProjectTitle(false); + } }, [group.displayName, group.memberProjects, updateAllMembers], ); @@ -761,15 +796,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { description="The shared name for this project group in the sidebar and thread lists." control={ setProjectTitle(event.currentTarget.value)} onBlur={(event) => { void renameGroup(event.currentTarget.value); }} onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } }} /> } diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0bb0d1b0ec18..883e7e9d8c84 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -42,11 +42,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker"; import { clearThemeInspectorHover, - clearThemeInspectorHighlights, - highlightThemeRoleUsage, + countThemeRoleUsage, inspectThemeRoleAtElement, inspectThemeRoleFromUtilitiesAtElement, - refreshThemeInspectorSpotlight, showThemeInspectorHover, type ThemeElementInspection, } from "./themeInspector"; @@ -541,18 +539,17 @@ export function ThemeEditorPanel({ const selectedHighlightRolesKey = selectedHighlightRoles.join(","); useEffect(() => { - clearThemeInspectorHighlights(); if (!open || selectedRole === null) { setUsageCount(null); return; } - // Picking a new element needs the unobscured app, so suspend the existing - // spotlight while the picker is armed. + // Picking a new element needs the unobscured app, so suspend usage probing + // while the picker is armed. if (isInspecting) return; - const highlightedRoles = selectedHighlightRolesKey.split(",") as Array; - const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles)); - refreshHighlights(); + const usageRoles = selectedHighlightRolesKey.split(",") as Array; + const refreshUsageCount = () => setUsageCount(countThemeRoleUsage(usageRoles)); + refreshUsageCount(); // A refresh snapshots computed styles for the whole tree twice, so it is // throttled rather than run per frame: a streaming reply or a virtualized // list mutates the DOM continuously and would otherwise stall the main @@ -568,7 +565,7 @@ export function ThemeEditorPanel({ refreshFrame = null; refreshTimer = null; lastRefreshAt = performance.now(); - refreshHighlights(); + refreshUsageCount(); }; if (wait === 0) refreshFrame = requestAnimationFrame(run); else refreshTimer = setTimeout(run, wait); @@ -578,8 +575,7 @@ export function ThemeEditorPanel({ mutations.every( (mutation) => mutation.target instanceof Element && - (mutation.target.closest("#theme-inspector-spotlight") || - mutation.target.closest("[data-theme-editor-panel]")), + mutation.target.closest("[data-theme-editor-panel]"), ) ) { return; @@ -587,23 +583,10 @@ export function ThemeEditorPanel({ scheduleRefresh(); }); observer.observe(document.body, { childList: true, subtree: true }); - let spotlightFrame: number | null = null; - const scheduleSpotlightRefresh = () => { - spotlightFrame ??= requestAnimationFrame(() => { - spotlightFrame = null; - refreshThemeInspectorSpotlight(); - }); - }; - window.addEventListener("resize", scheduleSpotlightRefresh); - window.addEventListener("scroll", scheduleSpotlightRefresh, true); return () => { observer.disconnect(); if (refreshFrame !== null) cancelAnimationFrame(refreshFrame); if (refreshTimer !== null) clearTimeout(refreshTimer); - if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame); - window.removeEventListener("resize", scheduleSpotlightRefresh); - window.removeEventListener("scroll", scheduleSpotlightRefresh, true); - clearThemeInspectorHighlights(); }; }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); diff --git a/apps/web/src/components/settings/themeInspector.ts b/apps/web/src/components/settings/themeInspector.ts index 9306226b8c86..daee8bb296fb 100644 --- a/apps/web/src/components/settings/themeInspector.ts +++ b/apps/web/src/components/settings/themeInspector.ts @@ -14,16 +14,10 @@ const THEME_PAINT_KIND_ORDER: ReadonlyArray = [ "foreground", ]; -export const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; - const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe"; const THEME_TOKEN_PROBE_COLOR = "#01fea7"; const THEME_TOKEN_ALTERNATE_PROBE_COLOR = "#fe01a7"; -const THEME_SPOTLIGHT_ID = "theme-inspector-spotlight"; -const THEME_SPOTLIGHT_MASK_ID = "theme-inspector-spotlight-mask"; -const THEME_SPOTLIGHT_GLOW_ID = "theme-inspector-spotlight-glow"; const THEME_HOVER_ID = "theme-inspector-hover"; -const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const THEME_UTILITY_ROLES: Readonly>> = { background: "canvas", @@ -79,28 +73,11 @@ const THEME_UTILITY_PREFIXES: Readonly element.removeAttribute(attribute)); -} - -export function clearThemeInspectorHighlights(): void { - clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE); - document.getElementById(THEME_SPOTLIGHT_ID)?.remove(); -} - export function clearThemeInspectorHover(): void { document.getElementById(THEME_HOVER_ID)?.remove(); } -function svgElement( - name: Name, -): SVGElementTagNameMap[Name] { - return document.createElementNS(SVG_NAMESPACE, name); -} - -function spotlightRect(element: Element): { +function hoverRect(element: Element): { x: number; y: number; width: number; @@ -136,7 +113,7 @@ function spotlightRect(element: Element): { } export function showThemeInspectorHover(inspection: ThemeElementInspection, label: string): void { - const rectangle = spotlightRect(inspection.element); + const rectangle = hoverRect(inspection.element); if (!rectangle) { clearThemeInspectorHover(); return; @@ -163,92 +140,6 @@ export function showThemeInspectorHover(inspection: ThemeElementInspection, labe if (tokenLabel) tokenLabel.textContent = label; } -function renderThemeInspectorSpotlight(elements: ReadonlyArray): void { - const rectangles = new Map>>(); - for (const element of elements) { - const rectangle = spotlightRect(element); - if (!rectangle) continue; - const key = [rectangle.x, rectangle.y, rectangle.width, rectangle.height] - .map((value) => Math.round(value)) - .join(":"); - rectangles.set(key, rectangle); - } - - if (rectangles.size === 0) { - document.getElementById(THEME_SPOTLIGHT_ID)?.remove(); - return; - } - - let spotlight = document.getElementById(THEME_SPOTLIGHT_ID) as SVGSVGElement | null; - if (!spotlight) { - spotlight = svgElement("svg"); - spotlight.id = THEME_SPOTLIGHT_ID; - spotlight.setAttribute("aria-hidden", "true"); - spotlight.setAttribute("focusable", "false"); - document.body.append(spotlight); - } - spotlight.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); - - const definitions = svgElement("defs"); - const mask = svgElement("mask"); - mask.id = THEME_SPOTLIGHT_MASK_ID; - mask.setAttribute("maskUnits", "userSpaceOnUse"); - const maskSurface = svgElement("rect"); - maskSurface.setAttribute("width", String(window.innerWidth)); - maskSurface.setAttribute("height", String(window.innerHeight)); - maskSurface.setAttribute("fill", "white"); - mask.append(maskSurface); - - const glowFilter = svgElement("filter"); - glowFilter.id = THEME_SPOTLIGHT_GLOW_ID; - glowFilter.setAttribute("x", "-50%"); - glowFilter.setAttribute("y", "-50%"); - glowFilter.setAttribute("width", "200%"); - glowFilter.setAttribute("height", "200%"); - const blur = svgElement("feGaussianBlur"); - blur.setAttribute("stdDeviation", "5"); - blur.setAttribute("result", "blur"); - const merge = svgElement("feMerge"); - const blurredGlow = svgElement("feMergeNode"); - blurredGlow.setAttribute("in", "blur"); - const crispGlow = svgElement("feMergeNode"); - crispGlow.setAttribute("in", "SourceGraphic"); - merge.append(blurredGlow, crispGlow); - glowFilter.append(blur, merge); - definitions.append(mask, glowFilter); - - const glowGroup = svgElement("g"); - for (const rectangle of rectangles.values()) { - const hole = svgElement("rect"); - hole.setAttribute("x", String(rectangle.x)); - hole.setAttribute("y", String(rectangle.y)); - hole.setAttribute("width", String(rectangle.width)); - hole.setAttribute("height", String(rectangle.height)); - hole.setAttribute("rx", String(rectangle.radius)); - hole.setAttribute("fill", "black"); - mask.append(hole); - - const glow = hole.cloneNode(false) as SVGRectElement; - glow.removeAttribute("fill"); - glow.setAttribute("class", "theme-inspector-spotlight-glow"); - glow.setAttribute("filter", `url(#${THEME_SPOTLIGHT_GLOW_ID})`); - glowGroup.append(glow); - } - - const dimmer = svgElement("rect"); - dimmer.setAttribute("class", "theme-inspector-spotlight-dimmer"); - dimmer.setAttribute("width", String(window.innerWidth)); - dimmer.setAttribute("height", String(window.innerHeight)); - dimmer.setAttribute("mask", `url(#${THEME_SPOTLIGHT_MASK_ID})`); - spotlight.replaceChildren(definitions, dimmer, glowGroup); -} - -export function refreshThemeInspectorSpotlight(): void { - renderThemeInspectorSpotlight([ - ...document.querySelectorAll(`[${THEME_INSPECTOR_MATCH_ATTRIBUTE}]`), - ]); -} - function elementHasVisibleText(element: Element): boolean { if (element.matches("input, textarea, select, option")) return true; return Array.from(element.childNodes).some( @@ -346,8 +237,7 @@ function applyThemeTokenProbes(roles: ReadonlyArray): () => void function themeInspectorCandidates(): ReadonlyArray { return [document.body, ...document.body.querySelectorAll("*")].filter( (element) => - !element.closest("[data-theme-editor-panel]") && - !element.closest(`#${THEME_SPOTLIGHT_ID}, #${THEME_HOVER_ID}`), + !element.closest("[data-theme-editor-panel]") && !element.closest(`#${THEME_HOVER_ID}`), ); } @@ -402,11 +292,8 @@ export function inspectThemeRoleFromUtilitiesAtElement( * A token is synchronously replaced with a sentinel, computed paint is read, * and the original value is restored before the browser can render a frame. */ -export function highlightThemeRoleUsage(roles: ReadonlyArray): number { - const startedAt = performance.now(); - clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE); +function findThemeRoleUsage(roles: ReadonlyArray): ReadonlySet { const candidates = themeInspectorCandidates(); - const probeStartedAt = performance.now(); const matches = withThemeTokenProbeSession(() => { const baseline = new Map(); for (const element of candidates) { @@ -426,27 +313,16 @@ export function highlightThemeRoleUsage(roles: ReadonlyArray): n } return changed; }); - const probeDuration = performance.now() - probeStartedAt; + return matches; +} - const highlightedElements = new Set(); +export function countThemeRoleUsage(roles: ReadonlyArray): number { + const matches = findThemeRoleUsage(roles); + const elements = new Set(); for (const element of matches) { - highlightedElements.add( - element instanceof SVGElement ? (element.closest("svg") ?? element) : element, - ); - } - for (const element of highlightedElements) { - element.setAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE, ""); - } - renderThemeInspectorSpotlight([...highlightedElements]); - if (import.meta.env.DEV) { - const spotlight = document.getElementById(THEME_SPOTLIGHT_ID); - if (spotlight) { - spotlight.dataset.themeInspectorCandidates = String(candidates.length); - spotlight.dataset.themeInspectorProbeMs = probeDuration.toFixed(2); - spotlight.dataset.themeInspectorTotalMs = (performance.now() - startedAt).toFixed(2); - } + elements.add(element instanceof SVGElement ? (element.closest("svg") ?? element) : element); } - return highlightedElements.size; + return elements.size; } /** Resolves the nearest painted token at a touched element. */ diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f69adb9cf08e..38c605fe746b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1676,33 +1676,11 @@ html[data-theme-token-probe] *::after { transition: none !important; } -#theme-inspector-spotlight { - position: fixed; - z-index: 100; - inset: 0; - width: 100vw; - height: 100dvh; - overflow: visible; - pointer-events: none; -} - [data-slot="popover-positioner"]:has([data-theme-editor-panel]), [data-slot="tooltip-positioner"]:has([data-theme-editor-panel]) { z-index: 120; } -.theme-inspector-spotlight-dimmer { - fill: rgb(2 3 8 / 54%); -} - -.theme-inspector-spotlight-glow { - fill: color-mix(in oklab, var(--ring) 9%, transparent); - stroke: color-mix(in oklab, var(--ring) 72%, white); - stroke-width: 2px; - opacity: 0.92; - vector-effect: non-scaling-stroke; -} - #theme-inspector-hover { position: fixed; z-index: 101; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6eaaca6f57de..5da186b90ecf 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,7 @@ import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "./index.css"; +import "./addons/terminal-shell/terminal-shell.css"; import { isElectron } from "./env"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index b6997554efbc..5833f6e1ee02 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -177,7 +177,7 @@ describe("rightPanelStore", () => { ).toEqual({ byThreadKey: { "env-1:thread-A": panelState } }); }); - it("drops persisted plan surfaces and does not reopen an empty panel", () => { + it("preserves persisted plan surfaces alongside the inline timeline", () => { expect( migratePersistedRightPanelState({ byThreadKey: { @@ -199,14 +199,17 @@ describe("rightPanelStore", () => { ).toEqual({ byThreadKey: { "env-1:thread-A": { - isOpen: false, - activeSurfaceId: null, - surfaces: [], + isOpen: true, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], }, "env-1:thread-B": { isOpen: true, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], + activeSurfaceId: "plan", + surfaces: [ + { id: "plan", kind: "plan" }, + { id: "diff", kind: "diff" }, + ], }, }, }); @@ -227,6 +230,17 @@ describe("rightPanelStore", () => { ).toHaveLength(2); }); + it("opens the plan surface as a singleton", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "plan"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], + }); + }); + it("reopening an inactive singleton activates its existing surface", () => { useRightPanelStore.getState().open(refA, "diff"); useRightPanelStore.getState().open(refA, "agents"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 27d5ded5d272..18593ead12cb 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -22,6 +22,7 @@ export const RIGHT_PANEL_KINDS = [ "terminal", "pull-request", "agents", + "plan", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -62,13 +63,14 @@ export type RightPanelSurface = repository: string; number: number; } - | { id: "agents"; kind: "agents" }; + | { id: "agents"; kind: "agents" } + | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -// v9 removed the "plan" surface kind (plans render inline in the transcript). // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. -const RIGHT_PANEL_STORAGE_VERSION = 11; +// v12 restores a plan surface alongside the inline plan timeline. +const RIGHT_PANEL_STORAGE_VERSION = 12; /** * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session @@ -136,6 +138,8 @@ const singletonSurface = ( return { id: "files", kind }; case "agents": return { id: "agents", kind }; + case "plan": + return { id: "plan", kind }; } }; @@ -263,9 +267,6 @@ export function migratePersistedRightPanelState(persistedState: unknown): { threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap((surface) => { - // Dropped surface kind: plans now render inline in the - // transcript (v9). - if ((surface as { kind?: string }).kind === "plan") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 91fc4f7789de..772f9680189c 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1580,12 +1580,14 @@ function PullRequestsRouteView() { onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddAgents={() => undefined} + onAddPlan={() => undefined} browserAvailable={false} terminalAvailable={false} diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} agentsAvailable={false} + planAvailable={false} liveAgentCount={0} pullRequestStatuses={pullRequestTabStatuses} > diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 304502873539..13e344e76ae0 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -10,6 +10,7 @@ import { type PersistedUiState, persistState, reorderProjects, + reorderThreads, resolveProjectExpanded, setDefaultAdvertisedEndpointKey, setProjectExpanded, @@ -21,6 +22,7 @@ function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, projectOrder: [], + threadOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -116,6 +118,14 @@ describe("uiStateStore pure functions", () => { ); }); + it("reorders chats from the current sidebar order", () => { + const currentOrder = ["thread-1", "thread-2", "thread-3"]; + + const next = reorderThreads(makeUiState(), currentOrder, ["thread-1"], ["thread-3"]); + + expect(next.threadOrder).toEqual(["thread-2", "thread-3", "thread-1"]); + }); + it("stores explicit changed-file expansion choices", () => { const threadId = ThreadId.make("thread-1"); const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); @@ -154,6 +164,7 @@ describe("parsePersistedState", () => { invalid: "no" as unknown as boolean, }, projectOrder: ["physical-b", "", "physical-a", "physical-b"], + threadOrder: ["environment:thread-2", "environment:thread-1", "environment:thread-2"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", @@ -173,6 +184,7 @@ describe("parsePersistedState", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + threadOrder: ["environment:thread-2", "environment:thread-1"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, @@ -270,6 +282,7 @@ describe("uiStateStore persistence", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + threadOrder: ["environment:thread-2", "environment:thread-1"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, @@ -292,6 +305,7 @@ describe("uiStateStore persistence", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + threadOrder: ["environment:thread-2", "environment:thread-1"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 5d744d540a5a..632aad58a977 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -20,6 +20,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ export interface PersistedUiState { projectExpandedById?: Record; projectOrder?: string[]; + threadOrder?: string[]; threadLastVisitedAtById?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; @@ -35,6 +36,7 @@ export interface UiProjectState { } export interface UiThreadState { + threadOrder: string[]; threadLastVisitedAtById: Record; threadChangedFilesExpandedById: Record>; } @@ -48,6 +50,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState const initialState: UiState = { projectExpandedById: {}, projectOrder: [], + threadOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -125,6 +128,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { return { projectExpandedById, projectOrder, + threadOrder: sanitizeStringArray(parsed.threadOrder), threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), threadChangedFilesExpandedById: parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION @@ -203,6 +207,7 @@ export function persistState(state: UiState): void { JSON.stringify({ projectExpandedById, projectOrder: state.projectOrder, + threadOrder: state.threadOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, @@ -381,12 +386,60 @@ export function reorderProjects( }; } +export function reorderThreads( + state: UiState, + currentThreadOrder: readonly string[], + draggedThreadIds: readonly string[], + targetThreadIds: readonly string[], +): UiState { + if (draggedThreadIds.length === 0) { + return state; + } + const draggedSet = new Set(draggedThreadIds); + const targetSet = new Set(targetThreadIds); + if (draggedThreadIds.every((id) => targetSet.has(id))) { + return state; + } + + const originalTargetIndex = currentThreadOrder.findIndex((id) => targetSet.has(id)); + if (originalTargetIndex < 0) { + return state; + } + + const threadOrder = [...currentThreadOrder]; + const removed: string[] = []; + let draggedBeforeTarget = 0; + for (let i = threadOrder.length - 1; i >= 0; i--) { + if (draggedSet.has(threadOrder[i]!)) { + removed.unshift(threadOrder.splice(i, 1)[0]!); + if (i < originalTargetIndex) { + draggedBeforeTarget++; + } + } + } + if (removed.length === 0) { + return state; + } + + const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1); + threadOrder.splice(insertIndex, 0, ...removed); + return { + ...state, + threadOrder, + }; +} + interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; + reorderThreads: ( + currentThreadOrder: readonly string[], + draggedThreadIds: readonly string[], + targetThreadIds: readonly string[], + ) => void; reorderProjects: ( currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], @@ -406,6 +459,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), + reorderThreads: (currentThreadOrder, draggedThreadIds, targetThreadIds) => + set((state) => reorderThreads(state, currentThreadOrder, draggedThreadIds, targetThreadIds)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds), diff --git a/docs/internals/fork-addons.md b/docs/internals/fork-addons.md new file mode 100644 index 000000000000..77bcc8a769af --- /dev/null +++ b/docs/internals/fork-addons.md @@ -0,0 +1,18 @@ +# Fork addons + +Fork-only product customizations live under `apps/web/src/addons/`. Each addon +owns its presentation code, styles, and focused tests. Upstream feature code +should remain unchanged unless the addon needs a small host hook. + +The terminal shell is the first addon. Its stylesheet is imported separately +from the upstream `index.css`, and `ChatView` only supplies the root props and +runtime status values that the addon cannot discover on its own. + +When updating from upstream: + +1. Keep addon files in their own commits where practical. +2. Rebase or merge upstream before resolving addon host-hook conflicts. +3. If upstream changes the chat markup targeted by an addon stylesheet, update + the addon and its tests together. +4. Do not copy addon styles back into shared stylesheets; that makes future + upstream pulls needlessly conflict-prone. diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 0c4ca077f5de..4d15e07e4239 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -41,13 +41,13 @@ Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refre `projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`. Repeating either shortcut closes that search, and switching shortcuts replaces the open search. `themeEditor.toggle` opens or closes the floating theme editor and defaults to -`mod+alt+shift+t`. Select a color label to spotlight the elements that use it; select the label -again to clear the spotlight. The swatch and hex field keep that color selected while you edit it. +`mod+alt+shift+t`. Select a color label to show how many elements use it. The swatch and hex field +keep that color selected while you edit it. Advanced mode groups related app tokens into a smaller set of color families. Changing a family updates its paired text and interaction states while leaving every unrelated imported color intact. Use **Inspect** to pick an element in the app and reveal its color token. Inspect disarms after one successful pick; its hover glow and badge preview the element and color family that click will select. -**Cancel** or `Escape` exits Inspect and clears its selection and spotlight. +**Cancel** or `Escape` exits Inspect and clears its selection. `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want to use it.