diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 3dc218d8252..58870bbab1d 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), ); +const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => + ({ + id: 7, + webContents: { getZoomFactor: () => zoomFactor }, + }) as unknown as Electron.BrowserWindow; + describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); @@ -70,7 +76,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(), items: [{ id: "copy", label: "Copy" }], position: Option.none(), }); @@ -81,20 +87,24 @@ describe("ElectronMenu", () => { it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { + let popupOptions: Electron.PopupOptions | undefined; buildFromTemplateMock.mockImplementation(() => ({ popup: (options: Electron.PopupOptions) => { + popupOptions = options; options.callback?.(); }, })); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(2), items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); assert.isTrue(Option.isNone(selectedItemId)); + assert.equal(popupOptions?.x, 21); + assert.equal(popupOptions?.y, 40); assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { label: "Copy", enabled: true, diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 09fb5d1807d..4d3e5a1c241 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } +// Renderer positions arrive in CSS pixels; popup() expects window points, so +// page zoom must be factored in or menus drift proportionally to their +// distance from the window origin. const normalizePosition = ( position: Option.Option, + zoomFactor: number, ): Option.Option => Option.filter( position, - ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, - ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + ({ x, y }) => + Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor), + ).pipe( + Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), + ); export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; @@ -214,7 +221,10 @@ export const make = Effect.gen(function* () { try { const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); + const popupPosition = normalizePosition( + input.position, + input.window.webContents.getZoomFactor(), + ); const popupOptions = Option.match(popupPosition, { onNone: (): Electron.PopupOptions => ({ window: input.window, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 16141e3cc36..587ec65cbc9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -74,8 +74,6 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); - const pathnameRef = useRef(pathname); - pathnameRef.current = pathname; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; @@ -113,7 +111,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const unsubscribe = onMenuAction((action) => { if (action === "open-settings") { - const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current); + const isSettingsRoute = /^\/settings(\/|$)/.test(pathname); if (!isSettingsRoute) { void navigate({ to: "/settings" }); } @@ -123,7 +121,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return () => { unsubscribe?.(); }; - }, [navigate]); + }, [navigate, pathname]); return ( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..bc7487cee29 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,11 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { Thread } from "../types"; @@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: completedTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: readySession, hasPendingApproval: false, hasPendingUserInput: false, @@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: newerTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, updatedAt: newerTurn.completedAt }, hasPendingApproval: false, hasPendingUserInput: false, @@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestTurn: runningTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, status: "running", @@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestTurn: runningTurn, + latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, status: "running", @@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("acknowledges a steering message projected onto the current running turn", () => { + const runningTurn = { + ...completedTurn, + state: "running" as const, + completedAt: null, + }; + const runningSession = { + ...readySession, + status: "running" as const, + activeTurnId: runningTurn.turnId, + }; + const localDispatch = createLocalDispatchSnapshot( + makeThread({ + latestTurn: runningTurn, + session: runningSession, + messages: [ + { + id: MessageId.make("message-before-steer"), + role: "user", + text: "Initial prompt", + turnId: runningTurn.turnId, + createdAt: runningTurn.requestedAt, + updatedAt: runningTurn.requestedAt, + streaming: false, + }, + ], + }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + latestUserMessageId: MessageId.make("message-steer"), + session: runningSession, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); + it("acknowledges pending user interaction and errors immediately", () => { const localDispatch = createLocalDispatchSnapshot(makeThread()); const common = { localDispatch, phase: "ready" as const, latestTurn: null, + latestUserMessageId: localDispatch.latestUserMessageId, session: null, hasPendingApproval: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..325f9afa90a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -387,6 +387,7 @@ export async function waitForStartedServerThread( export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; + latestUserMessageId: ChatMessage["id"] | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; @@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot( ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; + const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user"); return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), + latestUserMessageId: latestUserMessage?.id ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, @@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; latestTurn: Thread["latestTurn"] | null; + latestUserMessageId: ChatMessage["id"] | null; session: Thread["session"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; @@ -431,6 +435,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; + const latestUserMessageChanged = + input.localDispatch.latestUserMessageId !== input.latestUserMessageId; const latestTurnChanged = input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || @@ -438,6 +444,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); if (input.phase === "running") { + // Steering adds a user message to the current running turn without + // necessarily changing any of the turn timestamps. Treat that projected + // message as the server acknowledgment so the composer does not remain + // stuck in its local "Sending" state until the turn settles. + if (latestUserMessageChanged) { + return true; + } if (!latestTurnChanged) { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 878fa754460..80ddebd702b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,6 +53,7 @@ import { useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { @@ -201,6 +202,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -212,6 +214,14 @@ import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { + DRAFT_HERO_TRANSITION_ANIMATION_ID, + DRAFT_HERO_TRANSITION_DURATION_MS, + DRAFT_HERO_TRANSITION_EASING, + MOBILE_COMPOSER_VIEW_TRANSITION_NAME, + MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, + runMobileComposerTransition, +} from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, @@ -256,6 +266,78 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { + const transitionGroupRef = useRef(null); + const composerAnchorRef = useRef(null); + const previousStateRef = useRef(isDraftHeroState); + const previousComposerRectRef = useRef(null); + const animationRef = useRef(null); + const attachTransitionGroupRef = (element: HTMLDivElement | null) => { + transitionGroupRef.current = element; + }; + const attachComposerAnchorRef = (element: HTMLDivElement | null) => { + composerAnchorRef.current = element; + }; + const captureComposerRect = () => { + previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; + }; + + useLayoutEffect(() => { + const transitionGroup = transitionGroupRef.current; + const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; + const stateChanged = previousStateRef.current !== isDraftHeroState; + const prefersReducedMotion = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + const mobileComposerTransitionActive = + typeof document !== "undefined" && + document.documentElement.dataset.mobileComposerRouteTransition === "true"; + + animationRef.current?.cancel(); + animationRef.current = null; + + const previousComposerRect = previousComposerRectRef.current; + if ( + stateChanged && + !prefersReducedMotion && + !mobileComposerTransitionActive && + transitionGroup && + previousComposerRect && + nextComposerRect && + typeof transitionGroup.animate === "function" + ) { + const translateX = previousComposerRect.left - nextComposerRect.left; + const translateY = previousComposerRect.top - nextComposerRect.top; + if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { + const animation = transitionGroup.animate( + [ + { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration: DRAFT_HERO_TRANSITION_DURATION_MS, + easing: DRAFT_HERO_TRANSITION_EASING, + }, + ); + animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; + animationRef.current = animation; + void animation.finished + .catch(() => undefined) + .then(() => { + if (animationRef.current !== animation) { + return; + } + animationRef.current = null; + }); + } + } + + previousStateRef.current = isDraftHeroState; + previousComposerRectRef.current = nextComposerRect; + }, [isDraftHeroState]); + + return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; +} const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); @@ -339,6 +421,7 @@ type ChatViewProps = threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; + forceExpandedMobileComposer?: boolean; routeKind: "server"; draftId?: never; } @@ -347,6 +430,7 @@ type ChatViewProps = threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; + forceExpandedMobileComposer?: boolean; routeKind: "draft"; draftId: DraftId; }; @@ -368,6 +452,8 @@ function useLocalDispatchState(input: { threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); + const latestUserMessageId = + input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null; const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -379,6 +465,7 @@ function useLocalDispatchState(input: { localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, + latestUserMessageId, session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, @@ -391,6 +478,7 @@ function useLocalDispatchState(input: { input.activeThread?.session, input.phase, input.threadError, + latestUserMessageId, localDispatch, ], ); @@ -979,6 +1067,14 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane ); }); +// Errors surface through two maps (draft-keyed and thread-keyed) whose entries +// can race around promotion, so each write carries its time to let the latest +// one win when they collide. +type LocalThreadErrorEntry = { + readonly message: string | null; + readonly at: number; +}; + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -986,6 +1082,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind, onDiffPanelOpen, reserveTitleBarControlInset = true, + forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( @@ -1035,10 +1132,10 @@ function ChatViewContent(props: ChatViewProps) { ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useThread(routeKind === "server" ? routeThreadRef : null); + const serverThread = useThread(routeThreadRef); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore((store) => - routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + const activeThreadLastVisitedAt = useUiStateStore( + (store) => store.threadLastVisitedAtById[routeThreadKey], ); const settings = useEnvironmentSettings(environmentId); const setStickyComposerModelSelection = useComposerDraftStore( @@ -1103,10 +1200,10 @@ function ChatViewContent(props: ChatViewProps) { const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< - Record + Record >({}); const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< - Record + Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -1218,11 +1315,45 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useProject(fallbackDraftProjectRef); - const localDraftError = - routeKind === "server" && serverThread - ? null - : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); - const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null; + const localDraftError = serverThread + ? null + : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null); + const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null; + // Draft errors are keyed by draftId while server errors are keyed by thread + // key, so a pending draft entry must migrate when the server thread loads or + // a failed send would silently disappear on promotion. When both keys hold + // an entry, the most recent write wins. + useEffect(() => { + if (!serverThread || !draftId) { + return; + } + const pendingDraftEntry = localDraftErrorsByDraftId[draftId]; + if (pendingDraftEntry === undefined) { + return; + } + setLocalDraftErrorsByDraftId((existing) => { + if (existing[draftId] === undefined) { + return existing; + } + const next = { ...existing }; + delete next[draftId]; + return next; + }); + setLocalServerErrorsByThreadKey((existing) => { + const currentEntry = existing[routeThreadKey]; + if ( + currentEntry !== undefined && + (currentEntry.at > pendingDraftEntry.at || + currentEntry.message === pendingDraftEntry.message) + ) { + return existing; + } + return { + ...existing, + [routeThreadKey]: pendingDraftEntry, + }; + }); + }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]); const localDraftThread = useMemo( () => draftThread @@ -1237,7 +1368,10 @@ function ChatViewContent(props: ChatViewProps) { : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], ); - const isServerThread = routeKind === "server" && serverThread !== null; + // Promotion is data-driven: the draft route keeps rendering while the + // server thread (same pre-allocated ref) starts, so live state must not + // depend on which route is mounted. + const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) @@ -2063,6 +2197,16 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); + const draftHeroDockRequested = + activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; + const isDraftHeroState = + isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + const [ + attachDraftHeroTransitionGroupRef, + attachDraftHeroComposerAnchorRef, + captureDraftHeroComposerRect, + ] = useDraftHeroLayoutTransition(isDraftHeroState); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { @@ -2112,12 +2256,13 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; + const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( - gitCwd === null + gitStatusCwd === null ? null : vcsEnvironment.status({ environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitStatusCwd }, }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -2151,6 +2296,9 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const initialDiffPanelGitScope = + gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; + const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -2226,6 +2374,7 @@ function ChatViewContent(props: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; if ( serverThread && targetThreadId === routeThreadRef.threadId && @@ -2233,24 +2382,24 @@ function ChatViewContent(props: ChatViewProps) { serverThread.id === targetThreadId ) { setLocalServerErrorsByThreadKey((existing) => { - if ((existing[routeThreadKey] ?? null) === nextError) { + if ((existing[routeThreadKey]?.message ?? null) === nextError) { return existing; } return { ...existing, - [routeThreadKey]: nextError, + [routeThreadKey]: nextEntry, }; }); return; } const localDraftErrorKey = draftId ?? targetThreadId; setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey] ?? null) === nextError) { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { return existing; } return { ...existing, - [localDraftErrorKey]: nextError, + [localDraftErrorKey]: nextEntry, }; }); }, @@ -2771,9 +2920,19 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; + if (planSidebarOpen) { + dismissPlanSidebarForCurrentTurn(); + } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); + }, [ + activeThreadRef, + dismissPlanSidebarForCurrentTurn, + isGitRepo, + isServerThread, + onDiffPanelOpen, + planSidebarOpen, + ]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -4068,7 +4227,16 @@ function ChatViewContent(props: ChatViewProps) { } return; } - if (!activeProject) return; + if (!activeProject) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Choose a project first", + description: "This draft no longer points to an available project.", + }), + ); + return; + } const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = @@ -4086,6 +4254,21 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; @@ -4340,6 +4523,9 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = false; if (!turnStartSucceeded) { + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); resetLocalDispatch(); } }; @@ -5077,7 +5263,12 @@ function ChatViewContent(props: ChatViewProps) { /> ) : activeRightPanelSurface?.kind === "diff" ? ( - + ) : activeRightPanelSurface?.kind === "plan" ? ( {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5232,134 +5424,187 @@ function ChatViewContent(props: ChatViewProps) { )} - {/* Input bar */} + {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
-