diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc9..cb7dea0ce2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -20,6 +20,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; +import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; @@ -353,7 +354,8 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function resolveWsRpc(tag: string): unknown { +function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { + const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } @@ -395,6 +397,19 @@ function resolveWsRpc(tag: string): unknown { truncated: false, }; } + if (tag === WS_METHODS.terminalOpen) { + return { + threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, + terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", + cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: NOW_ISO, + }; + } return {}; } @@ -423,7 +438,7 @@ const worker = setupWorker( client.send( JSON.stringify({ id: request.id, - result: resolveWsRpc(method), + result: resolveWsRpc(request.body), }), ); }); @@ -1048,6 +1063,130 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("creates a new thread from the global chat.new shortcut", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-chat-shortcut-test" as MessageId, + targetText: "chat shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "o", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID from the shortcut.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a fresh draft after the previous draft thread is promoted", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId, + targetText: "promoted draft shortcut test", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.new", + shortcut: { + key: "o", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + await newThreadButton.click(); + + const promotedThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a promoted draft thread UUID.", + ); + const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; + + const { syncServerReadModel } = useStore.getState(); + syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); + useComposerDraftStore.getState().clearDraftThread(promotedThreadId); + + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "o", + shiftKey: true, + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); + + const freshThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath, + "Shortcut should create a fresh draft instead of reusing the promoted thread.", + ); + expect(freshThreadPath).not.toBe(promotedThreadPath); + } finally { + await mounted.cleanup(); + } + }); + it("keeps long proposed plans lightweight until the user expands them", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4fb0bfb866..4b0485d7e5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -76,7 +76,7 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, + MAX_TERMINALS_PER_GROUP, type ChatMessage, type TurnDiffSummary, } from "../types"; @@ -117,6 +117,7 @@ import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -1013,7 +1014,16 @@ export default function ChatView({ threadId }: ChatViewProps) { (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); - const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; + const activeTerminalGroup = + terminalState.terminalGroups.find( + (group) => group.id === terminalState.activeTerminalGroupId, + ) ?? + terminalState.terminalGroups.find((group) => + group.terminalIds.includes(terminalState.activeTerminalId), + ) ?? + null; + const hasReachedSplitLimit = + (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -1061,17 +1071,17 @@ export default function ChatView({ threadId }: ChatViewProps) { setTerminalOpen(!terminalState.terminalOpen); }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { - if (!activeThreadId || hasReachedTerminalLimit) return; + if (!activeThreadId || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; storeSplitTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]); + }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { - if (!activeThreadId || hasReachedTerminalLimit) return; + if (!activeThreadId) return; const terminalId = `terminal-${randomUUID()}`; storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]); + }, [activeThreadId, storeNewTerminal]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; @@ -1138,8 +1148,7 @@ export default function ChatView({ threadId }: ChatViewProps) { DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = - wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; + const shouldCreateNewTerminal = wantsNewTerminal; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; @@ -1914,13 +1923,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, focusComposer, terminalState.terminalOpen]); useEffect(() => { - const isTerminalFocused = (): boolean => { - const activeElement = document.activeElement; - if (!(activeElement instanceof HTMLElement)) return false; - if (activeElement.classList.contains("xterm-helper-textarea")) return true; - return activeElement.closest(".thread-terminal-drawer .xterm") !== null; - }; - const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 634d91e9dc..f35f878269 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasUnseenCompletion, + resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -154,3 +155,27 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Completed", pulse: false }); }); }); + +describe("resolveThreadRowClassName", () => { + it("uses the darker selected palette when a thread is both selected and active", () => { + const className = resolveThreadRowClassName({ isActive: true, isSelected: true }); + expect(className).toContain("bg-primary/22"); + expect(className).toContain("hover:bg-primary/26"); + expect(className).toContain("dark:bg-primary/30"); + expect(className).not.toContain("bg-accent/85"); + }); + + it("uses selected hover colors for selected threads", () => { + const className = resolveThreadRowClassName({ isActive: false, isSelected: true }); + expect(className).toContain("bg-primary/15"); + expect(className).toContain("hover:bg-primary/19"); + expect(className).toContain("dark:bg-primary/22"); + expect(className).not.toContain("hover:bg-accent"); + }); + + it("keeps the accent palette for active-only threads", () => { + const className = resolveThreadRowClassName({ isActive: true, isSelected: false }); + expect(className).toContain("bg-accent/85"); + expect(className).toContain("hover:bg-accent"); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f1afa0f646..8acbed63a9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import type { Thread } from "../types"; +import { cn } from "../lib/utils"; import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; @@ -37,6 +38,37 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +export function resolveThreadRowClassName(input: { + isActive: boolean; + isSelected: boolean; +}): string { + const baseClassName = + "h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none focus-visible:ring-0"; + + if (input.isSelected && input.isActive) { + return cn( + baseClassName, + "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36", + ); + } + + if (input.isSelected) { + return cn( + baseClassName, + "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28", + ); + } + + if (input.isActive) { + return cn( + baseClassName, + "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70", + ); + } + + return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; hasPendingApprovals: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5bb0b84f72..86506f369a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -27,7 +27,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, @@ -40,14 +39,15 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; -import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; +import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { @@ -83,7 +83,11 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { + resolveThreadRowClassName, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -262,11 +266,8 @@ export default function Sidebar() { const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); @@ -276,6 +277,7 @@ export default function Sidebar() { const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const { settings: appSettings } = useAppSettings(); + const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), @@ -384,80 +386,6 @@ export default function Sidebar() { }); }, []); - const handleNewThread = useCallback( - ( - projectId: ProjectId, - options?: { - branch?: string | null; - worktreePath?: string | null; - envMode?: DraftThreadEnvMode; - }, - ): Promise => { - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); - if (storedDraftThread) { - return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); - if (routeThreadId === storedDraftThread.threadId) { - return; - } - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, - }); - })(); - } - clearProjectDraftThreadId(projectId); - - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); - } - setProjectDraftThreadId(projectId, routeThreadId); - return Promise.resolve(); - } - const threadId = newThreadId(); - const createdAt = new Date().toISOString(); - return (async () => { - setProjectDraftThreadId(projectId, threadId, { - createdAt, - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, - envMode: options?.envMode ?? "local", - runtimeMode: DEFAULT_RUNTIME_MODE, - }); - - await navigate({ - to: "/$threadId", - params: { threadId }, - }); - })(); - }, - [ - clearProjectDraftThreadId, - getDraftThreadByProjectId, - navigate, - getDraftThread, - routeThreadId, - setDraftThreadContext, - setProjectDraftThreadId, - ], - ); - const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = threads @@ -1023,37 +951,6 @@ export default function Sidebar() { ); useEffect(() => { - const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && selectedThreadIds.size > 0) { - event.preventDefault(); - clearSelection(); - return; - } - - const activeThread = routeThreadId - ? threads.find((thread) => thread.id === routeThreadId) - : undefined; - const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; - if (isChatNewLocalShortcut(event, keybindings)) { - const projectId = - activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - event.preventDefault(); - void handleNewThread(projectId); - return; - } - - if (!isChatNewShortcut(event, keybindings)) return; - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; - if (!projectId) return; - event.preventDefault(); - void handleNewThread(projectId, { - branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, - worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, - envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), - }); - }; - const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; @@ -1061,22 +958,11 @@ export default function Sidebar() { clearSelection(); }; - window.addEventListener("keydown", onWindowKeyDown); window.addEventListener("mousedown", onMouseDown); return () => { - window.removeEventListener("keydown", onWindowKeyDown); window.removeEventListener("mousedown", onMouseDown); }; - }, [ - clearSelection, - getDraftThread, - handleNewThread, - keybindings, - projects, - routeThreadId, - selectedThreadIds.size, - threads, - ]); + }, [clearSelection, selectedThreadIds.size]); useEffect(() => { if (!isElectron) return; @@ -1517,13 +1403,10 @@ export default function Sidebar() { render={
} size="sm" isActive={isActive} - className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${ - isSelected - ? "bg-primary/15 text-foreground dark:bg-primary/10" - : isActive - ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55" - : "text-muted-foreground" - }`} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} onClick={(event) => { handleThreadClick( event, @@ -1661,7 +1544,7 @@ export default function Sidebar() { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 7861212e48..8e480715f5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -22,7 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, + MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; import { readNativeApi } from "~/nativeApi"; @@ -605,7 +605,7 @@ export default function ThreadTerminalDrawer({ const showGroupHeaders = resolvedTerminalGroups.length > 1 || resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); - const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT; + const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; const terminalLabelById = useMemo( () => new Map( @@ -613,27 +613,24 @@ export default function ThreadTerminalDrawer({ ), [normalizedTerminalIds], ); - const splitTerminalActionLabel = hasReachedTerminalLimit - ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` + const splitTerminalActionLabel = hasReachedSplitLimit + ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"; - const newTerminalActionLabel = hasReachedTerminalLimit - ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` - : newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; const onSplitTerminalAction = useCallback(() => { - if (hasReachedTerminalLimit) return; + if (hasReachedSplitLimit) return; onSplitTerminal(); - }, [hasReachedTerminalLimit, onSplitTerminal]); + }, [hasReachedSplitLimit, onSplitTerminal]); const onNewTerminalAction = useCallback(() => { - if (hasReachedTerminalLimit) return; onNewTerminal(); - }, [hasReachedTerminalLimit, onNewTerminal]); + }, [onNewTerminal]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -744,7 +741,7 @@ export default function ThreadTerminalDrawer({
@@ -839,7 +832,7 @@ export default function ThreadTerminalDrawer({
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts new file mode 100644 index 0000000000..35f92d98e9 --- /dev/null +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -0,0 +1,116 @@ +import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { + type DraftThreadEnvMode, + type DraftThreadState, + useComposerDraftStore, +} from "../composerDraftStore"; +import { newThreadId } from "../lib/utils"; +import { useStore } from "../store"; + +export function useHandleNewThread() { + const projects = useStore((store) => store.projects); + const threads = useStore((store) => store.threads); + const navigate = useNavigate(); + const routeThreadId = useParams({ + strict: false, + select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + }); + const activeDraftThread = useComposerDraftStore((store) => + routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, + ); + + const activeThread = routeThreadId + ? threads.find((thread) => thread.id === routeThreadId) + : undefined; + + const handleNewThread = useCallback( + ( + projectId: ProjectId, + options?: { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + }, + ): Promise => { + const { + clearProjectDraftThreadId, + getDraftThread, + getDraftThreadByProjectId, + setDraftThreadContext, + setProjectDraftThreadId, + } = useComposerDraftStore.getState(); + const hasBranchOption = options?.branch !== undefined; + const hasWorktreePathOption = options?.worktreePath !== undefined; + const hasEnvModeOption = options?.envMode !== undefined; + const storedDraftThread = getDraftThreadByProjectId(projectId); + const latestActiveDraftThread: DraftThreadState | null = routeThreadId + ? getDraftThread(routeThreadId) + : null; + if (storedDraftThread) { + return (async () => { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(storedDraftThread.threadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, storedDraftThread.threadId); + if (routeThreadId === storedDraftThread.threadId) { + return; + } + await navigate({ + to: "/$threadId", + params: { threadId: storedDraftThread.threadId }, + }); + })(); + } + + clearProjectDraftThreadId(projectId); + + if ( + latestActiveDraftThread && + routeThreadId && + latestActiveDraftThread.projectId === projectId + ) { + if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { + setDraftThreadContext(routeThreadId, { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); + } + setProjectDraftThreadId(projectId, routeThreadId); + return Promise.resolve(); + } + + const threadId = newThreadId(); + const createdAt = new Date().toISOString(); + return (async () => { + setProjectDraftThreadId(projectId, threadId, { + createdAt, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + envMode: options?.envMode ?? "local", + runtimeMode: DEFAULT_RUNTIME_MODE, + }); + + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + })(); + }, + [navigate, routeThreadId], + ); + + return { + activeDraftThread, + activeThread, + handleNewThread, + projects, + routeThreadId, + }; +} diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts new file mode 100644 index 0000000000..d24c9572a3 --- /dev/null +++ b/apps/web/src/lib/terminalFocus.ts @@ -0,0 +1,6 @@ +export function isTerminalFocused(): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; + return activeElement.closest(".thread-terminal-drawer .xterm") !== null; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 0d7f1724b2..8e3145d999 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,10 +1,88 @@ +import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import ThreadSidebar from "../components/Sidebar"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { resolveShortcutCommand } from "../keybindings"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; + +function ChatRouteGlobalShortcuts() { + const clearSelection = useThreadSelectionStore((state) => state.clearSelection); + const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); + const { activeDraftThread, activeThread, handleNewThread, projects, routeThreadId } = + useHandleNewThread(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const terminalOpen = useTerminalStateStore((state) => + routeThreadId + ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen + : false, + ); + + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + + if (event.key === "Escape" && selectedThreadIdsSize > 0) { + event.preventDefault(); + clearSelection(); + return; + } + + const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; + if (!projectId) return; + + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + }, + }); + + if (command === "chat.newLocal") { + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(projectId); + return; + } + + if (command !== "chat.new") return; + event.preventDefault(); + event.stopPropagation(); + void handleNewThread(projectId, { + branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, + envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), + }); + }; + + window.addEventListener("keydown", onWindowKeyDown); + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + }; + }, [ + activeDraftThread, + activeThread, + clearSelection, + handleNewThread, + keybindings, + projects, + selectedThreadIdsSize, + terminalOpen, + ]); + + return null; +} + function ChatRouteLayout() { const navigate = useNavigate(); @@ -26,6 +104,7 @@ function ChatRouteLayout() { return ( + { ]); }); + it("caps splits at four terminals per group", () => { + const store = useTerminalStateStore.getState(); + store.splitTerminal(THREAD_ID, "terminal-2"); + store.splitTerminal(THREAD_ID, "terminal-3"); + store.splitTerminal(THREAD_ID, "terminal-4"); + store.splitTerminal(THREAD_ID, "terminal-5"); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalIds).toEqual([ + "default", + "terminal-2", + "terminal-3", + "terminal-4", + ]); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, + ]); + }); + it("creates new terminals in a separate group", () => { useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); @@ -62,6 +84,33 @@ describe("terminalStateStore actions", () => { ]); }); + it("allows unlimited groups while keeping each group capped at four terminals", () => { + const store = useTerminalStateStore.getState(); + store.splitTerminal(THREAD_ID, "terminal-2"); + store.splitTerminal(THREAD_ID, "terminal-3"); + store.splitTerminal(THREAD_ID, "terminal-4"); + store.newTerminal(THREAD_ID, "terminal-5"); + store.newTerminal(THREAD_ID, "terminal-6"); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalIds).toEqual([ + "default", + "terminal-2", + "terminal-3", + "terminal-4", + "terminal-5", + "terminal-6", + ]); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, + { id: "group-terminal-5", terminalIds: ["terminal-5"] }, + { id: "group-terminal-6", terminalIds: ["terminal-6"] }, + ]); + }); + it("tracks and clears terminal subprocess activity", () => { const store = useTerminalStateStore.getState(); store.splitTerminal(THREAD_ID, "terminal-2"); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index cf1ea8446c..b2cea6d560 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -11,7 +11,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, + MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "./types"; @@ -28,10 +28,7 @@ interface ThreadTerminalState { const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; function normalizeTerminalIds(terminalIds: string[]): string[] { - const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( - 0, - MAX_THREAD_TERMINAL_COUNT, - ); + const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; } @@ -243,10 +240,6 @@ function upsertTerminalIntoGroups( } const isNewTerminal = !normalized.terminalIds.includes(terminalId); - if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { - return normalized; - } - const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; @@ -297,6 +290,14 @@ function upsertTerminalIntoGroups( return normalized; } + if ( + isNewTerminal && + !destinationGroup.terminalIds.includes(terminalId) && + destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return normalized; + } + if (!destinationGroup.terminalIds.includes(terminalId)) { const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); if (anchorIndex >= 0) { diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d5fff12991..c071fb3f60 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -20,7 +20,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; export const DEFAULT_THREAD_TERMINAL_ID = "default"; -export const MAX_THREAD_TERMINAL_COUNT = 4; +export const MAX_TERMINALS_PER_GROUP = 4; export type ProjectScript = ContractProjectScript; export interface ThreadTerminalGroup {