From 0e6d4cab61b9a0f267df3b2b2c09f3a196ac5412 Mon Sep 17 00:00:00 2001 From: Mazba Uddin Saif Date: Tue, 8 Sep 2026 21:54:27 +0600 Subject: [PATCH 1/3] feat(web): open project action terminals in split view Busy action shells were landing as extra tabs, so a Dev-style action could not show backend, frontend, and worker side by side. Extra commands on an action now start in the current split group, and newly reconciled terminals join that group instead of opening as separate tabs. --- .../features/terminal/terminalMenu.test.ts | 23 +++ .../src/features/terminal/terminalMenu.ts | 22 +++ .../features/threads/ThreadRouteScreen.tsx | 96 ++++++++--- .../src/project/ProjectSetupScriptRunner.ts | 35 ++-- .../web/src/components/ChatView.logic.test.ts | 44 +++++ apps/web/src/components/ChatView.logic.ts | 14 ++ apps/web/src/components/ChatView.tsx | 151 ++++++++++++------ .../src/components/ProjectScriptsControl.tsx | 1 + .../src/components/ThreadTerminalDrawer.tsx | 14 +- .../src/components/projectScriptEditor.tsx | 63 +++++++- .../settings/ProjectActionsList.tsx | 7 +- .../settings/ProjectSettingsPanel.tsx | 1 + apps/web/src/projectScripts.test.ts | 35 ++++ apps/web/src/projectScripts.ts | 10 ++ apps/web/src/terminalUiStateStore.test.ts | 20 ++- apps/web/src/terminalUiStateStore.ts | 23 ++- docs/user/project-settings.md | 8 + packages/contracts/src/orchestration.ts | 13 ++ packages/contracts/src/t3ProjectFile.test.ts | 32 +++- packages/contracts/src/t3ProjectFile.ts | 14 +- packages/shared/src/projectScripts.ts | 21 ++- packages/shared/src/t3ProjectFile.test.ts | 1 + 22 files changed, 546 insertions(+), 102 deletions(-) diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 2f8ce1377720..382d3bf4bba5 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -13,6 +13,7 @@ import { nextOpenTerminalId, previousLiveTerminalId, resolveProjectScriptTerminalId, + resolveProjectScriptTerminalIds, type TerminalMenuSession, } from "./terminalMenu"; @@ -226,3 +227,25 @@ describe("resolveProjectScriptTerminalId", () => { ).toBe("term-3"); }); }); + +describe("resolveProjectScriptTerminalIds", () => { + it("reuses the idle default shell for the first command", () => { + expect( + resolveProjectScriptTerminalIds({ + commandCount: 3, + existingTerminalIds: [DEFAULT_TERMINAL_ID], + hasRunningTerminal: false, + }), + ).toEqual([DEFAULT_TERMINAL_ID, "term-2", "term-3"]); + }); + + it("allocates a new shell for every command when one is already running", () => { + expect( + resolveProjectScriptTerminalIds({ + commandCount: 2, + existingTerminalIds: [DEFAULT_TERMINAL_ID], + hasRunningTerminal: true, + }), + ).toEqual(["term-2", "term-3"]); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 06cb74e9467d..bf00ff0bd362 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -154,6 +154,28 @@ export function resolveProjectScriptTerminalId(input: { return nextTerminalId(input.existingTerminalIds); } +export function resolveProjectScriptTerminalIds(input: { + readonly commandCount: number; + readonly existingTerminalIds: ReadonlyArray; + readonly hasRunningTerminal: boolean; +}): string[] { + const terminalIds: string[] = []; + const existingTerminalIds = [...input.existingTerminalIds]; + let hasRunningTerminal = input.hasRunningTerminal; + for (let index = 0; index < input.commandCount; index += 1) { + const terminalId = resolveProjectScriptTerminalId({ + existingTerminalIds, + hasRunningTerminal, + }); + terminalIds.push(terminalId); + if (!existingTerminalIds.includes(terminalId)) { + existingTerminalIds.push(terminalId); + } + hasRunningTerminal = true; + } + return terminalIds; +} + export function projectScriptMenuLabel(script: ProjectScript): string { return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; } diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 55f122d6389a..f98017e7b618 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -19,6 +19,7 @@ import { } from "@t3tools/client-runtime/state/threads"; import { projectScriptCwd, + projectScriptCommands, projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; @@ -54,7 +55,7 @@ import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { buildTerminalMenuSessions, nextOpenTerminalId, - resolveProjectScriptTerminalId, + resolveProjectScriptTerminalIds, } from "../terminal/terminalMenu"; import { resolvePreferredThreadWorktreePath, @@ -74,6 +75,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; +import { terminalEnvironment } from "../../state/terminal"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { @@ -231,6 +233,8 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -573,7 +577,9 @@ function ThreadRouteContent( return; } - const targetTerminalId = resolveProjectScriptTerminalId({ + const scriptCommands = projectScriptCommands(script); + const targetTerminalIds = resolveProjectScriptTerminalIds({ + commandCount: scriptCommands.length, existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), hasRunningTerminal: terminalMenuSessions.some( (session) => session.status === "running" || session.status === "starting", @@ -591,38 +597,86 @@ function ThreadRouteContent( project: { cwd: selectedThreadProject.workspaceRoot }, worktreePath: preferredWorktreePath, }); - stagePendingTerminalLaunch({ - target: { + + for (const [commandIndex, command] of scriptCommands.entries()) { + const targetTerminalId = targetTerminalIds[commandIndex]; + if (!targetTerminalId) continue; + const isLastCommand = commandIndex === scriptCommands.length - 1; + if (isLastCommand) { + stagePendingTerminalLaunch({ + target: { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId: targetTerminalId, + }, + launch: { + cwd, + worktreePath: preferredWorktreePath, + env, + initialInput: `${command}\r`, + }, + }); + terminalDebugLog("project-script:staged", { + scriptId: script.id, + terminalId: targetTerminalId, + cwd, + worktreePath: preferredWorktreePath, + }); + continue; + } + + const openResult = await openTerminal({ environmentId: selectedThread.environmentId, - threadId: selectedThread.id, - terminalId: targetTerminalId, - }, - launch: { - cwd, - worktreePath: preferredWorktreePath, - env, - initialInput: `${script.command}\r`, - }, - }); - terminalDebugLog("project-script:staged", { - scriptId: script.id, - terminalId: targetTerminalId, - cwd, - worktreePath: preferredWorktreePath, - }); + input: { + threadId: selectedThread.id, + terminalId: targetTerminalId, + cwd, + worktreePath: preferredWorktreePath, + env, + cols: 80, + rows: 24, + }, + }); + if (openResult._tag === "Failure") { + terminalDebugLog("project-script:open-failed", { + scriptId: script.id, + terminalId: targetTerminalId, + }); + return; + } + const writeResult = await writeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId: targetTerminalId, + data: `${command}\r`, + }, + }); + if (writeResult._tag === "Failure") { + terminalDebugLog("project-script:write-failed", { + scriptId: script.id, + terminalId: targetTerminalId, + }); + return; + } + } + const focusTerminalId = targetTerminalIds[targetTerminalIds.length - 1]; + if (!focusTerminalId) return; void navigation.navigate("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), - terminalId: targetTerminalId, + terminalId: focusTerminalId, }); }, [ navigation, + openTerminal, selectedThread, selectedThreadDetailWorktreePath, selectedThreadProject, terminalMenuSessions, + writeTerminal, ], ); const threadGitControlProps = { diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 3bbb0daa7994..499d632b7c9b 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,6 @@ import { ProjectId } from "@t3tools/contracts"; import { + projectScriptCommands, projectScriptRuntimeEnv, resolveProjectScripts, setupProjectScript, @@ -173,22 +174,24 @@ export const make = Effect.gen(function* () { }), ), ); - yield* terminalManager - .write({ - threadId: input.threadId, - terminalId, - data: `${script.command}\r`, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "writeCommand", - cause, - }), - ), - ); + for (const command of projectScriptCommands(script)) { + yield* terminalManager + .write({ + threadId: input.threadId, + terminalId, + data: `${command}\r`, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + ); + } return { status: "started", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b754c9408904..5729cc5de068 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -53,6 +53,7 @@ import { resolveDraftPromotionNavigationTarget, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, + resolveProjectScriptTerminalPlacement, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, @@ -1958,3 +1959,46 @@ describe("shouldRefocusComposerOnWindowFocus", () => { expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); }); }); + +describe("resolveProjectScriptTerminalPlacement", () => { + it("reuses the current shell when it is idle", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: false, + activeGroupSize: 1, + maxGroupSize: 4, + }), + ).toBe("reuse"); + }); + + it("splits beside a busy shell", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: true, + activeGroupSize: 1, + maxGroupSize: 4, + }), + ).toBe("split"); + }); + + it("keeps stacking in an existing vertical group", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: true, + activeGroupSize: 2, + maxGroupSize: 4, + splitDirection: "vertical", + }), + ).toBe("split-vertical"); + }); + + it("opens a new tab once the split group is full", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: true, + activeGroupSize: 4, + maxGroupSize: 4, + }), + ).toBe("new"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c251e8ffba32..837225a0eb82 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1063,3 +1063,17 @@ export function shouldRefocusComposerOnWindowFocus( ) === null ); } + +export type ProjectScriptTerminalPlacement = "reuse" | "split" | "split-vertical" | "new"; + +/** Where a project action should land when the current shell is already busy. */ +export function resolveProjectScriptTerminalPlacement(input: { + readonly needsNewTerminal: boolean; + readonly activeGroupSize: number; + readonly maxGroupSize: number; + readonly splitDirection?: "horizontal" | "vertical"; +}): ProjectScriptTerminalPlacement { + if (!input.needsNewTerminal) return "reuse"; + if (input.activeGroupSize >= input.maxGroupSize) return "new"; + return input.splitDirection === "vertical" ? "split-vertical" : "split"; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..f938ad3d404b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -65,6 +65,7 @@ import { } from "@t3tools/shared/model"; import { projectScriptCwd, + projectScriptCommands, projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; @@ -410,6 +411,7 @@ import { observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, + resolveProjectScriptTerminalPlacement, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -3786,9 +3788,8 @@ export default function ChatView(props: ChatViewProps) { const baseTerminalId = terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; + const scriptCommands = projectScriptCommands(script); setTerminalUiLaunchContext({ threadId: activeThreadId, @@ -3800,6 +3801,15 @@ export default function ChatView(props: ChatViewProps) { return; } setTerminalFocusRequestId((value) => value + 1); + const allocatedTerminalIds = [ + ...new Set([ + ...allocatableActiveTerminalIds, + ...selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + activeThreadRef, + ).terminalIds, + ]), + ]; const runtimeEnv = projectScriptRuntimeEnv({ project: { @@ -3808,59 +3818,96 @@ export default function ChatView(props: ChatViewProps) { worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); - const targetTerminalId = shouldCreateNewTerminal - ? nextTerminalId(allocatableActiveTerminalIds) - : baseTerminalId; - const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - }; - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); - } + for (const [commandIndex, command] of scriptCommands.entries()) { + const currentUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + activeThreadRef, + ); + const currentGroup = + currentUiState.terminalGroups.find( + (group) => group.id === currentUiState.activeTerminalGroupId, + ) ?? + currentUiState.terminalGroups.find((group) => + group.terminalIds.includes(currentUiState.activeTerminalId), + ) ?? + null; + const needsNewTerminal = + commandIndex > 0 || Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const scriptTerminalPlacement = resolveProjectScriptTerminalPlacement({ + needsNewTerminal, + activeGroupSize: currentGroup?.terminalIds.length ?? 0, + maxGroupSize: MAX_TERMINALS_PER_GROUP, + ...(currentGroup?.splitDirection === "vertical" + ? { splitDirection: "vertical" as const } + : {}), + }); + const targetTerminalId = + scriptTerminalPlacement === "reuse" + ? currentUiState.activeTerminalId || baseTerminalId + : nextTerminalId(allocatedTerminalIds); + if (scriptTerminalPlacement !== "reuse") { + allocatedTerminalIds.push(targetTerminalId); + } + const openTerminalInput: TerminalOpenInput = + scriptTerminalPlacement === "reuse" + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + }; + + if (scriptTerminalPlacement === "split-vertical") { + storeSplitTerminalVertical(activeThreadRef, targetTerminalId); + } else if (scriptTerminalPlacement === "split") { + storeSplitTerminal(activeThreadRef, targetTerminalId); + } else if (scriptTerminalPlacement === "new") { + storeNewTerminal(activeThreadRef, targetTerminalId); + } else { + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + } - const openResult = await openTerminal({ environmentId, input: openTerminalInput }); - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + const openResult = await openTerminal({ environmentId, input: openTerminalInput }); + if (openResult._tag === "Failure") { + if (!isAtomCommandInterrupted(openResult)) { + const error = squashAtomCommandFailure(openResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; } - return; - } - const writeResult = await writeTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, - }, - }); - if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + const writeResult = await writeTerminal({ + environmentId, + input: { + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${command}\r`, + }, + }); + if (writeResult._tag === "Failure") { + if (!isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; + } } }, [ @@ -3873,6 +3920,8 @@ export default function ChatView(props: ChatViewProps) { setThreadError, storeNewTerminal, storeSetActiveTerminal, + storeSplitTerminal, + storeSplitTerminalVertical, setLastInvokedScriptByProjectId, environmentId, openTerminal, diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..d576bc4f0aad 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -111,6 +111,7 @@ export default function ProjectScriptsControl({ const payload: NewProjectScriptInput = { name: fileScript.name, command: fileScript.command, + commands: fileScript.commands ?? [], icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, keybinding: null, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225bdf..b77dda51749e 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1191,10 +1191,22 @@ export default function ThreadTerminalDrawer({ for (const terminalId of normalizedTerminalIds) { if (assignedTerminalIds.has(terminalId)) continue; + const destinationGroup = + nextGroups.find( + (group) => + group.id === activeTerminalGroupId && + group.terminalIds.length < MAX_TERMINALS_PER_GROUP, + ) ?? nextGroups.find((group) => group.terminalIds.length < MAX_TERMINALS_PER_GROUP); + if (destinationGroup) { + destinationGroup.terminalIds.push(terminalId); + assignedTerminalIds.add(terminalId); + continue; + } nextGroups.push({ id: assignUniqueGroupId(`group-${terminalId}`), terminalIds: [terminalId], }); + assignedTerminalIds.add(terminalId); } const terminalOrderIndex = new Map( @@ -1207,7 +1219,7 @@ export default function ThreadTerminalDrawer({ }); return nextGroups; - }, [normalizedTerminalIds, terminalGroups]); + }, [activeTerminalGroupId, normalizedTerminalIds, terminalGroups]); const resolvedActiveGroupIndex = useMemo(() => { const indexById = resolvedTerminalGroups.findIndex( diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4ffd453955e9..4ae0b5c6199f 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -1,7 +1,8 @@ -import type { - ProjectScript, - ProjectScriptIcon, - ResolvedKeybindingsConfig, +import { + PROJECT_SCRIPT_MAX_COMMANDS, + type ProjectScript, + type ProjectScriptIcon, + type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, @@ -14,7 +15,9 @@ import { HammerIcon, ListChecksIcon, PlayIcon, + PlusIcon, WrenchIcon, + XIcon, } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react"; @@ -76,6 +79,8 @@ export function ScriptIcon({ export interface NewProjectScriptInput { name: string; command: string; + /** Extra commands besides `command`. Each runs in its own split terminal. */ + commands: ReadonlyArray; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; keybinding: string | null; @@ -90,6 +95,7 @@ export type ProjectScriptActionResult = AtomCommandResult; export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { name: "", command: "", + commands: [], icon: "play", runOnWorktreeCreate: false, keybinding: null, @@ -114,6 +120,7 @@ export function editorRequestForScript( initial: { name: script.name, command: script.command, + commands: script.commands ?? [], icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), @@ -148,6 +155,7 @@ export function ProjectScriptEditorDialog({ const formId = React.useId(); const [name, setName] = useState(""); const [command, setCommand] = useState(""); + const [extraCommands, setExtraCommands] = useState([]); const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); @@ -165,6 +173,7 @@ export function ProjectScriptEditorDialog({ if (!request) return; setName(request.initial.name); setCommand(request.initial.command); + setExtraCommands([...request.initial.commands]); setIcon(request.initial.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); @@ -217,6 +226,10 @@ export function ProjectScriptEditorDialog({ payload = { name: trimmedName, command: trimmedCommand, + commands: extraCommands + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .slice(0, PROJECT_SCRIPT_MAX_COMMANDS - 1), icon, runOnWorktreeCreate, keybinding: keybindingRule?.key ?? null, @@ -332,6 +345,48 @@ export function ProjectScriptEditorDialog({ value={command} onChange={(event) => setCommand(event.target.value)} /> + {extraCommands.map((entry, index) => ( +
+ { + const next = [...extraCommands]; + next[index] = event.target.value; + setExtraCommands(next); + }} + /> + +
+ ))} + {1 + extraCommands.length < PROJECT_SCRIPT_MAX_COMMANDS ? ( + + ) : null} +

+ Extra commands each run in a split terminal. +

diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx index 1794a5fdaa2e..5be974546820 100644 --- a/apps/web/src/components/settings/ProjectActionsList.tsx +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -1,4 +1,5 @@ import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { projectScriptCommands } from "@t3tools/shared/projectScripts"; import { SettingsIcon } from "lucide-react"; import { shortcutLabelForCommand } from "../../keybindings"; import { commandForProjectScript } from "../../projectScripts"; @@ -45,7 +46,11 @@ export function ProjectActionsList({ ) : null} } - description={{script.command}} + description={ + + {projectScriptCommands(script).join(" ยท ")} + + } control={ <> {shortcutLabel ? ( diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d88644fb7e3c..3969d5c17fb0 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -771,6 +771,7 @@ function ProjectDetail({ const payload: NewProjectScriptInput = { name: fileScript.name, command: fileScript.command, + commands: fileScript.commands ?? [], icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, keybinding: null, diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index a09a18d03b35..d1c2110e9878 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -3,6 +3,7 @@ import { shortcutLabelForCommand } from "./keybindings"; import { describe, expect, it } from "vite-plus/test"; import { projectScriptCwd, + projectScriptCommands, projectScriptRuntimeEnv, setupProjectScript, } from "@t3tools/shared/projectScripts"; @@ -56,6 +57,27 @@ describe("projectScripts helpers", () => { }); }); + it("stores extra commands and drops blanks", () => { + expect( + buildProjectScript("dev", { + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: [" npm run dev ", "", "python -m arq app.worker.WorkerSettings"], + icon: "play", + runOnWorktreeCreate: false, + previewUrl: null, + autoOpenPreview: false, + }), + ).toEqual({ + id: "dev", + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: ["npm run dev", "python -m arq app.worker.WorkerSettings"], + icon: "play", + runOnWorktreeCreate: false, + }); + }); + it("builds and parses script run commands", () => { const command = commandForProjectScript("lint"); expect(command).toBe("script.lint.run"); @@ -109,6 +131,19 @@ describe("projectScripts helpers", () => { expect(setupProjectScript(scripts)?.id).toBe("setup"); }); + it("lists the primary command then extras", () => { + expect( + projectScriptCommands({ + command: "uvicorn app.main:app --reload", + commands: ["npm run dev", " ", "python -m arq app.worker.WorkerSettings"], + }), + ).toEqual([ + "uvicorn app.main:app --reload", + "npm run dev", + "python -m arq app.worker.WorkerSettings", + ]); + }); + it("builds default runtime env for scripts", () => { const env = projectScriptRuntimeEnv({ project: { cwd: "/repo" }, diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index 20d06c9c572a..d7e9b0811e44 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -1,5 +1,6 @@ import { MAX_SCRIPT_ID_LENGTH, + PROJECT_SCRIPT_MAX_COMMANDS, SCRIPT_RUN_COMMAND_PATTERN, type KeybindingCommand, type ProjectScript, @@ -10,6 +11,7 @@ const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); export interface ProjectScriptInput { readonly name: ProjectScript["name"]; readonly command: ProjectScript["command"]; + readonly commands?: ReadonlyArray; readonly icon: ProjectScript["icon"]; readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"]; readonly previewUrl: Exclude | null; @@ -17,10 +19,18 @@ export interface ProjectScriptInput { } export function buildProjectScript(id: string, input: ProjectScriptInput): ProjectScript { + const extraCommands: string[] = []; + for (const extra of input.commands ?? []) { + const trimmed = extra.trim(); + if (trimmed.length === 0) continue; + extraCommands.push(trimmed); + if (extraCommands.length >= PROJECT_SCRIPT_MAX_COMMANDS - 1) break; + } return { id, name: input.name, command: input.command, + ...(extraCommands.length > 0 ? { commands: extraCommands } : {}), icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, ...(input.previewUrl === null diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index b0b1df96e1fe..fa312af8dcbe 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -257,8 +257,24 @@ describe("terminalUiStateStore actions", () => { expect(terminalUiState.terminalIds).toEqual(["term-a", "term-b"]); expect(terminalUiState.activeTerminalId).toBe("term-a"); expect(terminalUiState.terminalGroups).toEqual([ - { id: "group-term-a", terminalIds: ["term-a"] }, - { id: "group-term-b", terminalIds: ["term-b"] }, + { id: "group-term-a", terminalIds: ["term-a", "term-b"] }, + ]); + }); + + it("splits newly reconciled terminals into the active group", () => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.reconcileTerminalIds(THREAD_REF, [DEFAULT_THREAD_TERMINAL_ID, "backend", "frontend"]); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "backend", "frontend"], + }, ]); }); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 290ca8e5954c..fd09a861dd4d 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -99,6 +99,7 @@ function normalizeTerminalGroupIds(terminalIds: string[]): string[] { function normalizeTerminalGroups( terminalGroups: ThreadTerminalGroup[], terminalIds: string[], + activeGroupId = "", ): ThreadTerminalGroup[] { if (terminalIds.length === 0) { return []; @@ -132,10 +133,20 @@ function normalizeTerminalGroups( for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; + const destinationGroup = + nextGroups.find( + (group) => group.id === activeGroupId && group.terminalIds.length < MAX_TERMINALS_PER_GROUP, + ) ?? nextGroups.find((group) => group.terminalIds.length < MAX_TERMINALS_PER_GROUP); + if (destinationGroup) { + destinationGroup.terminalIds.push(terminalId); + assignedTerminalIds.add(terminalId); + continue; + } nextGroups.push({ id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), terminalIds: [terminalId], }); + assignedTerminalIds.add(terminalId); } return nextGroups; @@ -206,7 +217,11 @@ function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTer const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) ? state.activeTerminalId : (nextTerminalIds[0] ?? ""); - const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); + const terminalGroups = normalizeTerminalGroups( + state.terminalGroups, + nextTerminalIds, + state.activeTerminalGroupId, + ); const activeGroupIdFromState = terminalGroups.some( (group) => group.id === state.activeTerminalGroupId, ) @@ -464,7 +479,11 @@ function reconcileThreadTerminalSessionIds( ? normalized.activeTerminalId : (nextIds[0] ?? ""); - const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds); + const terminalGroups = normalizeTerminalGroups( + normalized.terminalGroups, + nextIds, + normalized.activeTerminalGroupId, + ); const activeGroupIdFromTerminal = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null; diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index c76c18544df2..8c11939147c9 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -19,6 +19,14 @@ Reset that list to use shared actions again. Existing project actions are preser Project names, icons, removal, and importing actions from a checkout remain project-specific. When there are several checkouts, the checkout picker selects which actions and grouping to edit. +## Actions + +Add commands under **Actions**. One action can include extra commands; on web and desktop each extra +command starts in its own split terminal. Use this for a backend, frontend, and worker that should +run together. Mobile starts the extra shells but shows one terminal at a time. + +In a checkout `t3.json`, extra commands go in `commands` next to `command`. + ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4d2f80a1101a..2e1710216cae 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -383,10 +383,23 @@ export const ProjectScriptIcon = Schema.Literals([ ]); export type ProjectScriptIcon = typeof ProjectScriptIcon.Type; +/** One action can run this many shell commands, each in its own terminal. */ +export const PROJECT_SCRIPT_MAX_COMMANDS = 4; +const PROJECT_SCRIPT_MAX_EXTRA_COMMANDS = PROJECT_SCRIPT_MAX_COMMANDS - 1; + export const ProjectScript = Schema.Struct({ id: TrimmedNonEmptyString, name: TrimmedNonEmptyString, command: TrimmedNonEmptyString, + /** + * Extra shell commands besides `command`. Each runs in its own split + * terminal when the user starts the action. + */ + commands: Schema.optional( + Schema.Array(TrimmedNonEmptyString).check( + Schema.isMaxLength(PROJECT_SCRIPT_MAX_EXTRA_COMMANDS), + ), + ), icon: ProjectScriptIcon, runOnWorktreeCreate: Schema.Boolean, /** diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index ed19c6d69887..02d93444d37d 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -43,8 +43,36 @@ describe("T3ProjectFile", () => { expect(decoded.scripts?.[0]).toEqual({ name: "Dev", command: "pnpm dev" }); }); - it("rejects scripts without a command", () => { - expect(() => decode({ scripts: [{ name: "Dev" }] })).toThrow(); + it("decodes extra script commands", () => { + const decoded = decode({ + scripts: [ + { + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: [" npm run dev ", "python -m arq app.worker.WorkerSettings"], + }, + ], + }); + + expect(decoded.scripts?.[0]).toEqual({ + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: ["npm run dev", "python -m arq app.worker.WorkerSettings"], + }); + }); + + it("rejects more extra commands than a split group can show", () => { + expect(() => + decode({ + scripts: [ + { + name: "Dev", + command: "one", + commands: ["two", "three", "four", "five"], + }, + ], + }), + ).toThrow(); }); it("rejects unknown script icons", () => { diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 5062a1a370b5..ef0b5d020245 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ThreadEnvMode } from "./environment.ts"; -import { ProjectScriptIcon } from "./orchestration.ts"; +import { PROJECT_SCRIPT_MAX_COMMANDS, ProjectScriptIcon } from "./orchestration.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ export const T3_PROJECT_FILE_NAME = "t3.json"; @@ -31,6 +31,18 @@ export const T3ProjectFileScript = Schema.Struct({ command: trimmedNonEmpty({ description: "Shell command executed in a T3 Code terminal at the project root.", }), + commands: Schema.optionalKey( + Schema.Array( + trimmedNonEmpty({ + description: "An extra shell command. Each extra command runs in its own split terminal.", + }), + ) + .annotate({ + description: + "Additional commands started with this action. Each runs in its own split terminal.", + }) + .check(Schema.isMaxLength(PROJECT_SCRIPT_MAX_COMMANDS - 1)), + ), icon: Schema.optionalKey( ProjectScriptIcon.annotate({ description: 'Icon shown next to the script in the scripts menu. Defaults to "play".', diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 4d98e36b4d70..ef667cdbdf5e 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,4 +1,9 @@ -import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; +import { + PROJECT_SCRIPT_MAX_COMMANDS, + type ProjectId, + type ProjectScript, + type ServerSettings, +} from "@t3tools/contracts"; /** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ export function resolveProjectScripts( @@ -55,3 +60,17 @@ export function projectScriptRuntimeEnv( export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { return scripts.find((script) => script.runOnWorktreeCreate) ?? null; } + +/** Commands an action should start, in order. `command` is always first. */ +export function projectScriptCommands(script: { + readonly command: string; + readonly commands?: readonly string[]; +}): readonly string[] { + const extras: string[] = []; + for (const extra of script.commands ?? []) { + const trimmed = extra.trim(); + if (trimmed.length === 0) continue; + extras.push(trimmed); + } + return [script.command, ...extras].slice(0, PROJECT_SCRIPT_MAX_COMMANDS); +} diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index a1986ff35f9b..e1c6e74e853b 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -46,6 +46,7 @@ describe("buildT3ProjectFileJsonSchema", () => { expect(Object.keys(script?.properties ?? {}).sort()).toEqual([ "autoOpenPreview", "command", + "commands", "icon", "name", "previewUrl", From d8f0a0d0671904e070664ca2ceaaf31610056b3e Mon Sep 17 00:00:00 2001 From: Mazba Uddin Saif Date: Tue, 8 Sep 2026 22:19:02 +0600 Subject: [PATCH 2/3] fix(web): give each setup command its own terminal Worktree setup was writing extra commands into one shell, and overflow terminals were joining unrelated split groups. --- .../project/ProjectSetupScriptRunner.test.ts | 77 +++++++++++++++++++ .../src/project/ProjectSetupScriptRunner.ts | 45 ++++++----- .../src/components/ThreadTerminalDrawer.tsx | 25 +++--- apps/web/src/terminalUiStateStore.test.ts | 32 ++++++++ apps/web/src/terminalUiStateStore.ts | 22 +++--- docs/user/project-settings.md | 3 +- 6 files changed, 156 insertions(+), 48 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 3a8c3ad71e69..de959f494b70 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -219,6 +219,83 @@ describe("ProjectSetupScriptRunner", () => { }, ); + it.effect("opens a separate terminal for each setup command", () => { + const open = vi.fn((input: { terminalId: string }) => + Effect.succeed({ + threadId: "thread-1", + terminalId: input.terminalId, + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: input.terminalId, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Dev", + command: "npm run api", + commands: ["npm run web"], + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + + expect(result).toEqual({ + status: "started", + scriptId: "setup", + scriptName: "Dev", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(open).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenNthCalledWith(1, { + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(open).toHaveBeenNthCalledWith(2, { + threadId: "thread-1", + terminalId: "setup-setup-2", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(write).toHaveBeenNthCalledWith(1, { + threadId: "thread-1", + terminalId: "setup-setup", + data: "npm run api\r", + }); + expect(write).toHaveBeenNthCalledWith(2, { + threadId: "thread-1", + terminalId: "setup-setup-2", + data: "npm run web\r", + }); + }).pipe(Effect.provide(testLayer(project, { open, write }))); + }); + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { const rootCause = new Error("stat failed"); const terminalError = new TerminalManager.TerminalCwdStatError({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 499d632b7c9b..02db8629b180 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -149,32 +149,35 @@ export const make = Effect.gen(function* () { } as const; } - const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const commands = projectScriptCommands(script); + const baseTerminalId = input.preferredTerminalId ?? `setup-${script.id}`; const cwd = input.worktreePath; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }); - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - ); - for (const command of projectScriptCommands(script)) { + for (const [commandIndex, command] of commands.entries()) { + const terminalId = + commandIndex === 0 ? baseTerminalId : `${baseTerminalId}-${commandIndex + 1}`; + yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); yield* terminalManager .write({ threadId: input.threadId, @@ -197,7 +200,7 @@ export const make = Effect.gen(function* () { status: "started", scriptId: script.id, scriptName: script.name, - terminalId, + terminalId: baseTerminalId, cwd, } as const; }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index b77dda51749e..bf94ee45d0b8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1189,23 +1189,20 @@ export default function ThreadTerminalDrawer({ }); } + let fillGroup = nextGroups.find( + (group) => + group.id === activeTerminalGroupId && group.terminalIds.length < MAX_TERMINALS_PER_GROUP, + ); for (const terminalId of normalizedTerminalIds) { if (assignedTerminalIds.has(terminalId)) continue; - const destinationGroup = - nextGroups.find( - (group) => - group.id === activeTerminalGroupId && - group.terminalIds.length < MAX_TERMINALS_PER_GROUP, - ) ?? nextGroups.find((group) => group.terminalIds.length < MAX_TERMINALS_PER_GROUP); - if (destinationGroup) { - destinationGroup.terminalIds.push(terminalId); - assignedTerminalIds.add(terminalId); - continue; + if (!fillGroup || fillGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP) { + fillGroup = { + id: assignUniqueGroupId(`group-${terminalId}`), + terminalIds: [], + }; + nextGroups.push(fillGroup); } - nextGroups.push({ - id: assignUniqueGroupId(`group-${terminalId}`), - terminalIds: [terminalId], - }); + fillGroup.terminalIds.push(terminalId); assignedTerminalIds.add(terminalId); } diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index fa312af8dcbe..997b6d193e8b 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -278,6 +278,38 @@ describe("terminalUiStateStore actions", () => { ]); }); + it("opens a new group when the active split group is already full", () => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.splitTerminal(THREAD_REF, "pane-2"); + store.splitTerminal(THREAD_REF, "pane-3"); + store.splitTerminal(THREAD_REF, "pane-4"); + store.newTerminal(THREAD_REF, "other-tab"); + store.setActiveTerminal(THREAD_REF, DEFAULT_THREAD_TERMINAL_ID); + store.reconcileTerminalIds(THREAD_REF, [ + DEFAULT_THREAD_TERMINAL_ID, + "pane-2", + "pane-3", + "pane-4", + "other-tab", + "overflow-a", + "overflow-b", + ]); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "pane-2", "pane-3", "pane-4"], + }, + { id: "group-other-tab", terminalIds: ["other-tab"] }, + { id: "group-overflow-a", terminalIds: ["overflow-a", "overflow-b"] }, + ]); + }); + it("does not import a closed panel terminal from stale metadata", () => { const store = useTerminalUiStateStore.getState(); store.newTerminal(THREAD_REF, "term-2"); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index fd09a861dd4d..f9a26280fe88 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -131,21 +131,19 @@ function normalizeTerminalGroups( }); } + let fillGroup = nextGroups.find( + (group) => group.id === activeGroupId && group.terminalIds.length < MAX_TERMINALS_PER_GROUP, + ); for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; - const destinationGroup = - nextGroups.find( - (group) => group.id === activeGroupId && group.terminalIds.length < MAX_TERMINALS_PER_GROUP, - ) ?? nextGroups.find((group) => group.terminalIds.length < MAX_TERMINALS_PER_GROUP); - if (destinationGroup) { - destinationGroup.terminalIds.push(terminalId); - assignedTerminalIds.add(terminalId); - continue; + if (!fillGroup || fillGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP) { + fillGroup = { + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [], + }; + nextGroups.push(fillGroup); } - nextGroups.push({ - id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), - terminalIds: [terminalId], - }); + fillGroup.terminalIds.push(terminalId); assignedTerminalIds.add(terminalId); } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 8c11939147c9..11a197c23bcc 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -22,7 +22,8 @@ When there are several checkouts, the checkout picker selects which actions and ## Actions Add commands under **Actions**. One action can include extra commands; on web and desktop each extra -command starts in its own split terminal. Use this for a backend, frontend, and worker that should +command starts in its own split terminal until the active split group reaches its terminal limit; +remaining commands open in separate tabs. Use this for a backend, frontend, and worker that should run together. Mobile starts the extra shells but shows one terminal at a time. In a checkout `t3.json`, extra commands go in `commands` next to `command`. From fcef62217b2e5c543640e328d69509894c8f9c75 Mon Sep 17 00:00:00 2001 From: Mazba Uddin Saif Date: Tue, 8 Sep 2026 22:19:02 +0600 Subject: [PATCH 3/3] fix(web): open overflow action terminals as separate tabs Once the active split group is full, extra shells should become new tabs instead of stacking into another split pane. --- .../src/components/ThreadTerminalDrawer.tsx | 27 +++++++++++-------- apps/web/src/terminalUiStateStore.test.ts | 3 ++- apps/web/src/terminalUiStateStore.ts | 26 +++++++++++------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index bf94ee45d0b8..ff4556b0f62d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1189,21 +1189,26 @@ export default function ThreadTerminalDrawer({ }); } - let fillGroup = nextGroups.find( - (group) => - group.id === activeTerminalGroupId && group.terminalIds.length < MAX_TERMINALS_PER_GROUP, - ); + const activeGroup = nextGroups.find((group) => group.id === activeTerminalGroupId) ?? null; + let fillGroup = + activeGroup && activeGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP ? activeGroup : null; + const stackIntoNewGroups = activeGroup === null; for (const terminalId of normalizedTerminalIds) { if (assignedTerminalIds.has(terminalId)) continue; - if (!fillGroup || fillGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP) { - fillGroup = { - id: assignUniqueGroupId(`group-${terminalId}`), - terminalIds: [], - }; - nextGroups.push(fillGroup); + if (fillGroup && fillGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP) { + fillGroup.terminalIds.push(terminalId); + assignedTerminalIds.add(terminalId); + continue; } - fillGroup.terminalIds.push(terminalId); + fillGroup = { + id: assignUniqueGroupId(`group-${terminalId}`), + terminalIds: [terminalId], + }; + nextGroups.push(fillGroup); assignedTerminalIds.add(terminalId); + if (!stackIntoNewGroups) { + fillGroup = null; + } } const terminalOrderIndex = new Map( diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index 997b6d193e8b..b67ee7755eae 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -306,7 +306,8 @@ describe("terminalUiStateStore actions", () => { terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "pane-2", "pane-3", "pane-4"], }, { id: "group-other-tab", terminalIds: ["other-tab"] }, - { id: "group-overflow-a", terminalIds: ["overflow-a", "overflow-b"] }, + { id: "group-overflow-a", terminalIds: ["overflow-a"] }, + { id: "group-overflow-b", terminalIds: ["overflow-b"] }, ]); }); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index f9a26280fe88..ace8e535ba70 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -131,20 +131,26 @@ function normalizeTerminalGroups( }); } - let fillGroup = nextGroups.find( - (group) => group.id === activeGroupId && group.terminalIds.length < MAX_TERMINALS_PER_GROUP, - ); + const activeGroup = nextGroups.find((group) => group.id === activeGroupId) ?? null; + let fillGroup = + activeGroup && activeGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP ? activeGroup : null; + const stackIntoNewGroups = activeGroup === null; for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; - if (!fillGroup || fillGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP) { - fillGroup = { - id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), - terminalIds: [], - }; - nextGroups.push(fillGroup); + if (fillGroup && fillGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP) { + fillGroup.terminalIds.push(terminalId); + assignedTerminalIds.add(terminalId); + continue; } - fillGroup.terminalIds.push(terminalId); + fillGroup = { + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [terminalId], + }; + nextGroups.push(fillGroup); assignedTerminalIds.add(terminalId); + if (!stackIntoNewGroups) { + fillGroup = null; + } } return nextGroups;