Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/mobile/src/features/terminal/terminalMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
nextOpenTerminalId,
previousLiveTerminalId,
resolveProjectScriptTerminalId,
resolveProjectScriptTerminalIds,
type TerminalMenuSession,
} from "./terminalMenu";

Expand Down Expand Up @@ -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"]);
});
});
22 changes: 22 additions & 0 deletions apps/mobile/src/features/terminal/terminalMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ export function resolveProjectScriptTerminalId(input: {
return nextTerminalId(input.existingTerminalIds);
}

export function resolveProjectScriptTerminalIds(input: {
readonly commandCount: number;
readonly existingTerminalIds: ReadonlyArray<string>;
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;
}
Expand Down
96 changes: 75 additions & 21 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "@t3tools/client-runtime/state/threads";
import {
projectScriptCwd,
projectScriptCommands,
projectScriptRuntimeEnv,
resolveProjectScripts,
} from "@t3tools/shared/projectScripts";
Expand Down Expand Up @@ -54,7 +55,7 @@ import { GitActionProgressOverlay } from "./GitActionProgressOverlay";
import {
buildTerminalMenuSessions,
nextOpenTerminalId,
resolveProjectScriptTerminalId,
resolveProjectScriptTerminalIds,
} from "../terminal/terminalMenu";
import {
resolvePreferredThreadWorktreePath,
Expand All @@ -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 { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
Expand Down Expand Up @@ -234,6 +236,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);
Expand Down Expand Up @@ -576,7 +580,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",
Expand All @@ -594,38 +600,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 = {
Expand Down
77 changes: 77 additions & 0 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
78 changes: 42 additions & 36 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ProjectId } from "@t3tools/contracts";
import {
projectScriptCommands,
projectScriptRuntimeEnv,
resolveProjectScripts,
setupProjectScript,
Expand Down Expand Up @@ -148,53 +149,58 @@ 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,
}),
),
);
yield* terminalManager
.write({
threadId: input.threadId,
terminalId,
data: `${script.command}\r`,
})
.pipe(
Effect.mapError(
(cause) =>
new ProjectSetupScriptOperationError({
...errorContext,
operation: "writeCommand",
cause,
}),
),
);
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,
terminalId,
data: `${command}\r`,
})
.pipe(
Effect.mapError(
(cause) =>
new ProjectSetupScriptOperationError({
...errorContext,
operation: "writeCommand",
cause,
}),
),
);
}

return {
status: "started",
scriptId: script.id,
scriptName: script.name,
terminalId,
terminalId: baseTerminalId,
cwd,
} as const;
});
Expand Down
Loading
Loading