diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 906c3d0fe48c..b8257e07c649 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -2,10 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { - TerminalEvent, - TerminalOpenInput, -} from "@t3tools/contracts"; +import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput } from "@t3tools/contracts"; import { afterEach, describe, expect, it } from "vitest"; import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; @@ -109,10 +106,26 @@ function historyLogName(threadId: string): string { return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`; } +function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { + const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; + if (terminalId === DEFAULT_TERMINAL_ID) { + return `${threadPart}.log`; + } + return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`; +} + function historyLogPath(logsDir: string, threadId = "thread-1"): string { return path.join(logsDir, historyLogName(threadId)); } +function multiTerminalHistoryLogPath( + logsDir: string, + threadId = "thread-1", + terminalId = "default", +): string { + return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); +} + describe("TerminalManager", () => { const tempDirs: string[] = []; @@ -144,6 +157,7 @@ describe("TerminalManager", () => { const third = await manager.open(openInput()); expect(first.threadId).toBe("thread-1"); + expect(first.terminalId).toBe("default"); expect(second.threadId).toBe("thread-1"); expect(third.threadId).toBe("thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); @@ -167,6 +181,27 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("supports multiple terminals per thread with isolated sessions", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ terminalId: "default" })); + await manager.open(openInput({ terminalId: "term-2" })); + + const first = ptyAdapter.processes[0]; + const second = ptyAdapter.processes[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) return; + + await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); + await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); + + expect(first.writes).toEqual(["pwd\n"]); + expect(second.writes).toEqual(["ls\n"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + + manager.dispose(); + }); + it("clears transcript and emits cleared event", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); const events: TerminalEvent[] = []; @@ -184,6 +219,14 @@ describe("TerminalManager", () => { await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); expect(events.some((event) => event.type === "cleared")).toBe(true); + expect( + events.some( + (event) => + event.type === "cleared" && + event.threadId === "thread-1" && + event.terminalId === "default", + ), + ).toBe(true); manager.dispose(); }); @@ -262,6 +305,35 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("closes all terminals for a thread when close omits terminalId", async () => { + const { manager, ptyAdapter, logsDir } = makeManager(); + await manager.open(openInput({ terminalId: "default" })); + await manager.open(openInput({ terminalId: "sidecar" })); + const defaultProcess = ptyAdapter.processes[0]; + const sidecarProcess = ptyAdapter.processes[1]; + expect(defaultProcess).toBeDefined(); + expect(sidecarProcess).toBeDefined(); + if (!defaultProcess || !sidecarProcess) return; + + defaultProcess.emitData("default\n"); + sidecarProcess.emitData("sidecar\n"); + await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); + await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); + + await manager.close({ threadId: "thread-1", deleteHistory: true }); + + expect(defaultProcess.killed).toBe(true); + expect(sidecarProcess.killed).toBe(true); + expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe( + false, + ); + expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe( + false, + ); + + manager.dispose(); + }); + it("loads existing legacy transcript filenames and keeps new naming for writes", async () => { const { manager, logsDir } = makeManager(); const legacyPath = path.join(logsDir, "thread-1.log"); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index 0e4e130e02a1..8c8d18cdf558 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -3,17 +3,19 @@ import fs from "node:fs"; import path from "node:path"; import { + DEFAULT_TERMINAL_ID, + type TerminalClearInput, type TerminalCloseInput, type TerminalEvent, type TerminalOpenInput, + type TerminalResizeInput, type TerminalSessionSnapshot, type TerminalSessionStatus, - type TerminalThreadInput, type TerminalWriteInput, + terminalClearInputSchema, terminalCloseInputSchema, terminalOpenInputSchema, terminalResizeInputSchema, - terminalThreadInputSchema, terminalWriteInputSchema, } from "@t3tools/contracts"; @@ -36,6 +38,7 @@ export interface TerminalManagerOptions { interface TerminalSessionState { threadId: string; + terminalId: string; cwd: string; status: TerminalSessionStatus; pid: number | null; @@ -139,6 +142,14 @@ function toSafeThreadId(threadId: string): string { return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; } +function toSafeTerminalId(terminalId: string): string { + return Buffer.from(terminalId, "utf8").toString("base64url"); +} + +function toSessionKey(threadId: string, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; +} + export class TerminalManager extends EventEmitter { private readonly sessions = new Map(); private readonly logsDir: string; @@ -167,12 +178,14 @@ export class TerminalManager extends EventEmitter { return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); - const existing = this.sessions.get(input.threadId); + const sessionKey = toSessionKey(input.threadId, input.terminalId); + const existing = this.sessions.get(sessionKey); if (!existing) { - await this.flushPersistQueue(input.threadId); - const history = await this.readHistory(input.threadId); + await this.flushPersistQueue(input.threadId, input.terminalId); + const history = await this.readHistory(input.threadId, input.terminalId); const session: TerminalSessionState = { threadId: input.threadId, + terminalId: input.terminalId, cwd: input.cwd, status: "starting", pid: null, @@ -186,7 +199,7 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, }; - this.sessions.set(input.threadId, session); + this.sessions.set(sessionKey, session); this.startSession(session, input, "started"); return this.snapshot(session); } @@ -195,10 +208,10 @@ export class TerminalManager extends EventEmitter { this.stopProcess(existing); existing.cwd = input.cwd; existing.history = ""; - await this.persistHistory(existing.threadId, existing.history); + await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { existing.history = ""; - await this.persistHistory(existing.threadId, existing.history); + await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } if (!existing.process) { @@ -219,18 +232,22 @@ export class TerminalManager extends EventEmitter { async write(raw: TerminalWriteInput): Promise { const input = terminalWriteInputSchema.parse(raw); - const session = this.requireSession(input.threadId); + const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { - throw new Error(`Terminal is not running for thread: ${input.threadId}`); + throw new Error( + `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, + ); } session.process.write(input.data); } - async resize(raw: TerminalThreadInput & { cols: number; rows: number }): Promise { + async resize(raw: TerminalResizeInput): Promise { const input = terminalResizeInputSchema.parse(raw); - const session = this.requireSession(input.threadId); + const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { - throw new Error(`Terminal is not running for thread: ${input.threadId}`); + throw new Error( + `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, + ); } session.cols = input.cols; session.rows = input.rows; @@ -238,16 +255,17 @@ export class TerminalManager extends EventEmitter { session.process.resize(input.cols, input.rows); } - async clear(raw: TerminalThreadInput): Promise { - const input = terminalThreadInputSchema.parse(raw); + async clear(raw: TerminalClearInput): Promise { + const input = terminalClearInputSchema.parse(raw); await this.runWithThreadLock(input.threadId, async () => { - const session = this.requireSession(input.threadId); + const session = this.requireSession(input.threadId, input.terminalId); session.history = ""; session.updatedAt = new Date().toISOString(); - await this.persistHistory(input.threadId, session.history); + await this.persistHistory(input.threadId, input.terminalId, session.history); this.emitEvent({ type: "cleared", threadId: input.threadId, + terminalId: input.terminalId, createdAt: new Date().toISOString(), }); }); @@ -258,10 +276,12 @@ export class TerminalManager extends EventEmitter { return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); - let session = this.sessions.get(input.threadId); + const sessionKey = toSessionKey(input.threadId, input.terminalId); + let session = this.sessions.get(sessionKey); if (!session) { session = { threadId: input.threadId, + terminalId: input.terminalId, cwd: input.cwd, status: "starting", pid: null, @@ -275,14 +295,14 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, }; - this.sessions.set(input.threadId, session); + this.sessions.set(sessionKey, session); } else { this.stopProcess(session); session.cwd = input.cwd; } session.history = ""; - await this.persistHistory(input.threadId, session.history); + await this.persistHistory(input.threadId, input.terminalId, session.history); this.startSession(session, input, "restarted"); return this.snapshot(session); }); @@ -291,14 +311,28 @@ export class TerminalManager extends EventEmitter { async close(raw: TerminalCloseInput): Promise { const input = terminalCloseInputSchema.parse(raw); await this.runWithThreadLock(input.threadId, async () => { - const session = this.sessions.get(input.threadId); - if (session) { + if (input.terminalId) { + await this.closeSession( + input.threadId, + input.terminalId, + input.deleteHistory === true, + ); + return; + } + + const threadSessions = this.sessionsForThread(input.threadId); + for (const session of threadSessions) { this.stopProcess(session); - this.sessions.delete(input.threadId); + this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); } - await this.flushPersistQueue(input.threadId); + await Promise.all( + threadSessions.map((session) => + this.flushPersistQueue(session.threadId, session.terminalId), + ), + ); + if (input.deleteHistory) { - await this.deleteHistory(input.threadId); + await this.deleteAllHistoryForThread(input.threadId); } }); } @@ -382,6 +416,7 @@ export class TerminalManager extends EventEmitter { this.emitEvent({ type: eventType, threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), snapshot: this.snapshot(session), }); @@ -401,11 +436,13 @@ export class TerminalManager extends EventEmitter { this.emitEvent({ type: "error", threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), message, }); this.logger.error("failed to start terminal", { threadId: session.threadId, + terminalId: session.terminalId, error: message, ...(startedShell ? { shell: startedShell } : {}), }); @@ -415,10 +452,11 @@ export class TerminalManager extends EventEmitter { private onProcessData(session: TerminalSessionState, data: string): void { session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); session.updatedAt = new Date().toISOString(); - this.queuePersist(session.threadId, session.history); + this.queuePersist(session.threadId, session.terminalId, session.history); this.emitEvent({ type: "output", threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), data, }); @@ -435,6 +473,7 @@ export class TerminalManager extends EventEmitter { this.emitEvent({ type: "exited", threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), exitCode: session.exitCode, exitSignal: session.exitSignal, @@ -455,6 +494,7 @@ export class TerminalManager extends EventEmitter { const message = error instanceof Error ? error.message : String(error); this.logger.warn("failed to kill terminal process", { threadId: session.threadId, + terminalId: session.terminalId, error: message, }); } @@ -467,69 +507,87 @@ export class TerminalManager extends EventEmitter { session.unsubscribeExit = null; } - private queuePersist(threadId: string, history: string): void { - this.pendingPersistHistory.set(threadId, history); - this.schedulePersist(threadId); + private queuePersist(threadId: string, terminalId: string, history: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + this.pendingPersistHistory.set(persistenceKey, history); + this.schedulePersist(threadId, terminalId); } - private async persistHistory(threadId: string, history: string): Promise { - this.clearPersistTimer(threadId); - this.pendingPersistHistory.delete(threadId); - await this.enqueuePersistWrite(threadId, history); + private async persistHistory( + threadId: string, + terminalId: string, + history: string, + ): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + this.clearPersistTimer(threadId, terminalId); + this.pendingPersistHistory.delete(persistenceKey); + await this.enqueuePersistWrite(threadId, terminalId, history); } - private enqueuePersistWrite(threadId: string, history: string): Promise { + private enqueuePersistWrite( + threadId: string, + terminalId: string, + history: string, + ): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); const task = async () => { - await fs.promises.writeFile(this.historyPath(threadId), history, "utf8"); + await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); }; - const previous = this.persistQueues.get(threadId) ?? Promise.resolve(); + const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); const next = previous .catch(() => undefined) .then(task) .catch((error) => { this.logger.warn("failed to persist terminal history", { threadId, + terminalId, error: error instanceof Error ? error.message : String(error), }); }); - this.persistQueues.set(threadId, next); + this.persistQueues.set(persistenceKey, next); const finalized = next.finally(() => { - if (this.persistQueues.get(threadId) === next) { - this.persistQueues.delete(threadId); + if (this.persistQueues.get(persistenceKey) === next) { + this.persistQueues.delete(persistenceKey); } - if (this.pendingPersistHistory.has(threadId) && !this.persistTimers.has(threadId)) { - this.schedulePersist(threadId); + if ( + this.pendingPersistHistory.has(persistenceKey) && + !this.persistTimers.has(persistenceKey) + ) { + this.schedulePersist(threadId, terminalId); } }); void finalized.catch(() => undefined); return finalized; } - private schedulePersist(threadId: string): void { - if (this.persistTimers.has(threadId)) return; + private schedulePersist(threadId: string, terminalId: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + if (this.persistTimers.has(persistenceKey)) return; const timer = setTimeout(() => { - this.persistTimers.delete(threadId); - const pendingHistory = this.pendingPersistHistory.get(threadId); + this.persistTimers.delete(persistenceKey); + const pendingHistory = this.pendingPersistHistory.get(persistenceKey); if (pendingHistory === undefined) return; - this.pendingPersistHistory.delete(threadId); - void this.enqueuePersistWrite(threadId, pendingHistory); + this.pendingPersistHistory.delete(persistenceKey); + void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); }, this.persistDebounceMs); - this.persistTimers.set(threadId, timer); + this.persistTimers.set(persistenceKey, timer); } - private clearPersistTimer(threadId: string): void { - const timer = this.persistTimers.get(threadId); + private clearPersistTimer(threadId: string, terminalId: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + const timer = this.persistTimers.get(persistenceKey); if (!timer) return; clearTimeout(timer); - this.persistTimers.delete(threadId); + this.persistTimers.delete(persistenceKey); } - private async readHistory(threadId: string): Promise { + private async readHistory(threadId: string, terminalId: string): Promise { + const historyPath = this.historyPath(threadId, terminalId); try { - const raw = await fs.promises.readFile(this.historyPath(threadId), "utf8"); + const raw = await fs.promises.readFile(historyPath, "utf8"); const capped = capHistory(raw, this.historyLineLimit); if (capped !== raw) { - await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8"); + await fs.promises.writeFile(historyPath, capped, "utf8"); } return capped; } catch (error) { @@ -538,6 +596,10 @@ export class TerminalManager extends EventEmitter { } } + if (terminalId !== DEFAULT_TERMINAL_ID) { + return ""; + } + try { const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8"); const capped = capHistory(raw, this.historyLineLimit); @@ -553,31 +615,34 @@ export class TerminalManager extends EventEmitter { } } - private async deleteHistory(threadId: string): Promise { + private async deleteHistory(threadId: string, terminalId: string): Promise { + const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; + if (terminalId === DEFAULT_TERMINAL_ID) { + deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); + } try { - await Promise.all([ - fs.promises.rm(this.historyPath(threadId), { force: true }), - fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }), - ]); + await Promise.all(deletions); } catch (error) { this.logger.warn("failed to delete terminal history", { threadId, + terminalId, error: error instanceof Error ? error.message : String(error), }); } } - private async flushPersistQueue(threadId: string): Promise { - this.clearPersistTimer(threadId); + private async flushPersistQueue(threadId: string, terminalId: string): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + this.clearPersistTimer(threadId, terminalId); while (true) { - const pendingHistory = this.pendingPersistHistory.get(threadId); + const pendingHistory = this.pendingPersistHistory.get(persistenceKey); if (pendingHistory !== undefined) { - this.pendingPersistHistory.delete(threadId); - await this.enqueuePersistWrite(threadId, pendingHistory); + this.pendingPersistHistory.delete(persistenceKey); + await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); } - const pending = this.persistQueues.get(threadId); + const pending = this.persistQueues.get(persistenceKey); if (!pending) { return; } @@ -600,10 +665,54 @@ export class TerminalManager extends EventEmitter { } } - private requireSession(threadId: string): TerminalSessionState { - const session = this.sessions.get(threadId); + private async closeSession( + threadId: string, + terminalId: string, + deleteHistory: boolean, + ): Promise { + const key = toSessionKey(threadId, terminalId); + const session = this.sessions.get(key); + if (session) { + this.stopProcess(session); + this.sessions.delete(key); + } + await this.flushPersistQueue(threadId, terminalId); + if (deleteHistory) { + await this.deleteHistory(threadId, terminalId); + } + } + + private sessionsForThread(threadId: string): TerminalSessionState[] { + return [...this.sessions.values()].filter((session) => session.threadId === threadId); + } + + private async deleteAllHistoryForThread(threadId: string): Promise { + const threadPrefix = `${toSafeThreadId(threadId)}_`; + try { + const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); + const removals = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter( + (name) => + name === `${toSafeThreadId(threadId)}.log` || + name === `${legacySafeThreadId(threadId)}.log` || + name.startsWith(threadPrefix), + ) + .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); + await Promise.all(removals); + } catch (error) { + this.logger.warn("failed to delete terminal histories for thread", { + threadId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private requireSession(threadId: string, terminalId: string): TerminalSessionState { + const session = this.sessions.get(toSessionKey(threadId, terminalId)); if (!session) { - throw new Error(`Unknown terminal thread: ${threadId}`); + throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); } return session; } @@ -611,6 +720,7 @@ export class TerminalManager extends EventEmitter { private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { return { threadId: session.threadId, + terminalId: session.terminalId, cwd: session.cwd, status: session.status, pid: session.pid, @@ -625,8 +735,12 @@ export class TerminalManager extends EventEmitter { this.emit("event", event); } - private historyPath(threadId: string): string { - return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`); + private historyPath(threadId: string, terminalId: string): string { + const threadPart = toSafeThreadId(threadId); + if (terminalId === DEFAULT_TERMINAL_ID) { + return path.join(this.logsDir, `${threadPart}.log`); + } + return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); } private legacyHistoryPath(threadId: string): string { diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index e0a73a943d64..6cf6bdcd185b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -7,14 +7,21 @@ import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; -import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts"; +import { + DEFAULT_TERMINAL_ID, + WS_CHANNELS, + WS_METHODS, + type WsPush, + type WsResponse, +} from "@t3tools/contracts"; import { ProjectRegistry } from "./projectRegistry"; import type { + TerminalClearInput, TerminalCloseInput, TerminalEvent, TerminalOpenInput, + TerminalResizeInput, TerminalSessionSnapshot, - TerminalThreadInput, TerminalWriteInput, } from "@t3tools/contracts"; import type { TerminalManager } from "./terminalManager"; @@ -29,10 +36,16 @@ const pendingBySocket = new WeakMap(); class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { private readonly sessions = new Map(); + private key(threadId: string, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; + } + async open(input: TerminalOpenInput): Promise { const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; const snapshot: TerminalSessionSnapshot = { threadId: input.threadId, + terminalId, cwd: input.cwd, status: "running", pid: 4242, @@ -41,11 +54,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } exitSignal: null, updatedAt: now, }; - this.sessions.set(input.threadId, snapshot); + this.sessions.set(this.key(input.threadId, terminalId), snapshot); queueMicrotask(() => { this.emit("event", { type: "started", threadId: input.threadId, + terminalId, createdAt: now, snapshot, }); @@ -54,7 +68,8 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } } async write(input: TerminalWriteInput): Promise { - const existing = this.sessions.get(input.threadId); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const existing = this.sessions.get(this.key(input.threadId, terminalId)); if (!existing) { throw new Error(`Unknown terminal thread: ${input.threadId}`); } @@ -62,19 +77,22 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } this.emit("event", { type: "output", threadId: input.threadId, + terminalId, createdAt: new Date().toISOString(), data: input.data, }); }); } - async resize(_input: TerminalThreadInput & { cols: number; rows: number }): Promise {} + async resize(_input: TerminalResizeInput): Promise {} - async clear(input: TerminalThreadInput): Promise { + async clear(input: TerminalClearInput): Promise { + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; queueMicrotask(() => { this.emit("event", { type: "cleared", threadId: input.threadId, + terminalId, createdAt: new Date().toISOString(), }); }); @@ -82,8 +100,10 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } async restart(input: TerminalOpenInput): Promise { const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; const snapshot: TerminalSessionSnapshot = { threadId: input.threadId, + terminalId, cwd: input.cwd, status: "running", pid: 5252, @@ -92,11 +112,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } exitSignal: null, updatedAt: now, }; - this.sessions.set(input.threadId, snapshot); + this.sessions.set(this.key(input.threadId, terminalId), snapshot); queueMicrotask(() => { this.emit("event", { type: "restarted", threadId: input.threadId, + terminalId, createdAt: now, snapshot, }); @@ -105,7 +126,15 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } } async close(input: TerminalCloseInput): Promise { - this.sessions.delete(input.threadId); + if (input.terminalId) { + this.sessions.delete(this.key(input.threadId, input.terminalId)); + return; + } + for (const key of [...this.sessions.keys()]) { + if (key.startsWith(`${input.threadId}\u0000`)) { + this.sessions.delete(key); + } + } } dispose(): void {} @@ -340,6 +369,7 @@ describe("WebSocket Server", () => { }); expect(open.error).toBeUndefined(); expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1"); + expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID); const write = await sendRequest(ws, WS_METHODS.terminalWrite, { threadId: "thread-1", @@ -376,6 +406,7 @@ describe("WebSocket Server", () => { const manualEvent: TerminalEvent = { type: "output", threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "manual test output\n", }; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 88d2b233e85d..a982c1fc6e85 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,7 +7,7 @@ import Sidebar from "./components/Sidebar"; import { isElectron } from "./env"; import { DEFAULT_MODEL } from "./model-logic"; import { StoreProvider, useStore } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; import { onServerWelcome } from "./wsNativeApi"; import { useNativeApi } from "./hooks/useNativeApi"; @@ -27,18 +27,6 @@ function EventRouter() { }); }, [api, dispatch]); - useEffect(() => { - if (!api) return; - return api.terminal.onEvent((event) => { - if (event.type !== "exited") return; - dispatch({ - type: "SET_THREAD_TERMINAL_OPEN", - threadId: event.threadId, - open: false, - }); - }); - }, [api, dispatch]); - return null; } @@ -93,6 +81,15 @@ function AutoProjectBootstrap() { model: DEFAULT_MODEL, terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], events: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e27d5b5cf726..966d3eb4c28d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -269,6 +269,65 @@ export default function ChatView() { open: !isOpen, }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); + const splitTerminal = useCallback(() => { + if (!activeThreadId) return; + dispatch({ + type: "SPLIT_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId: `terminal-${crypto.randomUUID()}`, + }); + setTerminalFocusRequestId((value) => value + 1); + }, [activeThreadId, dispatch]); + const createNewTerminal = useCallback(() => { + if (!activeThreadId) return; + dispatch({ + type: "NEW_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId: `terminal-${crypto.randomUUID()}`, + }); + setTerminalFocusRequestId((value) => value + 1); + }, [activeThreadId, dispatch]); + const activateTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadId) return; + dispatch({ + type: "SET_THREAD_ACTIVE_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeThreadId, dispatch], + ); + const closeTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadId || !api) return; + const fallbackExitWrite = () => + api.terminal + .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) + .catch(() => undefined); + const fallbackClearAndExit = () => + api.terminal + .clear({ threadId: activeThreadId, terminalId }) + .catch(() => undefined) + .then(() => fallbackExitWrite()) + .catch(() => undefined); + if ("close" in api.terminal && typeof api.terminal.close === "function") { + void api.terminal + .close({ threadId: activeThreadId, terminalId, deleteHistory: true }) + .catch(() => fallbackClearAndExit()); + } else { + void fallbackClearAndExit(); + } + dispatch({ + type: "CLOSE_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeThreadId, api, dispatch], + ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; @@ -1449,9 +1508,17 @@ export default function ChatView() { key={activeThread.id} api={api} threadId={activeThread.id} - cwd={activeProject.cwd} + cwd={gitCwd ?? activeProject.cwd} height={activeThread.terminalHeight} + terminalIds={activeThread.terminalIds} + activeTerminalId={activeThread.activeTerminalId} + terminalGroups={activeThread.terminalGroups} + activeTerminalGroupId={activeThread.activeTerminalGroupId} focusRequestId={terminalFocusRequestId} + onSplitTerminal={splitTerminal} + onNewTerminal={createNewTerminal} + onActiveTerminalChange={activateTerminal} + onCloseTerminal={closeTerminal} onHeightChange={(height) => dispatch({ type: "SET_THREAD_TERMINAL_HEIGHT", @@ -1459,13 +1526,6 @@ export default function ChatView() { height, }) } - onThreadExited={() => - dispatch({ - type: "SET_THREAD_TERMINAL_OPEN", - threadId: activeThread.id, - open: false, - }) - } /> )} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4e0eed7b0ee1..32a832a491a6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,7 +5,12 @@ import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project, type Thread } from "../types"; +import { + DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, + type Project, + type Thread, +} from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; @@ -112,6 +117,15 @@ export default function Sidebar() { model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], events: [], diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 311a932a139c..b1a19087a3a1 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,10 +1,12 @@ import { FitAddon } from "@xterm/addon-fit"; +import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; import { type NativeApi } from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -15,7 +17,11 @@ import { resolvePathLinkTarget, } from "../terminal-links"; import { isTerminalClearShortcut } from "../terminal-shortcuts"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types"; +import { + DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, + type ThreadTerminalGroup, +} from "../types"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -103,162 +109,30 @@ function terminalThemeFromApp(): ITheme { }; } -interface ThreadTerminalDrawerProps { +interface TerminalViewportProps { api: NativeApi; threadId: string; + terminalId: string; cwd: string; - height: number; focusRequestId: number; - onHeightChange: (height: number) => void; - onThreadExited: () => void; + autoFocus: boolean; + resizeEpoch: number; + drawerHeight: number; } -export default function ThreadTerminalDrawer({ +function TerminalViewport({ api, threadId, + terminalId, cwd, - height, focusRequestId, - onHeightChange, - onThreadExited, -}: ThreadTerminalDrawerProps) { - const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + autoFocus, + resizeEpoch, + drawerHeight, +}: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); - const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); - const onHeightChangeRef = useRef(onHeightChange); - const onThreadExitedRef = useRef(onThreadExited); - const resizeStateRef = useRef<{ - pointerId: number; - startY: number; - startHeight: number; - } | null>(null); - const didResizeDuringDragRef = useRef(false); - - useEffect(() => { - onHeightChangeRef.current = onHeightChange; - }, [onHeightChange]); - - useEffect(() => { - onThreadExitedRef.current = onThreadExited; - }, [onThreadExited]); - - useEffect(() => { - drawerHeightRef.current = drawerHeight; - }, [drawerHeight]); - - const syncHeight = useCallback( - (nextHeight: number) => { - const clampedHeight = clampDrawerHeight(nextHeight); - if (lastSyncedHeightRef.current === clampedHeight) return; - lastSyncedHeightRef.current = clampedHeight; - onHeightChangeRef.current(clampedHeight); - }, - [], - ); - - const fitAndResizeTerminal = useCallback((preserveBottom = false) => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - preserveBottom && - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - activeFitAddon.fit(); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); - }, [api, threadId]); - - useEffect(() => { - const clampedHeight = clampDrawerHeight(height); - setDrawerHeight(clampedHeight); - lastSyncedHeightRef.current = clampedHeight; - }, [height, threadId]); - - const handleResizePointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - didResizeDuringDragRef.current = false; - resizeStateRef.current = { - pointerId: event.pointerId, - startY: event.clientY, - startHeight: drawerHeightRef.current, - }; - }, - [], - ); - - const handleResizePointerMove = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - setDrawerHeight(clampedHeight); - }, - [], - ); - - const handleResizePointerEnd = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - resizeStateRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - if (!didResizeDuringDragRef.current) { - return; - } - syncHeight(drawerHeightRef.current); - fitAndResizeTerminal(true); - }, - [fitAndResizeTerminal, syncHeight], - ); - - useEffect(() => { - const onWindowResize = () => { - const clampedHeight = clampDrawerHeight(drawerHeightRef.current); - const changed = clampedHeight !== drawerHeightRef.current; - if (changed) { - setDrawerHeight(clampedHeight); - } else { - fitAndResizeTerminal(true); - } - if (!resizeStateRef.current) { - syncHeight(clampedHeight); - } - }; - window.addEventListener("resize", onWindowResize); - return () => { - window.removeEventListener("resize", onWindowResize); - }; - }, [fitAndResizeTerminal, syncHeight]); - - useEffect(() => { - return () => { - syncHeight(drawerHeightRef.current); - }; - }, [syncHeight]); useEffect(() => { const mount = containerRef.current; @@ -287,7 +161,7 @@ export default function ThreadTerminalDrawer({ const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { - await api.terminal.write({ threadId, data: "\u000c" }); + await api.terminal.write({ threadId, terminalId, data: "\u000c" }); } catch (error) { writeSystemMessage( activeTerminal, @@ -367,7 +241,7 @@ export default function ThreadTerminalDrawer({ const inputDisposable = terminal.onData((data) => { void api.terminal - .write({ threadId, data }) + .write({ threadId, terminalId, data }) .catch((err) => writeSystemMessage( terminal, @@ -395,6 +269,7 @@ export default function ThreadTerminalDrawer({ activeFitAddon.fit(); const snapshot = await api.terminal.open({ threadId, + terminalId, cwd, cols: activeTerminal.cols, rows: activeTerminal.rows, @@ -404,9 +279,11 @@ export default function ThreadTerminalDrawer({ if (snapshot.history.length > 0) { activeTerminal.write(snapshot.history); } - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); + if (autoFocus) { + window.requestAnimationFrame(() => { + activeTerminal.focus(); + }); + } } catch (err) { if (disposed) return; writeSystemMessage( @@ -417,7 +294,7 @@ export default function ThreadTerminalDrawer({ }; const unsubscribe = api.terminal.onEvent((event) => { - if (event.threadId !== threadId) return; + if (event.threadId !== threadId || event.terminalId !== terminalId) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -446,12 +323,37 @@ export default function ThreadTerminalDrawer({ } if (event.type === "exited") { - onThreadExitedRef.current(); + const details = [ + typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, + typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, + ] + .filter((value): value is string => value !== null) + .join(", "); + writeSystemMessage( + activeTerminal, + details.length > 0 ? `Process exited (${details})` : "Process exited", + ); } }); const fitTimer = window.setTimeout(() => { - fitAndResizeTerminal(true); + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + const wasAtBottom = + activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; + activeFitAddon.fit(); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + }) + .catch(() => undefined); }, 30); void openTerminal(); @@ -466,9 +368,10 @@ export default function ThreadTerminalDrawer({ fitAddonRef.current = null; terminal.dispose(); }; - }, [api, cwd, fitAndResizeTerminal, threadId]); + }, [api, cwd, terminalId, threadId]); useEffect(() => { + if (!autoFocus) return; const terminal = terminalRef.current; if (!terminal) return; const frame = window.requestAnimationFrame(() => { @@ -477,7 +380,7 @@ export default function ThreadTerminalDrawer({ return () => { window.cancelAnimationFrame(frame); }; - }, [focusRequestId]); + }, [autoFocus, focusRequestId]); useEffect(() => { const terminal = terminalRef.current; @@ -492,6 +395,7 @@ export default function ThreadTerminalDrawer({ void api.terminal .resize({ threadId, + terminalId, cols: terminal.cols, rows: terminal.rows, }) @@ -500,27 +404,460 @@ export default function ThreadTerminalDrawer({ return () => { window.cancelAnimationFrame(frame); }; - }, [api, cwd, drawerHeight, threadId]); + }, [api, drawerHeight, resizeEpoch, terminalId, threadId]); + + return
; +} + +interface ThreadTerminalDrawerProps { + api: NativeApi; + threadId: string; + cwd: string; + height: number; + terminalIds: string[]; + activeTerminalId: string; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; + focusRequestId: number; + onSplitTerminal: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + onHeightChange: (height: number) => void; +} + +export default function ThreadTerminalDrawer({ + api, + threadId, + cwd, + height, + terminalIds, + activeTerminalId, + terminalGroups, + activeTerminalGroupId, + focusRequestId, + onSplitTerminal, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + onHeightChange, +}: ThreadTerminalDrawerProps) { + const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + const [resizeEpoch, setResizeEpoch] = useState(0); + const drawerHeightRef = useRef(drawerHeight); + const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); + const onHeightChangeRef = useRef(onHeightChange); + const resizeStateRef = useRef<{ + pointerId: number; + startY: number; + startHeight: number; + } | null>(null); + const didResizeDuringDragRef = useRef(false); + + const normalizedTerminalIds = useMemo(() => { + const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; + return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID]; + }, [terminalIds]); + + const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId) + ? activeTerminalId + : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + + const resolvedTerminalGroups = useMemo(() => { + const validTerminalIdSet = new Set(normalizedTerminalIds); + const assignedTerminalIds = new Set(); + const usedGroupIds = new Set(); + const nextGroups: ThreadTerminalGroup[] = []; + + const assignUniqueGroupId = (groupId: string): string => { + if (!usedGroupIds.has(groupId)) { + usedGroupIds.add(groupId); + return groupId; + } + let suffix = 2; + while (usedGroupIds.has(`${groupId}-${suffix}`)) { + suffix += 1; + } + const uniqueGroupId = `${groupId}-${suffix}`; + usedGroupIds.add(uniqueGroupId); + return uniqueGroupId; + }; + + for (const terminalGroup of terminalGroups) { + const nextTerminalIds = [ + ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), + ].filter((terminalId) => { + if (!validTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (nextTerminalIds.length === 0) continue; + + for (const terminalId of nextTerminalIds) { + assignedTerminalIds.add(terminalId); + } + + const baseGroupId = + terminalGroup.id.trim().length > 0 + ? terminalGroup.id.trim() + : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; + nextGroups.push({ + id: assignUniqueGroupId(baseGroupId), + terminalIds: nextTerminalIds, + }); + } + + for (const terminalId of normalizedTerminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + nextGroups.push({ + id: assignUniqueGroupId(`group-${terminalId}`), + terminalIds: [terminalId], + }); + } + + if (nextGroups.length > 0) { + return nextGroups; + } + + return [ + { + id: `group-${resolvedActiveTerminalId}`, + terminalIds: [resolvedActiveTerminalId], + }, + ]; + }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]); + + const resolvedActiveGroupIndex = useMemo(() => { + const indexById = resolvedTerminalGroups.findIndex( + (terminalGroup) => terminalGroup.id === activeTerminalGroupId, + ); + if (indexById >= 0) return indexById; + const indexByTerminal = resolvedTerminalGroups.findIndex((terminalGroup) => + terminalGroup.terminalIds.includes(resolvedActiveTerminalId), + ); + return indexByTerminal >= 0 ? indexByTerminal : 0; + }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); + + const visibleTerminalIds = + resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [resolvedActiveTerminalId]; + const hasTerminalSidebar = normalizedTerminalIds.length > 1; + const isSplitView = visibleTerminalIds.length > 1; + const showGroupHeaders = + resolvedTerminalGroups.length > 1 || + resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); + const terminalLabelById = useMemo( + () => + new Map( + normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]), + ), + [normalizedTerminalIds], + ); + + useEffect(() => { + onHeightChangeRef.current = onHeightChange; + }, [onHeightChange]); + + useEffect(() => { + drawerHeightRef.current = drawerHeight; + }, [drawerHeight]); + + const syncHeight = useCallback((nextHeight: number) => { + const clampedHeight = clampDrawerHeight(nextHeight); + if (lastSyncedHeightRef.current === clampedHeight) return; + lastSyncedHeightRef.current = clampedHeight; + onHeightChangeRef.current(clampedHeight); + }, []); + + useEffect(() => { + const clampedHeight = clampDrawerHeight(height); + setDrawerHeight(clampedHeight); + drawerHeightRef.current = clampedHeight; + lastSyncedHeightRef.current = clampedHeight; + }, [height, threadId]); + + const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + didResizeDuringDragRef.current = false; + resizeStateRef.current = { + pointerId: event.pointerId, + startY: event.clientY, + startHeight: drawerHeightRef.current, + }; + }, []); + + const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + const clampedHeight = clampDrawerHeight( + resizeState.startHeight + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + setDrawerHeight(clampedHeight); + }, []); + + const handleResizePointerEnd = useCallback( + (event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + resizeStateRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (!didResizeDuringDragRef.current) { + return; + } + syncHeight(drawerHeightRef.current); + setResizeEpoch((value) => value + 1); + }, + [syncHeight], + ); + + useEffect(() => { + const onWindowResize = () => { + const clampedHeight = clampDrawerHeight(drawerHeightRef.current); + const changed = clampedHeight !== drawerHeightRef.current; + if (changed) { + setDrawerHeight(clampedHeight); + drawerHeightRef.current = clampedHeight; + } + if (!resizeStateRef.current) { + syncHeight(clampedHeight); + } + setResizeEpoch((value) => value + 1); + }; + window.addEventListener("resize", onWindowResize); + return () => { + window.removeEventListener("resize", onWindowResize); + }; + }, [syncHeight]); + + useEffect(() => { + return () => { + syncHeight(drawerHeightRef.current); + }; + }, [syncHeight]); return ( ); diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts index 0081ef90b818..b05fbf352ae8 100644 --- a/apps/web/src/persistenceSchema.test.ts +++ b/apps/web/src/persistenceSchema.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MODEL } from "./model-logic"; import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; import type { Thread } from "./types"; describe("hydratePersistedState", () => { @@ -50,6 +50,14 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); expect(hydrated?.threads[0]?.terminalOpen).toBe(false); expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -92,6 +100,14 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); expect(hydrated?.threads[0]?.terminalOpen).toBe(false); expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); expect(hydrated?.activeThreadId).toBe("t-1"); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -117,7 +133,7 @@ describe("hydratePersistedState", () => { expect(hydrated?.runtimeMode).toBe("approval-required"); }); - it("hydrates terminal fields from v6 payload", () => { + it("hydrates terminal fields from legacy v6 payload", () => { const payload = JSON.stringify({ version: 6, runtimeMode: "full-access", @@ -139,6 +155,10 @@ describe("hydratePersistedState", () => { model: "gpt-5.3-codex", terminalOpen: true, terminalHeight: 360, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalLayout: "tabs", + splitTerminalIds: [], messages: [], createdAt: "2026-02-08T10:00:00.000Z", lastVisitedAt: "2026-02-08T10:01:00.000Z", @@ -150,9 +170,62 @@ describe("hydratePersistedState", () => { const hydrated = hydratePersistedState(payload, false); expect(hydrated?.threads[0]?.terminalOpen).toBe(true); expect(hydrated?.threads[0]?.terminalHeight).toBe(360); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); }); + it("hydrates legacy split layout into a grouped split", () => { + const payload = JSON.stringify({ + version: 6, + runtimeMode: "full-access", + projects: [ + { + id: "p-1", + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [ + { + id: "t-1", + codexThreadId: null, + projectId: "p-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: true, + terminalHeight: 360, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], + activeTerminalId: "term-2", + terminalLayout: "split", + splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + messages: [], + createdAt: "2026-02-08T10:00:00.000Z", + }, + ], + activeThreadId: "t-1", + }); + + const hydrated = hydratePersistedState(payload, false); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); + }); + it("defaults terminalHeight when hydrating v5 payloads", () => { const payload = JSON.stringify({ version: 5, @@ -183,11 +256,19 @@ describe("hydratePersistedState", () => { const hydrated = hydratePersistedState(payload, false); expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); }); }); describe("toPersistedState", () => { - it("writes v6 payload and strips non-persisted thread fields", () => { + it("writes v7 payload and strips non-persisted thread fields", () => { const thread: Thread = { id: "t-1", codexThreadId: "thr_1", @@ -196,6 +277,13 @@ describe("toPersistedState", () => { model: "gpt-5.3-codex", terminalOpen: true, terminalHeight: 320, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalGroups: [ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ], + activeTerminalGroupId: "group-term-2", session: null, messages: [ { @@ -239,7 +327,7 @@ describe("toPersistedState", () => { runtimeMode: "full-access", }); - expect(persisted.version).toBe(6); + expect(persisted.version).toBe(7); expect(persisted.runtimeMode).toBe("full-access"); expect(persisted.threads[0]).toEqual({ id: "t-1", @@ -249,6 +337,13 @@ describe("toPersistedState", () => { model: "gpt-5.3-codex", terminalOpen: true, terminalHeight: 320, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalGroups: [ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ], + activeTerminalGroupId: "group-term-2", messages: [ { id: "m-1", diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index 112d2e3c61a1..5738107c3ca7 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -3,10 +3,12 @@ import { z } from "zod"; import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread, + type ThreadTerminalGroup, } from "./types"; const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; @@ -38,6 +40,11 @@ const persistedMessageSchema = z.object({ streaming: z.boolean(), }); +const persistedTerminalGroupSchema = z.object({ + id: z.string().trim().min(1), + terminalIds: z.array(z.string().trim().min(1)), +}); + const persistedThreadSchema = z.object({ id: z.string().min(1), codexThreadId: z.string().min(1).nullable().default(null), @@ -48,6 +55,13 @@ const persistedThreadSchema = z.object({ terminalHeight: z.number().int().min(120).max(4_096).default( DEFAULT_THREAD_TERMINAL_HEIGHT, ), + terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), + activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), + terminalGroups: z.array(persistedTerminalGroupSchema).default([]), + activeTerminalGroupId: z.string().trim().min(1).optional(), + // Legacy v6 and older fields retained for migration. + terminalLayout: z.enum(["single", "split", "tabs"]).optional(), + splitTerminalIds: z.array(z.string().trim().min(1)).optional(), messages: z.array(persistedMessageSchema), createdAt: z.string().min(1), lastVisitedAt: z.string().min(1).optional(), @@ -86,12 +100,18 @@ export const persistedStateV6Schema = persistedStateBodySchema.extend({ version: z.literal(6).optional(), }); +export const persistedStateV7Schema = persistedStateBodySchema.extend({ + runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), + version: z.literal(7).optional(), +}); + export const persistedStateV5Schema = persistedStateBodySchema.extend({ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), version: z.literal(5).optional(), }); const persistedStateSchema = z.union([ + persistedStateV7Schema, persistedStateV6Schema, persistedStateV5Schema, persistedStateV4Schema, @@ -129,6 +149,91 @@ function hydrateThread( thread: z.infer, isLegacyPayload: boolean, ): Thread { + const terminalIds = [...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; + const safeTerminalIds = + terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; + const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) + ? thread.activeTerminalId + : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + const safeTerminalIdSet = new Set(safeTerminalIds); + const assignedTerminalIds = new Set(); + const usedGroupIds = new Set(); + const normalizedGroups: ThreadTerminalGroup[] = []; + const assignUniqueGroupId = (groupId: string): string => { + if (!usedGroupIds.has(groupId)) { + usedGroupIds.add(groupId); + return groupId; + } + let suffix = 2; + while (usedGroupIds.has(`${groupId}-${suffix}`)) { + suffix += 1; + } + const uniqueGroupId = `${groupId}-${suffix}`; + usedGroupIds.add(uniqueGroupId); + return uniqueGroupId; + }; + + for (const terminalGroup of thread.terminalGroups) { + const nextTerminalIds = [ + ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), + ].filter((terminalId) => { + if (!safeTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (nextTerminalIds.length === 0) continue; + for (const terminalId of nextTerminalIds) { + assignedTerminalIds.add(terminalId); + } + const baseGroupId = + terminalGroup.id.trim().length > 0 + ? terminalGroup.id.trim() + : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; + normalizedGroups.push({ + id: assignUniqueGroupId(baseGroupId), + terminalIds: nextTerminalIds, + }); + } + + if (normalizedGroups.length === 0 && thread.terminalLayout === "split") { + const splitTerminalIds = [ + ...new Set((thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), + ].filter((terminalId) => safeTerminalIdSet.has(terminalId)); + if (splitTerminalIds.length >= 2) { + const splitGroupTerminalIds = splitTerminalIds.slice(0, 2); + for (const terminalId of splitGroupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + normalizedGroups.push({ + id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`), + terminalIds: splitGroupTerminalIds, + }); + } + } + + for (const terminalId of safeTerminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + normalizedGroups.push({ + id: assignUniqueGroupId(`group-${terminalId}`), + terminalIds: [terminalId], + }); + } + + const activeGroupIndexFromId = normalizedGroups.findIndex( + (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId, + ); + const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) => + terminalGroup.terminalIds.includes(activeTerminalId), + ); + const activeGroupIndex = + activeGroupIndexFromId >= 0 + ? activeGroupIndexFromId + : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0); + const activeTerminalGroupId = + normalizedGroups[activeGroupIndex]?.id ?? + normalizedGroups[0]?.id ?? + `group-${DEFAULT_THREAD_TERMINAL_ID}`; + return { id: thread.id, codexThreadId: thread.codexThreadId, @@ -137,6 +242,10 @@ function hydrateThread( model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), terminalOpen: thread.terminalOpen ?? false, terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: safeTerminalIds, + activeTerminalId, + terminalGroups: normalizedGroups, + activeTerminalGroupId, session: null, messages: thread.messages.map((message) => { const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); @@ -199,9 +308,9 @@ export function hydratePersistedState( export function toPersistedState( state: PersistedStoreSnapshot, -): z.infer { +): z.infer { return { - version: 6, + version: 7, projects: state.projects, threads: state.threads.map((thread) => ({ id: thread.id, @@ -211,6 +320,10 @@ export function toPersistedState( model: thread.model, terminalOpen: thread.terminalOpen, terminalHeight: thread.terminalHeight, + terminalIds: thread.terminalIds, + activeTerminalId: thread.activeTerminalId, + terminalGroups: thread.terminalGroups, + activeTerminalGroupId: thread.activeTerminalGroupId, messages: thread.messages.map((message) => ({ id: message.id, role: message.role, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index c33f1c077a76..4a5dd0e099e0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -2,7 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { type AppState, reducer } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; import type { Thread } from "./types"; function makeSession(overrides: Partial = {}): ProviderSession { @@ -37,6 +37,15 @@ function makeThread(overrides: Partial = {}): Thread { model: "gpt-5.3-codex", terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: makeSession(), messages: [], events: [], @@ -111,6 +120,160 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.terminalHeight).toBe(360); }); + it("splits the active terminal into side-by-side mode", () => { + const state = makeState(makeThread()); + const next = reducer(state, { + type: "SPLIT_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); + expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + ]); + expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + + it("creates a new full-width terminal and switches to tab mode", () => { + const state = makeState(makeThread()); + const next = reducer(state, { + type: "NEW_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); + expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ]); + expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); + }); + + it("switches the active terminal and restores its owning group", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ], + activeTerminalGroupId: "group-term-3", + }), + ); + const next = reducer(state, { + type: "SET_THREAD_ACTIVE_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + + it("supports splitting beyond two terminals in the same group", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + }), + ); + const next = reducer(state, { + type: "SPLIT_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-3", + }); + + expect(next.threads[0]?.terminalIds).toEqual([ + DEFAULT_THREAD_TERMINAL_ID, + "term-2", + "term-3", + ]); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], + }, + ]); + expect(next.threads[0]?.activeTerminalId).toBe("term-3"); + expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + + it("closes a terminal and keeps grouped layout coherent", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], + activeTerminalId: "term-2", + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + }), + ); + const next = reducer(state, { + type: "CLOSE_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]); + expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ]); + }); + + it("closes the final terminal and hides the drawer", () => { + const state = makeState( + makeThread({ + terminalOpen: true, + }), + ); + const next = reducer(state, { + type: "CLOSE_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: DEFAULT_THREAD_TERMINAL_ID, + }); + + expect(next.threads[0]?.terminalOpen).toBe(false); + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ]); + }); + it("backfills codexThreadId from routed provider events", () => { const state = makeState(makeThread({ codexThreadId: null })); const next = reducer(state, { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c8b457bb1c68..3527d46b0fc1 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -14,10 +14,12 @@ import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; import { type ChatAttachment, + DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread, + type ThreadTerminalGroup, } from "./types"; // ── Actions ────────────────────────────────────────────────────────── @@ -31,6 +33,10 @@ type Action = | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } + | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } + | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } + | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } + | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "TOGGLE_DIFF" } | { type: "APPLY_EVENT"; @@ -67,8 +73,9 @@ export interface AppState { diffOpen: boolean; } -const PERSISTED_STATE_KEY = "t3code:renderer-state:v6"; +const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; const LEGACY_PERSISTED_STATE_KEYS = [ + "t3code:renderer-state:v6", "t3code:renderer-state:v5", "t3code:renderer-state:v4", "t3code:renderer-state:v3", @@ -133,6 +140,180 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } +function normalizeTerminalIds(terminalIds: string[]): string[] { + const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); + const unique = [...new Set(ids)]; + if (unique.length > 0) { + return unique; + } + return [DEFAULT_THREAD_TERMINAL_ID]; +} + +function normalizeTerminalGroupIds(terminalIds: string[]): string[] { + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; +} + +function fallbackGroupId(terminalId: string): string { + return `group-${terminalId}`; +} + +function assignUniqueGroupId(groupId: string, usedGroupIds: Set): string { + if (!usedGroupIds.has(groupId)) { + usedGroupIds.add(groupId); + return groupId; + } + let suffix = 2; + while (usedGroupIds.has(`${groupId}-${suffix}`)) { + suffix += 1; + } + const uniqueGroupId = `${groupId}-${suffix}`; + usedGroupIds.add(uniqueGroupId); + return uniqueGroupId; +} + +function normalizeTerminalGroups( + thread: Thread, + terminalIds: string[], +): ThreadTerminalGroup[] { + const validTerminalIdSet = new Set(terminalIds); + const assignedTerminalIds = new Set(); + const usedGroupIds = new Set(); + const groups: ThreadTerminalGroup[] = []; + + for (const group of thread.terminalGroups) { + const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { + if (!validTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (groupTerminalIds.length === 0) continue; + for (const terminalId of groupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + const baseGroupId = + group.id.trim().length > 0 + ? group.id.trim() + : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + groups.push({ + id: assignUniqueGroupId(baseGroupId, usedGroupIds), + terminalIds: groupTerminalIds, + }); + } + + for (const terminalId of terminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + groups.push({ + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [terminalId], + }); + } + + if (groups.length > 0) { + return groups; + } + + return [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ]; +} + +function findGroupIndexByTerminalId( + terminalGroups: ThreadTerminalGroup[], + terminalId: string, +): number { + return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); +} + +function normalizeThreadTerminals(thread: Thread): Thread { + const terminalIds = normalizeTerminalIds(thread.terminalIds); + const activeTerminalId = terminalIds.includes(thread.activeTerminalId) + ? thread.activeTerminalId + : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + const terminalGroups = normalizeTerminalGroups(thread, terminalIds); + const activeGroupIndexFromId = terminalGroups.findIndex( + (group) => group.id === thread.activeTerminalGroupId, + ); + const activeGroupIndexFromTerminal = findGroupIndexByTerminalId( + terminalGroups, + activeTerminalId, + ); + const activeGroupIndex = + activeGroupIndexFromId >= 0 + ? activeGroupIndexFromId + : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0); + const activeTerminalGroupId = + terminalGroups[activeGroupIndex]?.id ?? + terminalGroups[0]?.id ?? + fallbackGroupId(activeTerminalId); + + return { + ...thread, + terminalIds, + activeTerminalId, + terminalGroups, + activeTerminalGroupId, + }; +} + +function closeThreadTerminal(thread: Thread, terminalId: string): Thread { + if (!thread.terminalIds.includes(terminalId)) { + return thread; + } + + const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId); + if (remainingTerminalIds.length === 0) { + return normalizeThreadTerminals({ + ...thread, + terminalOpen: false, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + }); + } + + const closedTerminalIndex = thread.terminalIds.indexOf(terminalId); + const closedTerminalGroup = thread.terminalGroups.find((group) => + group.terminalIds.includes(terminalId), + ); + const closedTerminalGroupIndex = closedTerminalGroup + ? closedTerminalGroup.terminalIds.indexOf(terminalId) + : -1; + const remainingTerminalsInClosedGroup = ( + closedTerminalGroup?.terminalIds ?? [] + ).filter((id) => id !== terminalId); + const nextActiveTerminalId = + thread.activeTerminalId === terminalId + ? (remainingTerminalsInClosedGroup[ + Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1) + ] ?? + remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + remainingTerminalIds[0] ?? + DEFAULT_THREAD_TERMINAL_ID) + : thread.activeTerminalId; + const nextTerminalGroups = thread.terminalGroups + .map((group) => ({ + ...group, + terminalIds: group.terminalIds.filter((id) => id !== terminalId), + })) + .filter((group) => group.terminalIds.length > 0); + + return normalizeThreadTerminals({ + ...thread, + terminalIds: remainingTerminalIds, + activeTerminalId: nextActiveTerminalId, + terminalGroups: nextTerminalGroups, + }); +} + function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { return threads.find((t) => t.session?.sessionId === sessionId); } @@ -259,10 +440,10 @@ export function reducer(state: AppState, action: Action): AppState { if (!previousProject) return null; const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); if (!mappedProjectId) return null; - return { + return normalizeThreadTerminals({ ...thread, projectId: mappedProjectId, - }; + }); }) .filter((thread): thread is Thread => thread !== null); const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) @@ -285,19 +466,18 @@ export function reducer(state: AppState, action: Action): AppState { ), }; - case "ADD_THREAD": + case "ADD_THREAD": { + const nextThread = normalizeThreadTerminals({ + ...action.thread, + model: resolveModelSlug(action.thread.model), + lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, + }); return { ...state, - threads: [ - ...state.threads, - { - ...action.thread, - model: resolveModelSlug(action.thread.model), - lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, - }, - ], + threads: [...state.threads, nextThread], activeThreadId: action.thread.id, }; + } case "SET_ACTIVE_THREAD": { const visitedAt = new Date().toISOString(); @@ -338,6 +518,135 @@ export function reducer(state: AppState, action: Action): AppState { })), }; + case "SPLIT_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + const normalizedThread = normalizeThreadTerminals(thread); + const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) + ? normalizedThread.terminalIds + : [...normalizedThread.terminalIds, action.terminalId]; + const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ + ...group, + terminalIds: [...group.terminalIds], + })); + let activeGroupIndex = terminalGroups.findIndex( + (group) => group.id === normalizedThread.activeTerminalGroupId, + ); + if (activeGroupIndex < 0) { + activeGroupIndex = findGroupIndexByTerminalId( + terminalGroups, + normalizedThread.activeTerminalId, + ); + } + if (activeGroupIndex < 0) { + terminalGroups.push({ + id: fallbackGroupId(normalizedThread.activeTerminalId), + terminalIds: [normalizedThread.activeTerminalId], + }); + activeGroupIndex = terminalGroups.length - 1; + } + + const existingGroupIndex = findGroupIndexByTerminalId( + terminalGroups, + action.terminalId, + ); + if (existingGroupIndex >= 0) { + terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ + existingGroupIndex + ]!.terminalIds.filter((id) => id !== action.terminalId); + if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { + terminalGroups.splice(existingGroupIndex, 1); + if (existingGroupIndex < activeGroupIndex) { + activeGroupIndex -= 1; + } + } + } + + const destinationGroup = terminalGroups[activeGroupIndex]; + if (!destinationGroup) { + return normalizedThread; + } + if (!destinationGroup.terminalIds.includes(action.terminalId)) { + const anchorIndex = destinationGroup.terminalIds.indexOf( + normalizedThread.activeTerminalId, + ); + if (anchorIndex >= 0) { + destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); + } else { + destinationGroup.terminalIds.push(action.terminalId); + } + } + return normalizeThreadTerminals({ + ...normalizedThread, + terminalIds, + activeTerminalId: action.terminalId, + activeTerminalGroupId: destinationGroup.id, + terminalGroups, + }); + }), + }; + + case "NEW_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + const normalizedThread = normalizeThreadTerminals(thread); + const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) + ? normalizedThread.terminalIds + : [...normalizedThread.terminalIds, action.terminalId]; + const terminalGroups = normalizedThread.terminalGroups + .map((group) => ({ + ...group, + terminalIds: group.terminalIds.filter((id) => id !== action.terminalId), + })) + .filter((group) => group.terminalIds.length > 0); + const nextGroupId = fallbackGroupId(action.terminalId); + terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); + + return normalizeThreadTerminals({ + ...normalizedThread, + terminalIds, + activeTerminalId: action.terminalId, + activeTerminalGroupId: nextGroupId, + terminalGroups, + }); + }), + }; + + case "SET_THREAD_ACTIVE_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + const normalizedThread = normalizeThreadTerminals(thread); + if (!normalizedThread.terminalIds.includes(action.terminalId)) { + return thread; + } + const nextActiveGroupIndex = findGroupIndexByTerminalId( + normalizedThread.terminalGroups, + action.terminalId, + ); + const activeTerminalGroupId = + nextActiveGroupIndex >= 0 + ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ?? + normalizedThread.activeTerminalGroupId) + : normalizedThread.activeTerminalGroupId; + return normalizeThreadTerminals({ + ...normalizedThread, + activeTerminalId: action.terminalId, + activeTerminalGroupId, + }); + }), + }; + + case "CLOSE_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => + closeThreadTerminal(thread, action.terminalId), + ), + }; + case "TOGGLE_DIFF": return { ...state, diffOpen: !state.diffOpen }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 16a51fc87e41..56343f128e9b 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -4,6 +4,12 @@ export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export type RuntimeMode = "approval-required" | "full-access"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; +export const DEFAULT_THREAD_TERMINAL_ID = "default"; + +export interface ThreadTerminalGroup { + id: string; + terminalIds: string[]; +} export interface ChatImageAttachment { type: "image"; @@ -41,6 +47,10 @@ export interface Thread { model: string; terminalOpen: boolean; terminalHeight: number; + terminalIds: string[]; + activeTerminalId: string; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; session: ProviderSession | null; messages: ChatMessage[]; events: ProviderEvent[]; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 53573d5ff32d..a3f67ced9aee 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -30,12 +30,12 @@ import type { ProjectRemoveInput, } from "./project"; import type { + TerminalClearInput, TerminalCloseInput, TerminalEvent, TerminalOpenInput, TerminalResizeInput, TerminalSessionSnapshot, - TerminalThreadInput, TerminalWriteInput, } from "./terminal"; import type { NewTodoInput, Todo } from "./todo"; @@ -61,7 +61,7 @@ export interface NativeApi { open: (input: TerminalOpenInput) => Promise; write: (input: TerminalWriteInput) => Promise; resize: (input: TerminalResizeInput) => Promise; - clear: (input: TerminalThreadInput) => Promise; + clear: (input: TerminalClearInput) => Promise; restart: (input: TerminalOpenInput) => Promise; close: (input: TerminalCloseInput) => Promise; onEvent: (callback: (event: TerminalEvent) => void) => () => void; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 5f340a10074c..336a0476265b 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_TERMINAL_ID, + terminalClearInputSchema, terminalCloseInputSchema, terminalEventSchema, terminalOpenInputSchema, @@ -30,6 +32,16 @@ describe("terminalOpenInputSchema", () => { }); expect(result.success).toBe(false); }); + + it("defaults terminalId when missing", () => { + const parsed = terminalOpenInputSchema.parse({ + threadId: "thread-1", + cwd: "/tmp/project", + cols: 100, + rows: 24, + }); + expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); + }); }); describe("terminalWriteInputSchema", () => { @@ -68,6 +80,15 @@ describe("terminalResizeInputSchema", () => { }); }); +describe("terminalClearInputSchema", () => { + it("defaults terminal id", () => { + const parsed = terminalClearInputSchema.parse({ + threadId: "thread-1", + }); + expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); + }); +}); + describe("terminalCloseInputSchema", () => { it("accepts optional deleteHistory", () => { const result = terminalCloseInputSchema.safeParse({ @@ -82,6 +103,7 @@ describe("terminalSessionSnapshotSchema", () => { it("accepts running snapshots", () => { const result = terminalSessionSnapshotSchema.safeParse({ threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, cwd: "/tmp/project", status: "running", pid: 1234, @@ -99,6 +121,7 @@ describe("terminalEventSchema", () => { const result = terminalEventSchema.safeParse({ type: "output", threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", }); @@ -109,6 +132,7 @@ describe("terminalEventSchema", () => { const result = terminalEventSchema.safeParse({ type: "exited", threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), exitCode: 0, exitSignal: null, diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index ed79937ab915..cec35f5956f2 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,28 +1,38 @@ import { z } from "zod"; +export const DEFAULT_TERMINAL_ID = "default"; + const terminalColsSchema = z.number().int().min(20).max(400); const terminalRowsSchema = z.number().int().min(5).max(200); +const terminalIdSchema = z.string().trim().min(1).max(128); export const terminalThreadInputSchema = z.object({ threadId: z.string().trim().min(1), }); -export const terminalOpenInputSchema = terminalThreadInputSchema.extend({ +export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ + terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID), +}); + +export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ cwd: z.string().trim().min(1), cols: terminalColsSchema, rows: terminalRowsSchema, }); -export const terminalWriteInputSchema = terminalThreadInputSchema.extend({ +export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ data: z.string().min(1).max(65_536), }); -export const terminalResizeInputSchema = terminalThreadInputSchema.extend({ +export const terminalResizeInputSchema = terminalSessionInputSchema.extend({ cols: terminalColsSchema, rows: terminalRowsSchema, }); +export const terminalClearInputSchema = terminalSessionInputSchema; + export const terminalCloseInputSchema = terminalThreadInputSchema.extend({ + terminalId: terminalIdSchema.optional(), deleteHistory: z.boolean().optional(), }); @@ -35,6 +45,7 @@ export const terminalSessionStatusSchema = z.enum([ export const terminalSessionSnapshotSchema = z.object({ threadId: z.string().min(1), + terminalId: z.string().min(1), cwd: z.string().min(1), status: terminalSessionStatusSchema, pid: z.number().int().positive().nullable(), @@ -46,6 +57,7 @@ export const terminalSessionSnapshotSchema = z.object({ const terminalEventBaseSchema = z.object({ threadId: z.string().min(1), + terminalId: z.string().min(1), createdAt: z.string().datetime(), }); @@ -89,9 +101,11 @@ export const terminalEventSchema = z.discriminatedUnion("type", [ ]); export type TerminalThreadInput = z.input; +export type TerminalSessionInput = z.input; export type TerminalOpenInput = z.input; export type TerminalWriteInput = z.input; export type TerminalResizeInput = z.input; +export type TerminalClearInput = z.input; export type TerminalCloseInput = z.input; export type TerminalSessionStatus = z.infer; export type TerminalSessionSnapshot = z.infer;