diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 0c4194b88a..1a27cb1d58 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -55,6 +55,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | | Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | +| Chat view, composer and timeline fixes / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full 11-source list in [#457](https://github.com/pylon-code/pylon/pull/457), from #10256 through #10768; `7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49` (#9871) lands separately | Adopted English tooltip dates, visible task row states, projection-owned checkpoint lookups, anchored toolbar labels, remembered Fast mode, `thread.stop`, proactive panels on entry, scroll-to-end clearance, minimap turn navigation and the composer footer/bar loading fixes. Pylon adaptations: revert buttons keep the exact rollback proof (`deriveRollbackTargets` over server messages only, so optimistic and `/feedback` rows never claim a target; target re-derived on click) instead of inferred counts; Stop, `thread.stop` and a new palette action share one interrupt that also stops turns awaiting provider admission; the implicit Normal Fast default applies only to interactive composers, not background text generation; visible status labels replace the screen-reader-only prefixes; `reportsContextWindow` is set for Codex, Claude and Prime daemon (not Prime ACP, Cursor, Grok, OpenCode or Antigravity) and reads `contextWindowIndicatorEnabled`. Pylon's locked workspace label keeps its unmotioned markup. Mobile unchanged: the stop shortcut and palette action, minimap navigation and reserved meter slot are web/desktop surfaces. Cursor unchanged. | [Web composer #457](https://github.com/pylon-code/pylon/pull/457); 853 focused tests, web/server/contracts/client-runtime typechecks, scoped lint/format. | ## Deferred register diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e1a8638bc6..dcc6808526 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -51,6 +51,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, + reportsContextWindow: true, supportsConversationRollback: false, } as const; function toTitleCaseWords(value: string): string { diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 85dc8c028e..34c20fadf3 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -64,6 +64,7 @@ const CODEX_RATE_LIMITS_PROBE_TIMEOUT = "5 seconds" as const; const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, + reportsContextWindow: true, supportsConversationRollback: false, } as const; diff --git a/apps/server/src/provider/Layers/PrimeAgentProvider.test.ts b/apps/server/src/provider/Layers/PrimeAgentProvider.test.ts index 805b228ae3..fd968615e5 100644 --- a/apps/server/src/provider/Layers/PrimeAgentProvider.test.ts +++ b/apps/server/src/provider/Layers/PrimeAgentProvider.test.ts @@ -380,6 +380,7 @@ describe("buildInitialPrimeAgentProviderSnapshot", () => { expect(snapshot.featureCapabilities?.inputQueue?.operations).toContain("remove"); expect(snapshot.featureCapabilities?.context?.operations).toContain("refine"); expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.reportsContextWindow).toBe(true); expect(snapshot.supportedRuntimeModes).toEqual(["approval-required", "full-access"]); expect(snapshot.message).toBe("Checking Prime Agent CLI availability..."); }), @@ -421,6 +422,7 @@ describe("buildInitialPrimeAgentProviderSnapshot", () => { ), ).toEqual(["thinkingLevel", "serviceTier"]); expect(snapshot.requiresNewThreadForModelChange).toBe(true); + expect(snapshot.reportsContextWindow).toBe(false); expect(snapshot.supportedRuntimeModes).toEqual(["full-access"]); expect(snapshot.message).toBe( "Checking Prime Agent CLI availability... Prime Agent daemon integration is unavailable; using ACP compatibility mode.", diff --git a/apps/server/src/provider/Layers/PrimeAgentProvider.ts b/apps/server/src/provider/Layers/PrimeAgentProvider.ts index 801fd0238c..d412fef3fd 100644 --- a/apps/server/src/provider/Layers/PrimeAgentProvider.ts +++ b/apps/server/src/provider/Layers/PrimeAgentProvider.ts @@ -99,6 +99,8 @@ export function stampPrimeAgentBackendSnapshot( capabilities: EMPTY_CAPABILITIES, })), requiresNewThreadForModelChange: backend.runtime === "acp", + // Only the daemon reports context usage; ACP compatibility mode does not. + reportsContextWindow: backend.runtime === "daemon", supportedRuntimeModes: backend.runtime === "daemon" ? (["approval-required", "full-access"] as const) diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 8c3552b98d..b444d4b120 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -67,6 +67,7 @@ export interface ServerProviderPresentation { readonly displayName: string; readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; + readonly reportsContextWindow?: boolean; readonly requiresNewThreadForModelChange?: boolean; readonly supportedRuntimeModes?: ServerProvider["supportedRuntimeModes"]; readonly supportsBackgroundTextGeneration?: boolean; @@ -254,6 +255,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), + ...(typeof input.presentation.reportsContextWindow === "boolean" + ? { reportsContextWindow: input.presentation.reportsContextWindow } + : {}), ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 9d6d5585ff..341c0ccee9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -11,7 +11,7 @@ import { import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities"; import { ComposerUsageIndicator } from "./ComposerUsageIndicator"; import type { ComposerUsage } from "../providerUsageAccounts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; @@ -140,7 +140,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ > {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} @@ -250,12 +250,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ */ const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; -const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; +const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; function useLabelsOverflow(element: HTMLDivElement | null): boolean { const [overflows, setOverflows] = useState(false); - const pendingControlRectsRef = useRef | null>(null); - const controlAnimationsRef = useRef(new Map()); + const pendingLabelRectsRef = useRef | null>(null); + const labelAnimationsRef = useRef(new Map()); // A render-synced mirror instead of useEffectEvent: the compiler memoizes // the event callback, which left observers reading the first render's null // element forever. @@ -320,15 +320,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { for (const inner of label.querySelectorAll("*")) { textWidth = Math.max(textWidth, inner.scrollWidth); } - if (compact) { - // Compact: the label is squeezed to zero width but keeps reporting - // the full width it would need when expanded. - needed += textWidth; - } else { - // Expanded: the label is in flow; only the clipped remainder is - // missing from the content sum. - needed += Math.max(0, textWidth - label.clientWidth); - } + // Subtract the visible width even during an animation. The content + // sum already includes it; only the hidden text needs reserving. + needed += Math.max(0, textWidth - label.getBoundingClientRect().width); } const nextOverflows = resolveContextStripLabelsCompact({ compact, @@ -336,9 +330,9 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { availableWidth: available, }); if (nextOverflows !== compact) { - pendingControlRectsRef.current = new Map( - Array.from(current.querySelectorAll(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( - (control) => [control, control.getBoundingClientRect()], + pendingLabelRectsRef.current = new Map( + Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)).map( + (label) => [label, label.getBoundingClientRect()], ), ); } @@ -346,28 +340,29 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { }, []); useLayoutEffect(() => { - const previousRects = pendingControlRectsRef.current; + const previousRects = pendingLabelRectsRef.current; if (!previousRects) return; - pendingControlRectsRef.current = null; + pendingLabelRectsRef.current = null; - for (const animation of controlAnimationsRef.current.values()) { + for (const animation of labelAnimationsRef.current.values()) { animation.cancel(); } - controlAnimationsRef.current.clear(); + labelAnimationsRef.current.clear(); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - for (const [control, previousRect] of previousRects) { - if (!control.isConnected) continue; - const nextRect = control.getBoundingClientRect(); - const deltaX = previousRect.left - nextRect.left; - const deltaY = previousRect.top - nextRect.top; - if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; + for (const [label, previousRect] of previousRects) { + if (!label.isConnected) continue; + const nextWidth = label.getBoundingClientRect().width; + if (Math.abs(previousRect.width - nextWidth) < 0.5) continue; - const animation = control.animate( + // Animate the space occupied by each label so flex layout keeps the + // trailing controls anchored. Translating the whole group after its + // width snaps sends expanded text beyond the strip's right edge. + const animation = label.animate( [ - { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, + { width: `${previousRect.width}px`, maxWidth: `${previousRect.width}px` }, + { width: `${nextWidth}px`, maxWidth: `${nextWidth}px` }, ], { duration: COMPOSER_CONTEXT_MOTION_DURATION_MS, @@ -375,12 +370,12 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { fill: "backwards", }, ); - controlAnimationsRef.current.set(control, animation); + labelAnimationsRef.current.set(label, animation); animation.addEventListener( "finish", () => { - if (controlAnimationsRef.current.get(control) === animation) { - controlAnimationsRef.current.delete(control); + if (labelAnimationsRef.current.get(label) === animation) { + labelAnimationsRef.current.delete(label); } }, { once: true }, @@ -390,7 +385,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { useEffect( () => () => { - for (const animation of controlAnimationsRef.current.values()) { + for (const animation of labelAnimationsRef.current.values()) { animation.cancel(); } }, @@ -447,7 +442,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverThread = useThreadShell(threadRef); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index b083f0a96c..1f59d1495e 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -27,7 +27,7 @@ import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; -import { useProject, useThread } from "../state/entities"; +import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; @@ -119,7 +119,7 @@ export function BranchToolbarBranchSelector({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverThread = useThreadShell(threadRef); const serverSession = serverThread?.session ?? null; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); @@ -754,7 +754,7 @@ export function BranchToolbarBranchSelector({ > #{branchPr.number} @@ -784,7 +784,7 @@ export function BranchToolbarBranchSelector({ > {triggerLabel} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 3fb1479211..6c7a61c8d1 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -103,7 +103,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe > diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 1f3369c091..d450f564d4 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -63,7 +63,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir > {activeEnvironment?.label ?? "Run on"} @@ -96,7 +96,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir > diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c02b3d6eaf..3d3457e597 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -25,6 +25,7 @@ import { branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, + buildRunningThreadTurnInterruptInput, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, @@ -38,10 +39,13 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + threadShellHasStarted, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -127,7 +131,7 @@ describe("proactive panels", () => { expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref)).toEqual( oldPr, ); - expect(shouldOpenProactivePullRequest(loaded.targetKey, "owner/repo:2")).toBe(false); + expect(shouldOpenProactivePullRequest(loaded.targetKey, "owner/repo:2")).toBe(true); expect( shouldOpenProactiveTurnDiff({ previousRunningTurnId: loaded.runningTurnId, @@ -135,7 +139,10 @@ describe("proactive panels", () => { settledTurnId: turnId, turnCompleted: true, }), - ).toBe(false); + ).toBe(true); + expect(panels.openProactive(ref, { id: "diff", kind: "diff" }, loaded.userActionRevision)).toBe( + false, + ); }); it.each(["idle", "loading", "observed"] as const)( @@ -182,14 +189,15 @@ describe("proactive panels", () => { }, ); - it("opens a pull request only after a newly observed link appears", () => { - expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); + it("opens an existing pull request on entry and follows newly observed links", () => { + expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(true); + expect(shouldOpenProactivePullRequest(undefined, null)).toBe(false); expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true); expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false); expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); }); - it("opens the diff only when the observed running turn settles", () => { + it("opens a completed diff on entry or when the observed running turn settles", () => { const turnId = TurnId.make("turn-1"); expect( shouldOpenProactiveTurnDiff({ @@ -198,7 +206,7 @@ describe("proactive panels", () => { settledTurnId: turnId, turnCompleted: true, }), - ).toBe(false); + ).toBe(true); expect( shouldOpenProactiveTurnDiff({ previousRunningTurnId: turnId, @@ -810,6 +818,65 @@ describe("buildThreadTurnInterruptInput", () => { buildThreadTurnInterruptInput(makeThread({ session: { ...readySession, status } })), ).toEqual({ threadId }); }); + + it("omits a turn id when a running session has not projected its active turn yet", () => { + expect( + buildThreadTurnInterruptInput( + makeThread({ + session: { + ...readySession, + status: "running", + activeTurnId: null, + }, + }), + ), + ).toEqual({ threadId }); + }); +}); + +describe("buildRunningThreadTurnInterruptInput", () => { + it("targets only the active turn of a running thread", () => { + const activeTurnId = TurnId.make("turn-running"); + const runningThread = makeThread({ + session: { + ...readySession, + status: "running", + activeTurnId, + }, + }); + + expect(buildRunningThreadTurnInterruptInput(runningThread, "running")).toEqual({ + threadId, + turnId: activeTurnId, + }); + expect(buildRunningThreadTurnInterruptInput(runningThread, "ready")).toBeNull(); + expect( + buildRunningThreadTurnInterruptInput(makeThread({ session: readySession }), "ready"), + ).toBeNull(); + expect(buildRunningThreadTurnInterruptInput(null, "disconnected")).toBeNull(); + }); + + it("targets a running thread before its active turn has been projected", () => { + const runningThread = makeThread({ + session: { + ...readySession, + status: "running", + activeTurnId: null, + }, + }); + + expect(buildRunningThreadTurnInterruptInput(runningThread, "running")).toEqual({ threadId }); + }); + + it("stops a turn that is still awaiting provider admission, like the Stop button", () => { + const admittingThread = makeThread({ + session: { ...readySession, status: "starting", activeTurnId: null }, + }); + + expect(buildRunningThreadTurnInterruptInput(admittingThread, "connecting")).toEqual({ + threadId, + }); + }); }); describe("deriveComposerSendState", () => { @@ -1647,3 +1714,62 @@ describe("floating browser preview", () => { expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); }); }); + +describe("checkout Git memory", () => { + it("answers from the last status seen for the same checkout", () => { + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); + }); + + it("does not answer for a checkout it has not seen", () => { + expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); + expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); + }); + + it("keeps environments apart", () => { + rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); + expect( + recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), + ).toBeUndefined(); + }); + + it("does not confuse an environment id containing the separator with a path", () => { + rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); + expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); + }); +}); + +describe("threadShellHasStarted", () => { + it("counts a thread that has a user message but no latest turn", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }), + ).toBe(true); + }); + + it("counts a thread with a live session and nothing else", () => { + expect( + threadShellHasStarted({ + latestTurn: null, + latestUserMessageAt: null, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ).toBe(true); + }); + + it("does not count a thread that never sent anything", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }), + ).toBe(false); + expect(threadShellHasStarted(null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1e7bfc1504..8f8f63a72a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,5 +1,3 @@ -import { shallow } from "zustand/vanilla/shallow"; -import type { RollbackTarget } from "@t3tools/client-runtime/rollback"; import { type AssetCreateUrlInput, type AssetCreateUrlResult, @@ -31,6 +29,10 @@ import { codexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; +import { + codexFeedbackMessage, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; import { type TurnDiffSummary, type ChatMessage, @@ -101,7 +103,7 @@ export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null, ): boolean { - return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey; + return targetKey !== null && targetKey !== previousTargetKey; } interface ProactivePanelObservation { @@ -137,11 +139,11 @@ export function shouldOpenProactiveTurnDiff(input: { turnCompleted: boolean; }): boolean { return ( - input.previousRunningTurnId !== undefined && - input.previousRunningTurnId !== null && input.runningTurnId === null && input.turnCompleted && - input.settledTurnId === input.previousRunningTurnId + input.settledTurnId !== null && + (input.previousRunningTurnId === undefined || + input.settledTurnId === input.previousRunningTurnId) ); } @@ -378,6 +380,46 @@ export function buildThreadTurnInterruptInput(thread: Pick = new Set(); + +/** + * Ids of timeline messages that exist only on this client: optimistic sends and + * `/feedback` transcripts. The timeline never anchors a rollback on them. Built + * from local state alone, so its identity holds while a turn streams. + */ +export function collectLocalTimelineMessageIds( + optimisticUserMessages: ReadonlyArray>, + feedbackSubmissions: ReadonlyArray, +): ReadonlySet { + if (optimisticUserMessages.length === 0 && feedbackSubmissions.length === 0) { + return NO_LOCAL_TIMELINE_MESSAGE_IDS; + } + return new Set([ + ...optimisticUserMessages.map((message) => message.id), + ...feedbackSubmissions.flatMap((submission) => [ + codexFeedbackMessage(submission).id, + codexFeedbackMessage(submission, "assistant").id, + ]), + ]); +} + +/** + * The interrupt a Stop action may send for the focused thread, or null when + * nothing can be stopped. Mirrors the composer's Stop button: a running turn, + * or a turn still awaiting provider admission (which has no turn id yet). + */ +export function buildRunningThreadTurnInterruptInput( + thread: Pick | null | undefined, + phase: SessionPhase, +): { threadId: ThreadId; turnId?: TurnId } | null { + const sessionStatus = thread?.session?.status; + const running = phase === "running" && sessionStatus === "running"; + if (!thread || !(running || sessionStatus === "starting")) { + return null; + } + return buildThreadTurnInterruptInput(thread); +} + export function reconcileMountedTerminalThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; @@ -678,12 +720,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean return key !== null && sessionDismissedBranchMismatchKeys.has(key); } +// Git status for a checkout arrives after the composer paints, and the branch +// strip mounts on the assumption that a project is a Git repo. Without a +// memory, a non-Git project would mount the strip and drop it on every visit. +// Keyed by environment and checkout for the session; never persisted. +const sessionCheckoutIsRepo = new Map(); + +function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string { + return JSON.stringify([environmentId, cwd]); +} + +export function rememberCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string, + isRepo: boolean, +): void { + sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo); +} + +export function recallCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string | null, +): boolean | undefined { + return cwd === null + ? undefined + : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), ); } +/** + * Whether a thread ran at least one turn, judged from its shell alone. + * + * `threadHasStarted` needs the detail: a thread whose latest turn was cleared + * still has messages, and the loading shell carries none. The shell records + * when the last user message landed, which every started thread has. + */ +export function threadShellHasStarted( + shell: Pick | null | undefined, +): boolean { + return Boolean( + shell && + (shell.latestTurn !== null || shell.latestUserMessageAt !== null || shell.session !== null), + ); +} + // `threadProvider` is the open branded driver kind carried by the session. // Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe // rollback / fork behavior — the routing layer is the right place to surface @@ -1000,17 +1085,6 @@ export function shouldRetargetThreadPullRequestPanel( ); } -/** Reuse timeline counts without changing Pylon's verified rollback targets. */ -export function buildRollbackTurnCountByMessageId( - targets: ReadonlyMap, - previous: Map | null = null, -): Map { - const counts = new Map( - [...targets].map(([messageId, target]) => [messageId, target.targetTurnCount]), - ); - return previous !== null && shallow(previous, counts) ? previous : counts; -} - // Returning to the window should land the caret in the composer, so the reader can type right // away. The exceptions are places where focus is deliberate: another text field, a terminal in // the drawer or the right panel, or an open dialog or popup. A focused button outside those is diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d2c4b6646..32059fb4cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,4 @@ import { videoMimeType } from "@t3tools/shared/video"; -import { buildRollbackTurnCountByMessageId } from "./ChatView.logic"; import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { type AssistantCitation, @@ -164,11 +163,9 @@ import { isImageAttachment, type SessionPhase, type Thread, - type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -278,6 +275,7 @@ import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; +import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut, @@ -361,7 +359,7 @@ import { type SessionInteractionSubmissionState, } from "./chat/ComposerSessionInteractionPanel"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; -import { resolveComposerTimelineInset } from "./composerFooterLayout"; +import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { @@ -423,7 +421,9 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, + buildRunningThreadTurnInterruptInput, buildThreadTurnInterruptInput, + collectLocalTimelineMessageIds, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, @@ -454,6 +454,8 @@ import { mergeFailedComposerSend, resolveFileAttachmentUrl, reconcileMountedTerminalThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, resolveThreadMetadataUpdateForNextTurn, @@ -3241,6 +3243,12 @@ export default function ChatView(props: ChatViewProps) { optimisticUserMessages, projectHandoffMessagePreviews, ]); + // Stable while a turn streams: it changes only when this client sends or + // resolves a local message, so the row projection keeps its fast path. + const localTimelineMessageIds = useMemo( + () => collectLocalTimelineMessageIds(optimisticUserMessages, feedbackSubmissions), + [feedbackSubmissions, optimisticUserMessages], + ); const timelineProjectionRef = useRef<{ threadKey: string | null; projection: TimelineEntriesProjection; @@ -3281,29 +3289,6 @@ export default function ChatView(props: ChatViewProps) { attachDraftHeroComposerAnchorRef, captureDraftHeroComposerRect, ] = useDraftHeroLayoutTransition(isDraftHeroState); - const { turnDiffSummaries } = useTurnDiffSummaries(activeThread); - const turnDiffSummaryByAssistantMessageId = useMemo(() => { - const byMessageId = new Map(); - for (const summary of turnDiffSummaries) { - if (!summary.assistantMessageId) continue; - byMessageId.set(summary.assistantMessageId, summary); - } - return byMessageId; - }, [turnDiffSummaries]); - const rollbackTargetsByUserMessageId = useMemo( - () => - activeThread ? deriveRollbackTargets(activeThread) : new Map(), - [activeThread], - ); - const lastRevertTurnCountRef = useRef | null>(null); - const revertTurnCountByUserMessageId = useMemo(() => { - const counts = buildRollbackTurnCountByMessageId( - rollbackTargetsByUserMessageId, - lastRevertTurnCountRef.current, - ); - lastRevertTurnCountRef.current = counts; - return counts; - }, [rollbackTargetsByUserMessageId]); const rollbackActive = isRollbackActive(activeThread?.rollbackStatus); const rollbackTargetIdle = activeThread?.session !== null && @@ -3465,8 +3450,17 @@ export default function ChatView(props: ChatViewProps) { const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; - // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + // Git status arrives after the composer paints. A checkout seen earlier in + // this session answers from memory, so a non-Git project does not mount the + // branch strip and then drop it. A never-seen checkout assumes Git, which + // is what nearly every project is. + const liveIsGitRepo = gitStatusQuery.data?.isRepo; + useEffect(() => { + if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { + rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); + } + }, [environmentId, gitStatusCwd, liveIsGitRepo]); + const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -3578,6 +3572,29 @@ export default function ChatView(props: ChatViewProps) { [activeServerThread, draftId, routeThreadKey, routeThreadRef], ); + // The Stop button, the composer handle, and the `thread.stop` shortcut all + // share this one interrupt path. + const interruptContextRef = useRef({ activeThread, phase, setThreadError }); + interruptContextRef.current = { activeThread, phase, setThreadError }; + const onInterrupt = useCallback(async () => { + const { activeThread, phase, setThreadError } = interruptContextRef.current; + const input = buildRunningThreadTurnInterruptInput(activeThread, phase); + if (!input || !activeThread) return; + const result = await interruptThreadTurn({ + environmentId: activeThread.environmentId, + input, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to interrupt the current turn.", + ); + } + }, [interruptThreadTurn]); + const canInterruptRunningThread = + buildRunningThreadTurnInterruptInput(activeThread, phase) !== null; + const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); }, [composerRef]); @@ -4236,7 +4253,8 @@ export default function ChatView(props: ChatViewProps) { const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown; proactivePanelObservationRef.current = { ...observation, - runningTurnId: diffAction === "defer" ? (previousRunningTurnId ?? null) : activeRunningTurnId, + // Preserve first-entry eligibility while the checkpoint or repository is loading. + runningTurnId: diffAction === "defer" ? previousRunningTurnId : activeRunningTurnId, targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey, }; @@ -5254,27 +5272,48 @@ export default function ChatView(props: ChatViewProps) { [activeComposerPlan?.turnId, activeComposerTaskSteps, agentSessionLive, threadActivities], ); - const publishComposerOverlayHeight = useCallback((height: number) => { - const nextHeight = Math.ceil(height); - if (nextHeight <= 0) return; - const previousHeight = composerOverlayHeightRef.current; - if (previousHeight !== nextHeight) { - composerOverlayHeightRef.current = nextHeight; - setComposerOverlayHeight(nextHeight); - } - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, - isResting: composerRestingRef.current, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); - } - setScrollToEndClearance((currentClearance) => - currentClearance === nextHeight ? currentClearance : nextHeight, - ); - }, []); + const publishComposerOverlayHeight = useCallback( + (height: number) => { + const nextHeight = Math.ceil(height); + if (nextHeight <= 0) return; + const previousHeight = composerOverlayHeightRef.current; + if (previousHeight !== nextHeight) { + composerOverlayHeightRef.current = nextHeight; + setComposerOverlayHeight(nextHeight); + } + const nextInset = resolveComposerTimelineInset({ + currentInset: composerTimelineInsetRef.current, + overlayHeight: nextHeight, + isResting: composerRestingRef.current, + }); + if (composerTimelineInsetRef.current !== nextInset) { + composerTimelineInsetRef.current = nextInset; + setComposerTimelineInset(nextInset); + } + const mainSurface = composerOverlayElement?.querySelector( + '[data-chat-composer-main-surface="true"]', + ); + const button = composerOverlayElement?.parentElement?.querySelector( + 'button[aria-label="Scroll to end"]', + ); + const clearance = + composerOverlayElement && mainSurface && button + ? resolveScrollToEndClearance({ + overlayHeight: nextHeight, + mainSurfaceTop: mainSurface.getBoundingClientRect().top, + button: button.getBoundingClientRect(), + attachments: Array.from( + composerOverlayElement.querySelectorAll( + '[data-composer-banner-surface="attached"]', + ), + (element) => element.getBoundingClientRect(), + ), + }) + : nextHeight; + setScrollToEndClearance(clearance); + }, + [composerOverlayElement], + ); // The composer reports its resting flag from a layout effect, which runs // before this component's own layout effects and before any resize // observation, so every measurement below sees the flag for its layout. @@ -5307,7 +5346,7 @@ export default function ChatView(props: ChatViewProps) { return () => { resizeObserver.disconnect(); }; - }, [composerOverlayElement, publishComposerOverlayHeight]); + }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); const linkedPullRequestStatus = useLinkedThreadPullRequest( activeThreadRef?.environmentId ?? null, linkedThreadPullRequest, @@ -6166,6 +6205,8 @@ export default function ChatView(props: ChatViewProps) { const shortcutContext = { terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), + previewFocus: isPreviewFocused(), + previewOpen: previewPanelOpen, modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; @@ -6336,6 +6377,16 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "thread.stop") { + // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog. + if (!canInterruptRunningThread) return; + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + void onInterrupt(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -6353,6 +6404,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef, activeThreadPinned, activeThreadSettled, + canInterruptRunningThread, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, @@ -6367,6 +6419,7 @@ export default function ChatView(props: ChatViewProps) { keybindings, handleUnsettleActiveThread, isServerThread, + onInterrupt, onToggleDiff, pinThread, settleThread, @@ -6374,6 +6427,7 @@ export default function ChatView(props: ChatViewProps) { supportsSettlement, confirmAndUnpinThread, copyActiveThreadReference, + previewPanelOpen, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -7329,21 +7383,6 @@ export default function ChatView(props: ChatViewProps) { } }; - const onInterrupt = 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.", - ); - } - }; - const onReloadSessionResources = useCallback(async () => { if (!activeThreadId) return; const result = await reloadThreadSessionResources({ @@ -8689,14 +8728,17 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadRef, isServerThread, onDiffPanelOpen], ); - // Both the Map and the revert handler are read from refs at call-time so - // the callback reference is fully stable and never busts context identity. - const rollbackTargetsRef = useRef(rollbackTargetsByUserMessageId); - rollbackTargetsRef.current = rollbackTargetsByUserMessageId; + // The thread and revert handler are read from refs at call-time so the + // callback reference is fully stable and never busts context identity. The + // rollback target is derived from server messages only when a revert is + // requested; the timeline projection owns the per-row lookups. + const rollbackThreadRef = useRef(activeThread); + rollbackThreadRef.current = activeThread; const onRevertToTurnCountRef = useRef(onRevertToTurnCount); onRevertToTurnCountRef.current = onRevertToTurnCount; const onRevertUserMessage = useCallback((messageId: MessageId) => { - const target = rollbackTargetsRef.current.get(messageId); + const thread = rollbackThreadRef.current; + const target = thread ? deriveRollbackTargets(thread).get(messageId) : undefined; if (!target) return; void onRevertToTurnCountRef.current(target); }, []); @@ -9076,16 +9118,14 @@ export default function ChatView(props: ChatViewProps) { timelineEntries={timelineEntries} latestTurn={activeLatestTurn} runningTurnId={activeRunningTurnId} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + turnDiffSummaries={activeThread.checkpoints} reportedTurnCosts={reportedTurnCosts} activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + supportsConversationRollback={rollbackTargetIdle} + localMessageIds={localTimelineMessageIds} onRevertUserMessage={onRevertUserMessage} - supportsConversationRollback={ - rollbackTargetIdle && rollbackTargetsByUserMessageId.size > 0 - } onUseArtifactTemplate={useArtifactTemplate} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} @@ -9219,6 +9259,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThreadId} activeThreadEnvironmentId={activeThread?.environmentId} activeThread={activeThread} + activeThreadShell={routeServerThreadShell} promptHistoryMessages={timelineMessages} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} @@ -9260,6 +9301,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} + providerCatalogKnown={serverConfig !== null} activeProjectDefaultModelSelection={ activeProject?.defaultModelSelection } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0c306571ec..9e50a48608 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -52,6 +52,7 @@ import { MessageCircleQuestionIcon, MessageSquareIcon, PaletteIcon, + CircleStopIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -1683,6 +1684,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (composerHandleRef?.current?.canStopThread()) { + actionItems.push({ + kind: "action", + value: "action:stop-thread", + searchTerms: ["stop", "interrupt", "cancel", "turn", "generation", "thread"], + title: "Stop the running turn", + icon: , + shortcutCommand: "thread.stop", + run: async () => { + composerHandleRef?.current?.stopThread(); + }, + }); + } + if (activeThreadReferenceCopyTarget !== null) { actionItems.push({ kind: "action", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 16773a9631..c3894d2350 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -87,7 +87,7 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { useThread } from "~/state/entities"; +import { useThreadShell } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -1012,9 +1012,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThread = useThread(activeThreadRef, { - waitForShell: activeDraftThread !== null, - }); + const activeServerThread = useThreadShell(activeThreadRef); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 390617fc04..f8327bd77f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -92,7 +92,12 @@ import { replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + buildRunningThreadTurnInterruptInput, + deriveComposerSendState, + readFileAsDataUrl, + threadShellHasStarted, +} from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, @@ -229,13 +234,14 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; -import { ContextWindowMeter } from "./ContextWindowMeter"; +import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; import { SessionGoalControl } from "./SessionGoalControl"; import { SessionHarnessControl } from "./SessionHarnessControl"; import { SessionInputQueueControl } from "./SessionInputQueueControl"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, + shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, @@ -905,7 +911,13 @@ import { } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { + type ChatMessage, + type SessionPhase, + type Thread, + type ThreadShell, + videoMimeType, +} from "../../types"; import { buildComposerPromptHistoryEntries, stepComposerPromptHistory, @@ -1161,6 +1173,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ContextWindowSnapshot | null; + reserveContextWindowMeter: boolean; activeThreadModelDisplayName: string | null; timestampFormat: UnifiedSettings["timestampFormat"]; contextCompaction: import("./ContextWindowMeter").ContextCompactionControlProps | null; @@ -1202,6 +1215,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compaction={props.contextCompaction} harnessRefinement={props.harnessRefinement} /> + ) : props.reserveContextWindowMeter ? ( + ) : null} boolean; canOpenSessionResources: () => boolean; openSessionResources: () => boolean; + /** Whether the focused thread has a running or admitting turn to stop. */ + canStopThread: () => boolean; + /** Runs the same interrupt as the Stop button and the `thread.stop` shortcut. */ + stopThread: () => boolean; compactContext: () => void; readSnapshot: () => { value: string; @@ -1306,6 +1325,8 @@ export interface ChatComposerProps { activeThreadId: ThreadId | null; activeThreadEnvironmentId: EnvironmentId | undefined; activeThread: Thread | undefined; + /** The routed server thread's shell, present before its detail loads. */ + activeThreadShell: ThreadShell | null; /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */ promptHistoryMessages: ReadonlyArray; isServerThread: boolean; @@ -1362,6 +1383,8 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; + /** False until the environment's server config has arrived at least once. */ + providerCatalogKnown: boolean; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -1515,6 +1538,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, lockedProvider, providerStatuses, + providerCatalogKnown, activeProjectDefaultModelSelection, activeThreadModelSelection, activeThreadActivities, @@ -1806,6 +1830,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // same driver kind. const selectedProviderEntry = composerSelection.entry; const noProviderAvailable = selectedProviderEntry === undefined; + // Before the catalog arrives, every thread resolves to "no provider". Send + // stays blocked either way; only the chrome waits, keeping the picker with + // the thread's own selection instead of swapping in the setup button and + // back once the catalog lands. + const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; + const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; const providerSelectionBlocked = composerSelection.blockedByUnavailablePreference; const activeSessionInstanceId = activeThread?.session?.providerInstanceId; const providerBindingConflict = composerDraft.providerBindingConflict; @@ -2606,6 +2636,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + const reserveContextWindowMeter = shouldReserveContextWindowMeter({ + meterEnabled: settings.contextWindowIndicatorEnabled, + detailLoading: props.threadSyncPhase === "loading", + threadStarted: threadShellHasStarted(props.activeThreadShell), + providerReportsContextWindow: selectedProviderStatus + ? selectedProviderStatus.reportsContextWindow === true + : null, + }); // ------------------------------------------------------------------ // Composer-local state @@ -4559,7 +4597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isStashMenuOpen || isDragOverComposer || isPreparingWorktree || - noProviderAvailable || + showProviderUnavailable || projectSelectionRequired || environmentUnavailable !== null || composerSubmissionError !== null || @@ -4838,7 +4876,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hiddenRestingBlockIds = restingBlockDefs .slice(restingBlockDefs.length - restingHiddenBlockCount) .map((def) => def.id); - const composerControls = noProviderAvailable ? ( + const composerControls = showProviderUnavailable ? ( @@ -6421,7 +6476,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "relative", isComposerResting && "flex min-w-0 items-center gap-1", isComposerResting && - (settings.contextWindowIndicatorEnabled && activeContextWindow + ((settings.contextWindowIndicatorEnabled && activeContextWindow) || + reserveContextWindowMeter ? "pr-28" : fileStagingLimit !== null ? "pr-20" @@ -6474,7 +6530,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Choose a project above to start a thread" : providerSelectionBlocked ? "Select another provider to send a message" - : noProviderAvailable + : showProviderUnavailable ? "Enable a provider in Settings to send a message" : phase === "disconnected" ? DISCONNECTED_COMPOSER_PLACEHOLDER @@ -6610,6 +6666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeContextWindow={ settings.contextWindowIndicatorEnabled ? activeContextWindow : null } + reserveContextWindowMeter={reserveContextWindowMeter} activeThreadModelDisplayName={activeThreadModelDisplayName} timestampFormat={settings.timestampFormat} contextCompaction={contextCompactionControl} diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 5de40a0d90..c540613b00 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -120,6 +120,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro >
{ if (event.pointerType === "touch") return; if (document.activeElement === peekRef.current) { diff --git a/apps/web/src/components/chat/ComposerTasksBadge.test.tsx b/apps/web/src/components/chat/ComposerTasksBadge.test.tsx index 24d77091eb..83778c7c2a 100644 --- a/apps/web/src/components/chat/ComposerTasksBadge.test.tsx +++ b/apps/web/src/components/chat/ComposerTasksBadge.test.tsx @@ -41,7 +41,7 @@ describe("ComposerTasksBadge", () => { expect(markup).toContain("Tasks"); expect(markup).toContain("Attach task progress"); expect(markup).not.toContain("·"); - expect(markup).toContain("1/3"); + expect(markup).toContain("1/3 complete"); expect(markup).toContain("Current task: Attach task progress"); expect(markup).toContain("lucide-list-todo"); expect(markup).toContain('aria-label="Dismiss tasks for this turn"'); @@ -67,7 +67,7 @@ describe("ComposerTasksBadge", () => { />, ); - expect(markup).toContain("1/3"); + expect(markup).toContain("1/3 complete"); expect(markup).not.toContain("data-composer-shoulder-tab"); }); @@ -114,9 +114,11 @@ describe("ComposerTasksBadge", () => { expect(markup).toContain("text-primary"); expect(markup).toContain("text-muted-foreground/40"); expect(markup).not.toContain('data-slot="dot-matrix"'); - expect(markup).toContain("Completed:"); - expect(markup).toContain("In progress:"); - expect(markup).toContain("Pending:"); + // Each row names its state in text, so status never rests on glyph color alone. + expect(markup).toContain(">Completed"); + expect(markup).toContain(">Running"); + expect(markup).toContain(">Pending"); + expect(markup).not.toContain('class="sr-only"'); }); it("does not render an empty task count", () => { diff --git a/apps/web/src/components/chat/ComposerTasksBadge.tsx b/apps/web/src/components/chat/ComposerTasksBadge.tsx index b6482a6b2e..494415c2bf 100644 --- a/apps/web/src/components/chat/ComposerTasksBadge.tsx +++ b/apps/web/src/components/chat/ComposerTasksBadge.tsx @@ -155,7 +155,7 @@ function TaskSummary({ className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined} data-composer-task-progress="true" > - {progress.completedSteps}/{progress.totalSteps} + {progress.completedSteps}/{progress.totalSteps} complete {delegatesLabel ? ( - {TASK_PROGRESS_STATUS_LABEL[step.status]}: + + {TASK_PROGRESS_STATUS_LABEL[step.status]} + { ).toBe(false); }); }); + +describe("shouldReserveContextWindowMeter", () => { + const loadingStartedThread = { + meterEnabled: true, + detailLoading: true, + threadStarted: true, + providerReportsContextWindow: true, + }; + + it("holds the meter's slot while a started thread's detail loads", () => { + expect(shouldReserveContextWindowMeter(loadingStartedThread)).toBe(true); + }); + + it("reserves nothing once the detail is in", () => { + expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, detailLoading: false })).toBe( + false, + ); + }); + + it("reserves nothing for a thread that never ran a turn", () => { + expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, threadStarted: false })).toBe( + false, + ); + }); + + it("reserves while the thread's provider is not in the catalog yet", () => { + expect( + shouldReserveContextWindowMeter({ + ...loadingStartedThread, + providerReportsContextWindow: null, + }), + ).toBe(true); + }); + + it("reserves nothing for a provider that does not stream usage", () => { + expect( + shouldReserveContextWindowMeter({ + ...loadingStartedThread, + providerReportsContextWindow: false, + }), + ).toBe(false); + }); + + it("reserves nothing while the meter is switched off", () => { + expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, meterEnabled: false })).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 6ff2b6e0a6..a04582a042 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -108,3 +108,30 @@ export function formatContextWindowCompactionMessage( ? `Context for ${modelDisplayName} compacts automatically when needed.` : "Context compacts automatically when needed."; } + +/** + * Whether the footer should hold the meter's slot before a snapshot exists. + * + * The snapshot comes from thread activities, which load after the shell. + * Reserving the slot while the detail loads, for a started thread, keeps the + * attach button still until the meter mounts. Once the detail is in, a + * missing snapshot means there is no usage to show and nothing is reserved. + * + * The meter renders from stored activities whatever the provider's state, so + * only a provider known not to stream usage skips the reservation. An unknown + * provider (catalog still loading, or the thread's provider disabled) reserves. + */ +export function shouldReserveContextWindowMeter(input: { + readonly meterEnabled: boolean; + readonly detailLoading: boolean; + readonly threadStarted: boolean; + /** `null` while the thread's provider is not in the catalog. */ + readonly providerReportsContextWindow: boolean | null; +}): boolean { + return ( + input.meterEnabled && + input.detailLoading && + input.threadStarted && + input.providerReportsContextWindow !== false + ); +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 509d730b76..1c8af0fa92 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -279,3 +279,8 @@ export function ContextWindowMeter(props: { ); } + +/** Holds the meter's footprint while a thread's activities are still loading. */ +export function ContextWindowMeterPlaceholder() { + return
); } +function TimelineMinimapNavigationButton({ + direction, + disabled, + onClick, +}: { + direction: "previous" | "next"; + disabled: boolean; + onClick: () => void; +}) { + const previous = direction === "previous"; + const label = previous ? "Previous turn" : "Next turn"; + const Icon = previous ? ChevronUpIcon : ChevronDownIcon; + + return ( + + + } + > + + + {label} + + ); +} + // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- @@ -1332,8 +1414,7 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); - const canRevertAgentWork = - ctx.supportsConversationRollback && typeof row.revertTurnCount === "number"; + const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
diff --git a/apps/web/src/components/chat/TaskProgressStatus.tsx b/apps/web/src/components/chat/TaskProgressStatus.tsx index 89b4dee5b0..6a1502a1a0 100644 --- a/apps/web/src/components/chat/TaskProgressStatus.tsx +++ b/apps/web/src/components/chat/TaskProgressStatus.tsx @@ -44,7 +44,7 @@ function segmentTone(status: TaskProgressStatus, waitingOn?: TaskProgressWaiting export const TASK_PROGRESS_STATUS_LABEL: Record = { pending: "Pending", - inProgress: "In progress", + inProgress: "Running", waiting: "Waiting", completed: "Completed", }; diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 87d7ac6fdc..fd3a14504c 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -5,11 +5,14 @@ import { type ProviderOptionSelection, type ServerProviderModel, } from "@t3tools/contracts"; +import { getProviderOptionDescriptors } from "@t3tools/shared/model"; +import { getProviderModelCapabilities } from "../../providerModels"; import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, + withImplicitFastModeDefault, } from "./composerProviderState"; // Everything in composerProviderState is now data-driven by the model's @@ -37,8 +40,16 @@ function selectDescriptor( }; } -function booleanDescriptor(id: string): Extract { - return { id, label: id, type: "boolean" }; +function booleanDescriptor( + id: string, + currentValue?: boolean, +): Extract { + return { + id, + label: id, + type: "boolean", + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; } function modelWith( @@ -373,6 +384,104 @@ describe("getComposerProviderState", () => { expect(state).not.toHaveProperty("composerSurfaceClassName"); expect(state).not.toHaveProperty("modelPickerIconClassName"); }); + + it("defaults fastMode to false when the provider reports true but the user has not selected it", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("cursor"), + model: MODEL, + models: modelWith([booleanDescriptor("fastMode", true)]), + modelOptions: undefined, + planModeEnabled: true, + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false])); + }); + + it("keeps explicit fastMode true when the user selected Fast", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("cursor"), + model: MODEL, + models: modelWith([booleanDescriptor("fastMode", true)]), + modelOptions: selections(["fastMode", true]), + planModeEnabled: true, + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", true])); + }); + + it("keeps explicit fastMode false when the user selected Normal", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("cursor"), + model: MODEL, + models: modelWith([booleanDescriptor("fastMode", true)]), + modelOptions: selections(["fastMode", false]), + planModeEnabled: true, + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", false])); + }); + + it("keeps the provider fastMode default for background text generation", () => { + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("cursor"), + model: MODEL, + models: modelWith([booleanDescriptor("fastMode", true)]), + modelOptions: undefined, + planModeEnabled: true, + capabilityContext: "background-text-generation", + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["fastMode", true])); + }); +}); + +describe("withImplicitFastModeDefault", () => { + it("injects fastMode false only when the model exposes fastMode and no selection exists", () => { + expect( + withImplicitFastModeDefault( + { + optionDescriptors: [booleanDescriptor("fastMode", true)], + }, + undefined, + ), + ).toEqual(selections(["fastMode", false])); + + expect( + withImplicitFastModeDefault( + { + optionDescriptors: [booleanDescriptor("fastMode", true)], + }, + selections(["fastMode", true]), + ), + ).toEqual(selections(["fastMode", true])); + }); + + it("does not add fastMode when the model does not expose it", () => { + expect( + withImplicitFastModeDefault( + { + optionDescriptors: [booleanDescriptor("thinking", true)], + }, + undefined, + ), + ).toBeUndefined(); + }); +}); + +describe("trait controls fastMode display", () => { + it("resolves traits fastMode to Normal when the provider defaults to true without a user selection", () => { + const models = modelWith([booleanDescriptor("fastMode", true)]); + const provider = ProviderDriverKind.make("cursor"); + const caps = getProviderModelCapabilities(models, MODEL, provider); + const resolved = withImplicitFastModeDefault(caps, undefined); + const descriptors = getProviderOptionDescriptors({ caps, selections: resolved }); + const fastMode = descriptors.find((descriptor) => descriptor.id === "fastMode"); + + expect(fastMode?.type).toBe("boolean"); + if (fastMode?.type === "boolean") { + expect(fastMode.currentValue).toBe(false); + } + }); }); describe("provider traits render guards", () => { diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 3ce313b94b..cdba6efe73 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -1,5 +1,6 @@ import type { ComposerControlSize } from "./ComposerControl"; import { + type ModelCapabilities, type ProviderDriverKind, type ProviderInstanceId, type ProviderOptionSelection, @@ -68,6 +69,59 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; } +/** + * Cursor ACP can report `fastMode: true` as the provider default. Pylon only + * treats Fast as selected when the user chose it (draft/sticky/settings). + * Otherwise inject an explicit `false` so new chats stay Normal and the + * send path can overwrite a prior Fast session — descriptor defaults are + * otherwise omitted by `buildExplicitProviderOptionSelectionsFromDescriptors`. + */ +export function withImplicitFastModeDefault( + caps: ModelCapabilities, + modelOptions: ReadonlyArray | null | undefined, +): ReadonlyArray | undefined { + const hasExplicitFastMode = modelOptions?.some((selection) => selection.id === "fastMode"); + if (hasExplicitFastMode) { + return modelOptions ?? undefined; + } + const hasFastModeDescriptor = caps.optionDescriptors?.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + if (!hasFastModeDescriptor) { + return modelOptions ?? undefined; + } + return [...(modelOptions ?? []), { id: "fastMode", value: false }]; +} + +function resolveComposerOptionSelections( + models: ReadonlyArray, + model: string, + provider: ProviderDriverKind, + modelOptions: ReadonlyArray | null | undefined, + planModeEnabled: boolean, + capabilityContext: ProviderModelCapabilityContext = "interactive", +): { + caps: ModelCapabilities; + selections: ReadonlyArray | undefined; +} { + const caps = getProviderModelCapabilities( + models, + model, + provider, + planModeEnabled, + capabilityContext, + ); + // Fast stays opt-in only where a composer can offer the choice. Background + // text generation keeps dispatching descriptor defaults (see below). + return { + caps, + selections: + capabilityContext === "background-text-generation" + ? (modelOptions ?? undefined) + : withImplicitFastModeDefault(caps, modelOptions), + }; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { const { provider, @@ -93,14 +147,15 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com }; } } - const caps = getProviderModelCapabilities( + const { caps, selections } = resolveComposerOptionSelections( models, model, provider, + modelOptions, planModeEnabled, capabilityContext, ); - const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); + const descriptors = getProviderOptionDescriptors({ caps, selections }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => descriptor.type === "select", @@ -122,7 +177,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com modelOptionsForDispatch: capabilityContext === "background-text-generation" ? buildProviderOptionSelectionsFromDescriptors(descriptors) - : buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, modelOptions), + : buildExplicitProviderOptionSelectionsFromDescriptors(descriptors, selections), ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame", @@ -155,13 +210,20 @@ function renderTraitsControl( isComposerOwned, } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; + const { selections: resolvedModelOptions } = resolveComposerOptionSelections( + models, + model, + provider, + modelOptions, + planModeEnabled, + ); if ( !hasTarget || !shouldRenderTraitsControls({ provider, models, model, - modelOptions, + modelOptions: resolvedModelOptions, prompt, planModeEnabled, }) @@ -176,7 +238,7 @@ function renderTraitsControl( {...(threadRef ? { threadRef } : {})} {...(draftId ? { draftId } : {})} model={model} - modelOptions={modelOptions} + modelOptions={resolvedModelOptions} prompt={prompt} onPromptChange={onPromptChange} planModeEnabled={planModeEnabled} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 2a04e01538..5c0e4327d9 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -7,6 +7,7 @@ import { COMPOSER_RESTING_EXPANSION_MIN_PX, getRestingComposerImagePreviewCounts, resolveComposerTimelineInset, + resolveScrollToEndClearance, resolveRestingComposerControlsLayout, resolveRestingComposerControlsNaturalWidth, shouldAnimateComposerRestingTransition, @@ -424,3 +425,27 @@ describe("resolveRestingComposerControlsLayout hysteresis", () => { ).toEqual({ hiddenCount: 2, visible: true }); }); }); + +describe("resolveScrollToEndClearance", () => { + it("removes the side tab gap in both composer states while clearing overlapping attachments", () => { + for (const overlayHeight of [120, 214]) { + const layout = { + overlayHeight, + mainSurfaceTop: 534, + button: { left: 340, right: 460 }, + attachments: [{ top: 500, left: 600, right: 700 }], + }; + expect(resolveScrollToEndClearance(layout)).toBe(overlayHeight - 34); + expect(resolveScrollToEndClearance({ ...layout, attachments: [] })).toBe(overlayHeight); + expect( + resolveScrollToEndClearance({ + ...layout, + attachments: [...layout.attachments, { top: 500, left: 100, right: 700 }], + }), + ).toBe(overlayHeight); + expect(resolveScrollToEndClearance({ ...layout, button: { left: 590, right: 710 } })).toBe( + overlayHeight, + ); + } + }); +}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 441a694052..c568319231 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -191,3 +191,20 @@ export function resolveRestingComposerControlsLayout( : minimumWidth <= hostWidth; return { hiddenCount, visible }; } + +export function resolveScrollToEndClearance(input: { + overlayHeight: number; + mainSurfaceTop: number; + button: { left: number; right: number }; + attachments: ReadonlyArray<{ top: number; left: number; right: number }>; +}): number { + let contentTop = input.mainSurfaceTop; + let top = contentTop; + for (const attachment of input.attachments) { + contentTop = Math.min(contentTop, attachment.top); + if (attachment.left < input.button.right && attachment.right > input.button.left) { + top = Math.min(top, attachment.top); + } + } + return Math.ceil(input.overlayHeight - (top - contentTop)); +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index dd6750b0c4..286eb83f57 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { buildKeybindingRows, @@ -149,7 +150,7 @@ describe("KeybindingsSettings.logic", () => { expect(options).not.toContain("customModeActive"); }); - it("builds command options from built-in commands and resolved project bindings", () => { + it("builds command options from all static commands and resolved project bindings", () => { const options = buildKeybindingCommandOptions([ { command: "script.setup-db.run", @@ -165,7 +166,15 @@ describe("KeybindingsSettings.logic", () => { ] satisfies ResolvedKeybindingsConfig); expect(options).toEqual( - expect.arrayContaining(["chat.new", "rightPanel.toggleMaximized", "script.setup-db.run"]), + expect.arrayContaining([ + "chat.new", + "rightPanel.toggleMaximized", + "thread.stop", + "script.setup-db.run", + ]), + ); + expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === "thread.stop")).toBe( + false, ); }); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index b1c317eea6..c9432ae1aa 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2371,6 +2371,25 @@ describe("composerDraftStore sticky composer settings", () => { expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor"); }); + it("preserves sticky provider options when model selection omits options", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelSelection( + modelSelection(CURSOR_DRIVER, "composer-2", { + fastMode: false, + }), + ); + store.setStickyModelSelection(modelSelection(CURSOR_DRIVER, "composer-2.5")); + + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE], + ).toEqual( + modelSelection(CURSOR_DRIVER, "composer-2.5", { + fastMode: false, + }), + ); + }); + it("applies sticky activeProvider to new drafts", () => { const store = useComposerDraftStore.getState(); const threadId = ThreadId.make("thread-sticky-active-provider"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e48b49912c..c76d5da444 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2963,9 +2963,17 @@ const composerDraftStore = create()( if (!normalized) { return state; } + const current = state.stickyModelSelectionByProvider[normalized.instanceId]; + // Model-only picker updates omit options (same contract as + // setModelSelection). Keep the last sticky traits so Fast/Normal + // survives Composer 2 → 2.5 and new chats. + const nextSelection = + normalized.options !== undefined + ? normalized + : createModelSelection(normalized.instanceId, normalized.model, current?.options); const nextMap: Partial> = { ...state.stickyModelSelectionByProvider, - [normalized.instanceId]: normalized, + [normalized.instanceId]: nextSelection, }; if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { return state.stickyActiveProvider === normalized.instanceId diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index df00557119..97ef43b657 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -738,6 +738,42 @@ describe("cross-command precedence", () => { }); describe("resolveShortcutCommand", () => { + it("resolves a custom stop-thread shortcut", () => { + const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]); + + assert.strictEqual( + resolveShortcutCommand(event({ key: "Escape", metaKey: true }), keybindings, { + platform: "MacIntel", + }), + "thread.stop", + ); + }); + + it("honors preview conditions for a stop-thread shortcut", () => { + const keybindings = compile([ + { + shortcut: modShortcut("escape"), + command: "thread.stop", + whenAst: whenIdentifier("previewFocus"), + }, + ]); + const input = event({ key: "Escape", metaKey: true }); + + assert.isNull( + resolveShortcutCommand(input, keybindings, { + platform: "MacIntel", + context: { previewFocus: false }, + }), + ); + assert.strictEqual( + resolveShortcutCommand(input, keybindings, { + platform: "MacIntel", + context: { previewFocus: true }, + }), + "thread.stop", + ); + }); + it("returns dynamic script commands", () => { const keybindings = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 46091d3fd4..dd205adc1e 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -29,15 +29,17 @@ describe("rightPanelStore", () => { }); it.each(["diff-first", "pull-request-first"])( - "keeps the linked pull request above the completed diff with %s delivery", + "prioritizes the linked pull request over browser and diff with %s delivery", (order) => { const store = useRightPanelStore.getState(); + store.openBrowser(refA, "existing-browser"); const revision = store.getUserActionRevision(refA); const requests = order === "diff-first" ? [completedDiff, linkedPullRequest] : [linkedPullRequest, completedDiff]; for (const surface of requests) store.openProactive(refA, surface, revision); + store.reconcileBrowserSurfaces(refA, ["existing-browser", "agent-browser"]); expect( selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA), diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 18f40085b8..05226595ee 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -81,6 +81,25 @@ describe("formatRelativeTimeUntilLabel", () => { }); }); +describe("formatChatTimestampTooltip", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it.each(["de-DE", "it-IT"])("keeps the English date label in a %s runtime", async (locale) => { + const DateTimeFormat = Intl.DateTimeFormat; + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (locales, options) { + return new DateTimeFormat(locales ?? locale, options); + }); + vi.resetModules(); + const { formatChatTimestampTooltip: format } = await import("./timestampFormat"); + const date = new Date(2026, 5, 4, 14, 4).toISOString(); + + expect(format(date, "24-hour")).toBe("14:04, 4th June 2026"); + }); +}); + describe("formatExpiresInLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 91c1fb5b2b..4393b18304 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -81,7 +81,7 @@ export function parseTimestampDate(isoDate: string): Date | null { // Deliberately not the host locale: the tooltip's ordinal suffix and // day-before-month order below are English, so a localized month alone would // read "4th Juni 2026". Localizing the whole label is a separate change. -const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); +const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); function ordinalSuffix(day: number): string { const lastTwo = day % 100; diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 2dc120e64e..2186c39df7 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -66,6 +66,12 @@ link, or its thread ID when no pull request is available. Its default shortcut i terminal copy keeps its existing shortcut. The command is also available from the command palette and on mobile hardware keyboards. +`thread.stop` stops the running turn in the focused thread, including a turn that is still waiting +to start. It does the same thing as the composer's Stop button and **Stop the running turn** in the +command palette. It has no default shortcut, so add one in **Settings** → **Keybindings** if you want +to use it. When nothing is running, the shortcut does nothing and leaves the key free for other +commands, such as `Escape` closing a dialog. + `thread.settle` settles the active thread or restores it when it is already settled. Its default shortcut is `mod+shift+s`, and it does not run while the terminal has focus. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 98e383b903..a8eb4faa86 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -74,8 +74,10 @@ Pylon works with the platforms your team already uses: the tree moves below the code. - Choose **Settings → General → Diff layout** to use stacked or side-by-side diffs. Changing the layout in either diff toolbar updates this preference too. -- Enable **Settings → General → Proactive panels** to open a newly linked review automatically and - switch to the completed turn's diff when agent work changes files. An open review stays in front, and manual panel choices take priority +- Enable **Settings → General → Proactive panels** to open a thread's linked review automatically, + both when you enter the thread and when a new link appears, and to show the diff of the latest + completed turn that changed files, both when you enter the thread and when agent work finishes. + An open review stays in front, and manual panel choices take priority - Open the review directly in your browser with one click - If Pylon cannot load a GitHub pull request, including when GitHub rate limits requests, use **Open on GitHub** in the error view diff --git a/docs/user/status-indicators.md b/docs/user/status-indicators.md index 74fb7bd530..f52cbbf9a7 100644 --- a/docs/user/status-indicators.md +++ b/docs/user/status-indicators.md @@ -21,7 +21,9 @@ green liveness halo. Reduce Motion hides both halos. Task lists use `✓` for completed steps, `●` for the current step, and `○` for pending or passive waiting steps. A step that is specifically waiting for you -uses an amber `●`. +uses an amber `●`. In the composer's task list, each row also names its state +as **Completed**, **Running**, **Pending**, or **Waiting**, and the tasks bar +shows progress as a count such as `2/5 complete`. A working thread also carries its plan progress as a step count, such as `3/7`, on sidebar rows and mobile thread rows. The count appears once the diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 411cff23c8..27e096cbb2 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -121,6 +121,12 @@ it.effect("parses keybinding rules", () => when: "!terminalFocus", }); assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); + + const parsedThreadStop = yield* decode(KeybindingRule, { + key: "mod+escape", + command: "thread.stop", + }); + assert.strictEqual(parsedThreadStop.command, "thread.stop"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 43e7645b3e..f2ba64d6a8 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; const THREAD_KEYBINDING_COMMANDS = [ + "thread.stop", "thread.previous", "thread.next", "thread.copyReference", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c46d7a93cd..e22e943d84 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -372,6 +372,9 @@ export const ServerProvider = Schema.Struct({ // legacy producers retain their exact behavior through compatibility helpers. featureCapabilities: Schema.optionalKey(ProviderFeatureCapabilities), showInteractionModeToggle: Schema.optional(Schema.Boolean), + // The driver streams context window usage, so a started thread will have a + // meter once its activities load. Clients reserve the meter's space on it. + reportsContextWindow: Schema.optional(Schema.Boolean), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), supportedRuntimeModes: Schema.optional(Schema.Array(RuntimeMode)), supportsBackgroundTextGeneration: Schema.optional(Schema.Boolean),