authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 20:13:10-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 21:24:49-08:00
log800eb3b547aa5e073248f1db9272efe556df6928
tree622219eb2ba6bced6903e3c65e480aa772d2b352
parent854068dc565bdbe8b9e7c6183eb4914674816fc3

Add per-thread multi-terminal sessions and UI state

- Support multiple terminal IDs per thread across contracts, server manager, and WS handling - Persist terminal history per terminal ID and allow closing all terminals for a thread - Update web thread state and terminal drawer to create, split, and switch active terminals - Expand tests for multi-terminal behavior and terminal ID propagation

15 files changed, 948 insertions(+), 280 deletions(-)

apps/server/src/terminalManager.test.ts+76-4
...@@ -2,10 +2,7 @@ import fs from "node:fs";...@@ -2,10 +2,7 @@ import fs from "node:fs";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import type {5import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput } from "@t3tools/contracts";
6 TerminalEvent,
7 TerminalOpenInput,
8} from "@t3tools/contracts";
9import { afterEach, describe, expect, it } from "vitest";6import { afterEach, describe, expect, it } from "vitest";
107
11import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";8import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
...@@ -109,10 +106,26 @@ function historyLogName(threadId: string): string {...@@ -109,10 +106,26 @@ function historyLogName(threadId: string): string {
109 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;106 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;
110}107}
111108
109function multiTerminalHistoryLogName(threadId: string, terminalId: string): string {
110 const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
111 if (terminalId === DEFAULT_TERMINAL_ID) {
112 return `${threadPart}.log`;
113 }
114 return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`;
115}
116
112function historyLogPath(logsDir: string, threadId = "thread-1"): string {117function historyLogPath(logsDir: string, threadId = "thread-1"): string {
113 return path.join(logsDir, historyLogName(threadId));118 return path.join(logsDir, historyLogName(threadId));
114}119}
115120
121function multiTerminalHistoryLogPath(
122 logsDir: string,
123 threadId = "thread-1",
124 terminalId = "default",
125): string {
126 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
127}
128
116describe("TerminalManager", () => {129describe("TerminalManager", () => {
117 const tempDirs: string[] = [];130 const tempDirs: string[] = [];
118131
...@@ -144,6 +157,7 @@ describe("TerminalManager", () => {...@@ -144,6 +157,7 @@ describe("TerminalManager", () => {
144 const third = await manager.open(openInput());157 const third = await manager.open(openInput());
145158
146 expect(first.threadId).toBe("thread-1");159 expect(first.threadId).toBe("thread-1");
160 expect(first.terminalId).toBe("default");
147 expect(second.threadId).toBe("thread-1");161 expect(second.threadId).toBe("thread-1");
148 expect(third.threadId).toBe("thread-1");162 expect(third.threadId).toBe("thread-1");
149 expect(ptyAdapter.spawnInputs).toHaveLength(1);163 expect(ptyAdapter.spawnInputs).toHaveLength(1);
...@@ -167,6 +181,27 @@ describe("TerminalManager", () => {...@@ -167,6 +181,27 @@ describe("TerminalManager", () => {
167 manager.dispose();181 manager.dispose();
168 });182 });
169183
184 it("supports multiple terminals per thread with isolated sessions", async () => {
185 const { manager, ptyAdapter } = makeManager();
186 await manager.open(openInput({ terminalId: "default" }));
187 await manager.open(openInput({ terminalId: "term-2" }));
188
189 const first = ptyAdapter.processes[0];
190 const second = ptyAdapter.processes[1];
191 expect(first).toBeDefined();
192 expect(second).toBeDefined();
193 if (!first || !second) return;
194
195 await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" });
196 await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" });
197
198 expect(first.writes).toEqual(["pwd\n"]);
199 expect(second.writes).toEqual(["ls\n"]);
200 expect(ptyAdapter.spawnInputs).toHaveLength(2);
201
202 manager.dispose();
203 });
204
170 it("clears transcript and emits cleared event", async () => {205 it("clears transcript and emits cleared event", async () => {
171 const { manager, ptyAdapter, logsDir } = makeManager();206 const { manager, ptyAdapter, logsDir } = makeManager();
172 const events: TerminalEvent[] = [];207 const events: TerminalEvent[] = [];
...@@ -184,6 +219,14 @@ describe("TerminalManager", () => {...@@ -184,6 +219,14 @@ describe("TerminalManager", () => {
184 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");219 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
185220
186 expect(events.some((event) => event.type === "cleared")).toBe(true);221 expect(events.some((event) => event.type === "cleared")).toBe(true);
222 expect(
223 events.some(
224 (event) =>
225 event.type === "cleared" &&
226 event.threadId === "thread-1" &&
227 event.terminalId === "default",
228 ),
229 ).toBe(true);
187230
188 manager.dispose();231 manager.dispose();
189 });232 });
...@@ -262,6 +305,35 @@ describe("TerminalManager", () => {...@@ -262,6 +305,35 @@ describe("TerminalManager", () => {
262 manager.dispose();305 manager.dispose();
263 });306 });
264307
308 it("closes all terminals for a thread when close omits terminalId", async () => {
309 const { manager, ptyAdapter, logsDir } = makeManager();
310 await manager.open(openInput({ terminalId: "default" }));
311 await manager.open(openInput({ terminalId: "sidecar" }));
312 const defaultProcess = ptyAdapter.processes[0];
313 const sidecarProcess = ptyAdapter.processes[1];
314 expect(defaultProcess).toBeDefined();
315 expect(sidecarProcess).toBeDefined();
316 if (!defaultProcess || !sidecarProcess) return;
317
318 defaultProcess.emitData("default\n");
319 sidecarProcess.emitData("sidecar\n");
320 await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default")));
321 await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar")));
322
323 await manager.close({ threadId: "thread-1", deleteHistory: true });
324
325 expect(defaultProcess.killed).toBe(true);
326 expect(sidecarProcess.killed).toBe(true);
327 expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(
328 false,
329 );
330 expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(
331 false,
332 );
333
334 manager.dispose();
335 });
336
265 it("loads existing legacy transcript filenames and keeps new naming for writes", async () => {337 it("loads existing legacy transcript filenames and keeps new naming for writes", async () => {
266 const { manager, logsDir } = makeManager();338 const { manager, logsDir } = makeManager();
267 const legacyPath = path.join(logsDir, "thread-1.log");339 const legacyPath = path.join(logsDir, "thread-1.log");
apps/server/src/terminalManager.ts+184-70
...@@ -3,17 +3,19 @@ import fs from "node:fs";...@@ -3,17 +3,19 @@ import fs from "node:fs";
3import path from "node:path";3import path from "node:path";
44
5import {5import {
6 DEFAULT_TERMINAL_ID,
7 type TerminalClearInput,
6 type TerminalCloseInput,8 type TerminalCloseInput,
7 type TerminalEvent,9 type TerminalEvent,
8 type TerminalOpenInput,10 type TerminalOpenInput,
11 type TerminalResizeInput,
9 type TerminalSessionSnapshot,12 type TerminalSessionSnapshot,
10 type TerminalSessionStatus,13 type TerminalSessionStatus,
11 type TerminalThreadInput,
12 type TerminalWriteInput,14 type TerminalWriteInput,
15 terminalClearInputSchema,
13 terminalCloseInputSchema,16 terminalCloseInputSchema,
14 terminalOpenInputSchema,17 terminalOpenInputSchema,
15 terminalResizeInputSchema,18 terminalResizeInputSchema,
16 terminalThreadInputSchema,
17 terminalWriteInputSchema,19 terminalWriteInputSchema,
18} from "@t3tools/contracts";20} from "@t3tools/contracts";
1921
...@@ -36,6 +38,7 @@ export interface TerminalManagerOptions {...@@ -36,6 +38,7 @@ export interface TerminalManagerOptions {
3638
37interface TerminalSessionState {39interface TerminalSessionState {
38 threadId: string;40 threadId: string;
41 terminalId: string;
39 cwd: string;42 cwd: string;
40 status: TerminalSessionStatus;43 status: TerminalSessionStatus;
41 pid: number | null;44 pid: number | null;
...@@ -139,6 +142,14 @@ function toSafeThreadId(threadId: string): string {...@@ -139,6 +142,14 @@ function toSafeThreadId(threadId: string): string {
139 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;142 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
140}143}
141144
145function toSafeTerminalId(terminalId: string): string {
146 return Buffer.from(terminalId, "utf8").toString("base64url");
147}
148
149function toSessionKey(threadId: string, terminalId: string): string {
150 return `${threadId}\u0000${terminalId}`;
151}
152
142export class TerminalManager extends EventEmitter<TerminalManagerEvents> {153export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
143 private readonly sessions = new Map<string, TerminalSessionState>();154 private readonly sessions = new Map<string, TerminalSessionState>();
144 private readonly logsDir: string;155 private readonly logsDir: string;
...@@ -167,12 +178,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -167,12 +178,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
167 return this.runWithThreadLock(input.threadId, async () => {178 return this.runWithThreadLock(input.threadId, async () => {
168 await this.assertValidCwd(input.cwd);179 await this.assertValidCwd(input.cwd);
169180
170 const existing = this.sessions.get(input.threadId);181 const sessionKey = toSessionKey(input.threadId, input.terminalId);
182 const existing = this.sessions.get(sessionKey);
171 if (!existing) {183 if (!existing) {
172 await this.flushPersistQueue(input.threadId);184 await this.flushPersistQueue(input.threadId, input.terminalId);
173 const history = await this.readHistory(input.threadId);185 const history = await this.readHistory(input.threadId, input.terminalId);
174 const session: TerminalSessionState = {186 const session: TerminalSessionState = {
175 threadId: input.threadId,187 threadId: input.threadId,
188 terminalId: input.terminalId,
176 cwd: input.cwd,189 cwd: input.cwd,
177 status: "starting",190 status: "starting",
178 pid: null,191 pid: null,
...@@ -186,7 +199,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -186,7 +199,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
186 unsubscribeData: null,199 unsubscribeData: null,
187 unsubscribeExit: null,200 unsubscribeExit: null,
188 };201 };
189 this.sessions.set(input.threadId, session);202 this.sessions.set(sessionKey, session);
190 this.startSession(session, input, "started");203 this.startSession(session, input, "started");
191 return this.snapshot(session);204 return this.snapshot(session);
192 }205 }
...@@ -195,10 +208,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -195,10 +208,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
195 this.stopProcess(existing);208 this.stopProcess(existing);
196 existing.cwd = input.cwd;209 existing.cwd = input.cwd;
197 existing.history = "";210 existing.history = "";
198 await this.persistHistory(existing.threadId, existing.history);211 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
199 } else if (existing.status === "exited" || existing.status === "error") {212 } else if (existing.status === "exited" || existing.status === "error") {
200 existing.history = "";213 existing.history = "";
201 await this.persistHistory(existing.threadId, existing.history);214 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
202 }215 }
203216
204 if (!existing.process) {217 if (!existing.process) {
...@@ -219,18 +232,22 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -219,18 +232,22 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
219232
220 async write(raw: TerminalWriteInput): Promise<void> {233 async write(raw: TerminalWriteInput): Promise<void> {
221 const input = terminalWriteInputSchema.parse(raw);234 const input = terminalWriteInputSchema.parse(raw);
222 const session = this.requireSession(input.threadId);235 const session = this.requireSession(input.threadId, input.terminalId);
223 if (!session.process || session.status !== "running") {236 if (!session.process || session.status !== "running") {
224 throw new Error(`Terminal is not running for thread: ${input.threadId}`);237 throw new Error(
238 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,
239 );
225 }240 }
226 session.process.write(input.data);241 session.process.write(input.data);
227 }242 }
228243
229 async resize(raw: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {244 async resize(raw: TerminalResizeInput): Promise<void> {
230 const input = terminalResizeInputSchema.parse(raw);245 const input = terminalResizeInputSchema.parse(raw);
231 const session = this.requireSession(input.threadId);246 const session = this.requireSession(input.threadId, input.terminalId);
232 if (!session.process || session.status !== "running") {247 if (!session.process || session.status !== "running") {
233 throw new Error(`Terminal is not running for thread: ${input.threadId}`);248 throw new Error(
249 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,
250 );
234 }251 }
235 session.cols = input.cols;252 session.cols = input.cols;
236 session.rows = input.rows;253 session.rows = input.rows;
...@@ -238,16 +255,17 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -238,16 +255,17 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
238 session.process.resize(input.cols, input.rows);255 session.process.resize(input.cols, input.rows);
239 }256 }
240257
241 async clear(raw: TerminalThreadInput): Promise<void> {258 async clear(raw: TerminalClearInput): Promise<void> {
242 const input = terminalThreadInputSchema.parse(raw);259 const input = terminalClearInputSchema.parse(raw);
243 await this.runWithThreadLock(input.threadId, async () => {260 await this.runWithThreadLock(input.threadId, async () => {
244 const session = this.requireSession(input.threadId);261 const session = this.requireSession(input.threadId, input.terminalId);
245 session.history = "";262 session.history = "";
246 session.updatedAt = new Date().toISOString();263 session.updatedAt = new Date().toISOString();
247 await this.persistHistory(input.threadId, session.history);264 await this.persistHistory(input.threadId, input.terminalId, session.history);
248 this.emitEvent({265 this.emitEvent({
249 type: "cleared",266 type: "cleared",
250 threadId: input.threadId,267 threadId: input.threadId,
268 terminalId: input.terminalId,
251 createdAt: new Date().toISOString(),269 createdAt: new Date().toISOString(),
252 });270 });
253 });271 });
...@@ -258,10 +276,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -258,10 +276,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
258 return this.runWithThreadLock(input.threadId, async () => {276 return this.runWithThreadLock(input.threadId, async () => {
259 await this.assertValidCwd(input.cwd);277 await this.assertValidCwd(input.cwd);
260278
261 let session = this.sessions.get(input.threadId);279 const sessionKey = toSessionKey(input.threadId, input.terminalId);
280 let session = this.sessions.get(sessionKey);
262 if (!session) {281 if (!session) {
263 session = {282 session = {
264 threadId: input.threadId,283 threadId: input.threadId,
284 terminalId: input.terminalId,
265 cwd: input.cwd,285 cwd: input.cwd,
266 status: "starting",286 status: "starting",
267 pid: null,287 pid: null,
...@@ -275,14 +295,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -275,14 +295,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
275 unsubscribeData: null,295 unsubscribeData: null,
276 unsubscribeExit: null,296 unsubscribeExit: null,
277 };297 };
278 this.sessions.set(input.threadId, session);298 this.sessions.set(sessionKey, session);
279 } else {299 } else {
280 this.stopProcess(session);300 this.stopProcess(session);
281 session.cwd = input.cwd;301 session.cwd = input.cwd;
282 }302 }
283303
284 session.history = "";304 session.history = "";
285 await this.persistHistory(input.threadId, session.history);305 await this.persistHistory(input.threadId, input.terminalId, session.history);
286 this.startSession(session, input, "restarted");306 this.startSession(session, input, "restarted");
287 return this.snapshot(session);307 return this.snapshot(session);
288 });308 });
...@@ -291,14 +311,28 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -291,14 +311,28 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
291 async close(raw: TerminalCloseInput): Promise<void> {311 async close(raw: TerminalCloseInput): Promise<void> {
292 const input = terminalCloseInputSchema.parse(raw);312 const input = terminalCloseInputSchema.parse(raw);
293 await this.runWithThreadLock(input.threadId, async () => {313 await this.runWithThreadLock(input.threadId, async () => {
294 const session = this.sessions.get(input.threadId);314 if (input.terminalId) {
295 if (session) {315 await this.closeSession(
316 input.threadId,
317 input.terminalId,
318 input.deleteHistory === true,
319 );
320 return;
321 }
322
323 const threadSessions = this.sessionsForThread(input.threadId);
324 for (const session of threadSessions) {
296 this.stopProcess(session);325 this.stopProcess(session);
297 this.sessions.delete(input.threadId);326 this.sessions.delete(toSessionKey(session.threadId, session.terminalId));
298 }327 }
299 await this.flushPersistQueue(input.threadId);328 await Promise.all(
329 threadSessions.map((session) =>
330 this.flushPersistQueue(session.threadId, session.terminalId),
331 ),
332 );
333
300 if (input.deleteHistory) {334 if (input.deleteHistory) {
301 await this.deleteHistory(input.threadId);335 await this.deleteAllHistoryForThread(input.threadId);
302 }336 }
303 });337 });
304 }338 }
...@@ -382,6 +416,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -382,6 +416,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
382 this.emitEvent({416 this.emitEvent({
383 type: eventType,417 type: eventType,
384 threadId: session.threadId,418 threadId: session.threadId,
419 terminalId: session.terminalId,
385 createdAt: new Date().toISOString(),420 createdAt: new Date().toISOString(),
386 snapshot: this.snapshot(session),421 snapshot: this.snapshot(session),
387 });422 });
...@@ -401,11 +436,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -401,11 +436,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
401 this.emitEvent({436 this.emitEvent({
402 type: "error",437 type: "error",
403 threadId: session.threadId,438 threadId: session.threadId,
439 terminalId: session.terminalId,
404 createdAt: new Date().toISOString(),440 createdAt: new Date().toISOString(),
405 message,441 message,
406 });442 });
407 this.logger.error("failed to start terminal", {443 this.logger.error("failed to start terminal", {
408 threadId: session.threadId,444 threadId: session.threadId,
445 terminalId: session.terminalId,
409 error: message,446 error: message,
410 ...(startedShell ? { shell: startedShell } : {}),447 ...(startedShell ? { shell: startedShell } : {}),
411 });448 });
...@@ -415,10 +452,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -415,10 +452,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
415 private onProcessData(session: TerminalSessionState, data: string): void {452 private onProcessData(session: TerminalSessionState, data: string): void {
416 session.history = capHistory(`${session.history}${data}`, this.historyLineLimit);453 session.history = capHistory(`${session.history}${data}`, this.historyLineLimit);
417 session.updatedAt = new Date().toISOString();454 session.updatedAt = new Date().toISOString();
418 this.queuePersist(session.threadId, session.history);455 this.queuePersist(session.threadId, session.terminalId, session.history);
419 this.emitEvent({456 this.emitEvent({
420 type: "output",457 type: "output",
421 threadId: session.threadId,458 threadId: session.threadId,
459 terminalId: session.terminalId,
422 createdAt: new Date().toISOString(),460 createdAt: new Date().toISOString(),
423 data,461 data,
424 });462 });
...@@ -435,6 +473,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -435,6 +473,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
435 this.emitEvent({473 this.emitEvent({
436 type: "exited",474 type: "exited",
437 threadId: session.threadId,475 threadId: session.threadId,
476 terminalId: session.terminalId,
438 createdAt: new Date().toISOString(),477 createdAt: new Date().toISOString(),
439 exitCode: session.exitCode,478 exitCode: session.exitCode,
440 exitSignal: session.exitSignal,479 exitSignal: session.exitSignal,
...@@ -455,6 +494,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -455,6 +494,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
455 const message = error instanceof Error ? error.message : String(error);494 const message = error instanceof Error ? error.message : String(error);
456 this.logger.warn("failed to kill terminal process", {495 this.logger.warn("failed to kill terminal process", {
457 threadId: session.threadId,496 threadId: session.threadId,
497 terminalId: session.terminalId,
458 error: message,498 error: message,
459 });499 });
460 }500 }
...@@ -467,69 +507,87 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -467,69 +507,87 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
467 session.unsubscribeExit = null;507 session.unsubscribeExit = null;
468 }508 }
469509
470 private queuePersist(threadId: string, history: string): void {510 private queuePersist(threadId: string, terminalId: string, history: string): void {
471 this.pendingPersistHistory.set(threadId, history);511 const persistenceKey = toSessionKey(threadId, terminalId);
472 this.schedulePersist(threadId);512 this.pendingPersistHistory.set(persistenceKey, history);
513 this.schedulePersist(threadId, terminalId);
473 }514 }
474515
475 private async persistHistory(threadId: string, history: string): Promise<void> {516 private async persistHistory(
476 this.clearPersistTimer(threadId);517 threadId: string,
477 this.pendingPersistHistory.delete(threadId);518 terminalId: string,
478 await this.enqueuePersistWrite(threadId, history);519 history: string,
520 ): Promise<void> {
521 const persistenceKey = toSessionKey(threadId, terminalId);
522 this.clearPersistTimer(threadId, terminalId);
523 this.pendingPersistHistory.delete(persistenceKey);
524 await this.enqueuePersistWrite(threadId, terminalId, history);
479 }525 }
480526
481 private enqueuePersistWrite(threadId: string, history: string): Promise<void> {527 private enqueuePersistWrite(
528 threadId: string,
529 terminalId: string,
530 history: string,
531 ): Promise<void> {
532 const persistenceKey = toSessionKey(threadId, terminalId);
482 const task = async () => {533 const task = async () => {
483 await fs.promises.writeFile(this.historyPath(threadId), history, "utf8");534 await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8");
484 };535 };
485 const previous = this.persistQueues.get(threadId) ?? Promise.resolve();536 const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve();
486 const next = previous537 const next = previous
487 .catch(() => undefined)538 .catch(() => undefined)
488 .then(task)539 .then(task)
489 .catch((error) => {540 .catch((error) => {
490 this.logger.warn("failed to persist terminal history", {541 this.logger.warn("failed to persist terminal history", {
491 threadId,542 threadId,
543 terminalId,
492 error: error instanceof Error ? error.message : String(error),544 error: error instanceof Error ? error.message : String(error),
493 });545 });
494 });546 });
495 this.persistQueues.set(threadId, next);547 this.persistQueues.set(persistenceKey, next);
496 const finalized = next.finally(() => {548 const finalized = next.finally(() => {
497 if (this.persistQueues.get(threadId) === next) {549 if (this.persistQueues.get(persistenceKey) === next) {
498 this.persistQueues.delete(threadId);550 this.persistQueues.delete(persistenceKey);
499 }551 }
500 if (this.pendingPersistHistory.has(threadId) && !this.persistTimers.has(threadId)) {552 if (
501 this.schedulePersist(threadId);553 this.pendingPersistHistory.has(persistenceKey) &&
554 !this.persistTimers.has(persistenceKey)
555 ) {
556 this.schedulePersist(threadId, terminalId);
502 }557 }
503 });558 });
504 void finalized.catch(() => undefined);559 void finalized.catch(() => undefined);
505 return finalized;560 return finalized;
506 }561 }
507562
508 private schedulePersist(threadId: string): void {563 private schedulePersist(threadId: string, terminalId: string): void {
509 if (this.persistTimers.has(threadId)) return;564 const persistenceKey = toSessionKey(threadId, terminalId);
565 if (this.persistTimers.has(persistenceKey)) return;
510 const timer = setTimeout(() => {566 const timer = setTimeout(() => {
511 this.persistTimers.delete(threadId);567 this.persistTimers.delete(persistenceKey);
512 const pendingHistory = this.pendingPersistHistory.get(threadId);568 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);
513 if (pendingHistory === undefined) return;569 if (pendingHistory === undefined) return;
514 this.pendingPersistHistory.delete(threadId);570 this.pendingPersistHistory.delete(persistenceKey);
515 void this.enqueuePersistWrite(threadId, pendingHistory);571 void this.enqueuePersistWrite(threadId, terminalId, pendingHistory);
516 }, this.persistDebounceMs);572 }, this.persistDebounceMs);
517 this.persistTimers.set(threadId, timer);573 this.persistTimers.set(persistenceKey, timer);
518 }574 }
519575
520 private clearPersistTimer(threadId: string): void {576 private clearPersistTimer(threadId: string, terminalId: string): void {
521 const timer = this.persistTimers.get(threadId);577 const persistenceKey = toSessionKey(threadId, terminalId);
578 const timer = this.persistTimers.get(persistenceKey);
522 if (!timer) return;579 if (!timer) return;
523 clearTimeout(timer);580 clearTimeout(timer);
524 this.persistTimers.delete(threadId);581 this.persistTimers.delete(persistenceKey);
525 }582 }
526583
527 private async readHistory(threadId: string): Promise<string> {584 private async readHistory(threadId: string, terminalId: string): Promise<string> {
585 const historyPath = this.historyPath(threadId, terminalId);
528 try {586 try {
529 const raw = await fs.promises.readFile(this.historyPath(threadId), "utf8");587 const raw = await fs.promises.readFile(historyPath, "utf8");
530 const capped = capHistory(raw, this.historyLineLimit);588 const capped = capHistory(raw, this.historyLineLimit);
531 if (capped !== raw) {589 if (capped !== raw) {
532 await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8");590 await fs.promises.writeFile(historyPath, capped, "utf8");
533 }591 }
534 return capped;592 return capped;
535 } catch (error) {593 } catch (error) {
...@@ -538,6 +596,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -538,6 +596,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
538 }596 }
539 }597 }
540598
599 if (terminalId !== DEFAULT_TERMINAL_ID) {
600 return "";
601 }
602
541 try {603 try {
542 const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8");604 const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8");
543 const capped = capHistory(raw, this.historyLineLimit);605 const capped = capHistory(raw, this.historyLineLimit);
...@@ -553,31 +615,34 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -553,31 +615,34 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
553 }615 }
554 }616 }
555617
556 private async deleteHistory(threadId: string): Promise<void> {618 private async deleteHistory(threadId: string, terminalId: string): Promise<void> {
619 const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })];
620 if (terminalId === DEFAULT_TERMINAL_ID) {
621 deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }));
622 }
557 try {623 try {
558 await Promise.all([624 await Promise.all(deletions);
559 fs.promises.rm(this.historyPath(threadId), { force: true }),
560 fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }),
561 ]);
562 } catch (error) {625 } catch (error) {
563 this.logger.warn("failed to delete terminal history", {626 this.logger.warn("failed to delete terminal history", {
564 threadId,627 threadId,
628 terminalId,
565 error: error instanceof Error ? error.message : String(error),629 error: error instanceof Error ? error.message : String(error),
566 });630 });
567 }631 }
568 }632 }
569633
570 private async flushPersistQueue(threadId: string): Promise<void> {634 private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> {
571 this.clearPersistTimer(threadId);635 const persistenceKey = toSessionKey(threadId, terminalId);
636 this.clearPersistTimer(threadId, terminalId);
572637
573 while (true) {638 while (true) {
574 const pendingHistory = this.pendingPersistHistory.get(threadId);639 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);
575 if (pendingHistory !== undefined) {640 if (pendingHistory !== undefined) {
576 this.pendingPersistHistory.delete(threadId);641 this.pendingPersistHistory.delete(persistenceKey);
577 await this.enqueuePersistWrite(threadId, pendingHistory);642 await this.enqueuePersistWrite(threadId, terminalId, pendingHistory);
578 }643 }
579644
580 const pending = this.persistQueues.get(threadId);645 const pending = this.persistQueues.get(persistenceKey);
581 if (!pending) {646 if (!pending) {
582 return;647 return;
583 }648 }
...@@ -600,10 +665,54 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -600,10 +665,54 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
600 }665 }
601 }666 }
602667
603 private requireSession(threadId: string): TerminalSessionState {668 private async closeSession(
604 const session = this.sessions.get(threadId);669 threadId: string,
670 terminalId: string,
671 deleteHistory: boolean,
672 ): Promise<void> {
673 const key = toSessionKey(threadId, terminalId);
674 const session = this.sessions.get(key);
675 if (session) {
676 this.stopProcess(session);
677 this.sessions.delete(key);
678 }
679 await this.flushPersistQueue(threadId, terminalId);
680 if (deleteHistory) {
681 await this.deleteHistory(threadId, terminalId);
682 }
683 }
684
685 private sessionsForThread(threadId: string): TerminalSessionState[] {
686 return [...this.sessions.values()].filter((session) => session.threadId === threadId);
687 }
688
689 private async deleteAllHistoryForThread(threadId: string): Promise<void> {
690 const threadPrefix = `${toSafeThreadId(threadId)}_`;
691 try {
692 const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true });
693 const removals = entries
694 .filter((entry) => entry.isFile())
695 .map((entry) => entry.name)
696 .filter(
697 (name) =>
698 name === `${toSafeThreadId(threadId)}.log` ||
699 name === `${legacySafeThreadId(threadId)}.log` ||
700 name.startsWith(threadPrefix),
701 )
702 .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true }));
703 await Promise.all(removals);
704 } catch (error) {
705 this.logger.warn("failed to delete terminal histories for thread", {
706 threadId,
707 error: error instanceof Error ? error.message : String(error),
708 });
709 }
710 }
711
712 private requireSession(threadId: string, terminalId: string): TerminalSessionState {
713 const session = this.sessions.get(toSessionKey(threadId, terminalId));
605 if (!session) {714 if (!session) {
606 throw new Error(`Unknown terminal thread: ${threadId}`);715 throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`);
607 }716 }
608 return session;717 return session;
609 }718 }
...@@ -611,6 +720,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -611,6 +720,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
611 private snapshot(session: TerminalSessionState): TerminalSessionSnapshot {720 private snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
612 return {721 return {
613 threadId: session.threadId,722 threadId: session.threadId,
723 terminalId: session.terminalId,
614 cwd: session.cwd,724 cwd: session.cwd,
615 status: session.status,725 status: session.status,
616 pid: session.pid,726 pid: session.pid,
...@@ -625,8 +735,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -625,8 +735,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
625 this.emit("event", event);735 this.emit("event", event);
626 }736 }
627737
628 private historyPath(threadId: string): string {738 private historyPath(threadId: string, terminalId: string): string {
629 return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`);739 const threadPart = toSafeThreadId(threadId);
740 if (terminalId === DEFAULT_TERMINAL_ID) {
741 return path.join(this.logsDir, `${threadPart}.log`);
742 }
743 return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`);
630 }744 }
631745
632 private legacyHistoryPath(threadId: string): string {746 private legacyHistoryPath(threadId: string): string {
apps/server/src/wsServer.test.ts+39-8
...@@ -7,14 +7,21 @@ import { describe, expect, it, afterEach, vi } from "vitest";...@@ -7,14 +7,21 @@ import { describe, expect, it, afterEach, vi } from "vitest";
7import { createServer } from "./wsServer";7import { createServer } from "./wsServer";
8import WebSocket from "ws";8import WebSocket from "ws";
99
10import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts";10import {
11 DEFAULT_TERMINAL_ID,
12 WS_CHANNELS,
13 WS_METHODS,
14 type WsPush,
15 type WsResponse,
16} from "@t3tools/contracts";
11import { ProjectRegistry } from "./projectRegistry";17import { ProjectRegistry } from "./projectRegistry";
12import type {18import type {
19 TerminalClearInput,
13 TerminalCloseInput,20 TerminalCloseInput,
14 TerminalEvent,21 TerminalEvent,
15 TerminalOpenInput,22 TerminalOpenInput,
23 TerminalResizeInput,
16 TerminalSessionSnapshot,24 TerminalSessionSnapshot,
17 TerminalThreadInput,
18 TerminalWriteInput,25 TerminalWriteInput,
19} from "@t3tools/contracts";26} from "@t3tools/contracts";
20import type { TerminalManager } from "./terminalManager";27import type { TerminalManager } from "./terminalManager";
...@@ -29,10 +36,16 @@ const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();...@@ -29,10 +36,16 @@ const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
29class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {36class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
30 private readonly sessions = new Map<string, TerminalSessionSnapshot>();37 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
3138
39 private key(threadId: string, terminalId: string): string {
40 return `${threadId}\u0000${terminalId}`;
41 }
42
32 async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {43 async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
33 const now = new Date().toISOString();44 const now = new Date().toISOString();
45 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
34 const snapshot: TerminalSessionSnapshot = {46 const snapshot: TerminalSessionSnapshot = {
35 threadId: input.threadId,47 threadId: input.threadId,
48 terminalId,
36 cwd: input.cwd,49 cwd: input.cwd,
37 status: "running",50 status: "running",
38 pid: 4242,51 pid: 4242,
...@@ -41,11 +54,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }...@@ -41,11 +54,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
41 exitSignal: null,54 exitSignal: null,
42 updatedAt: now,55 updatedAt: now,
43 };56 };
44 this.sessions.set(input.threadId, snapshot);57 this.sessions.set(this.key(input.threadId, terminalId), snapshot);
45 queueMicrotask(() => {58 queueMicrotask(() => {
46 this.emit("event", {59 this.emit("event", {
47 type: "started",60 type: "started",
48 threadId: input.threadId,61 threadId: input.threadId,
62 terminalId,
49 createdAt: now,63 createdAt: now,
50 snapshot,64 snapshot,
51 });65 });
...@@ -54,7 +68,8 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }...@@ -54,7 +68,8 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
54 }68 }
5569
56 async write(input: TerminalWriteInput): Promise<void> {70 async write(input: TerminalWriteInput): Promise<void> {
57 const existing = this.sessions.get(input.threadId);71 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
72 const existing = this.sessions.get(this.key(input.threadId, terminalId));
58 if (!existing) {73 if (!existing) {
59 throw new Error(`Unknown terminal thread: ${input.threadId}`);74 throw new Error(`Unknown terminal thread: ${input.threadId}`);
60 }75 }
...@@ -62,19 +77,22 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }...@@ -62,19 +77,22 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
62 this.emit("event", {77 this.emit("event", {
63 type: "output",78 type: "output",
64 threadId: input.threadId,79 threadId: input.threadId,
80 terminalId,
65 createdAt: new Date().toISOString(),81 createdAt: new Date().toISOString(),
66 data: input.data,82 data: input.data,
67 });83 });
68 });84 });
69 }85 }
7086
71 async resize(_input: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {}87 async resize(_input: TerminalResizeInput): Promise<void> {}
7288
73 async clear(input: TerminalThreadInput): Promise<void> {89 async clear(input: TerminalClearInput): Promise<void> {
90 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
74 queueMicrotask(() => {91 queueMicrotask(() => {
75 this.emit("event", {92 this.emit("event", {
76 type: "cleared",93 type: "cleared",
77 threadId: input.threadId,94 threadId: input.threadId,
95 terminalId,
78 createdAt: new Date().toISOString(),96 createdAt: new Date().toISOString(),
79 });97 });
80 });98 });
...@@ -82,8 +100,10 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }...@@ -82,8 +100,10 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
82100
83 async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {101 async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
84 const now = new Date().toISOString();102 const now = new Date().toISOString();
103 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
85 const snapshot: TerminalSessionSnapshot = {104 const snapshot: TerminalSessionSnapshot = {
86 threadId: input.threadId,105 threadId: input.threadId,
106 terminalId,
87 cwd: input.cwd,107 cwd: input.cwd,
88 status: "running",108 status: "running",
89 pid: 5252,109 pid: 5252,
...@@ -92,11 +112,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }...@@ -92,11 +112,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
92 exitSignal: null,112 exitSignal: null,
93 updatedAt: now,113 updatedAt: now,
94 };114 };
95 this.sessions.set(input.threadId, snapshot);115 this.sessions.set(this.key(input.threadId, terminalId), snapshot);
96 queueMicrotask(() => {116 queueMicrotask(() => {
97 this.emit("event", {117 this.emit("event", {
98 type: "restarted",118 type: "restarted",
99 threadId: input.threadId,119 threadId: input.threadId,
120 terminalId,
100 createdAt: now,121 createdAt: now,
101 snapshot,122 snapshot,
102 });123 });
...@@ -105,7 +126,15 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }...@@ -105,7 +126,15 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
105 }126 }
106127
107 async close(input: TerminalCloseInput): Promise<void> {128 async close(input: TerminalCloseInput): Promise<void> {
108 this.sessions.delete(input.threadId);129 if (input.terminalId) {
130 this.sessions.delete(this.key(input.threadId, input.terminalId));
131 return;
132 }
133 for (const key of [...this.sessions.keys()]) {
134 if (key.startsWith(`${input.threadId}\u0000`)) {
135 this.sessions.delete(key);
136 }
137 }
109 }138 }
110139
111 dispose(): void {}140 dispose(): void {}
...@@ -340,6 +369,7 @@ describe("WebSocket Server", () => {...@@ -340,6 +369,7 @@ describe("WebSocket Server", () => {
340 });369 });
341 expect(open.error).toBeUndefined();370 expect(open.error).toBeUndefined();
342 expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");371 expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
372 expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID);
343373
344 const write = await sendRequest(ws, WS_METHODS.terminalWrite, {374 const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
345 threadId: "thread-1",375 threadId: "thread-1",
...@@ -376,6 +406,7 @@ describe("WebSocket Server", () => {...@@ -376,6 +406,7 @@ describe("WebSocket Server", () => {
376 const manualEvent: TerminalEvent = {406 const manualEvent: TerminalEvent = {
377 type: "output",407 type: "output",
378 threadId: "thread-1",408 threadId: "thread-1",
409 terminalId: DEFAULT_TERMINAL_ID,
379 createdAt: new Date().toISOString(),410 createdAt: new Date().toISOString(),
380 data: "manual test output\n",411 data: "manual test output\n",
381 };412 };
apps/web/src/App.tsx+5-13
...@@ -7,7 +7,7 @@ import Sidebar from "./components/Sidebar";...@@ -7,7 +7,7 @@ import Sidebar from "./components/Sidebar";
7import { isElectron } from "./env";7import { isElectron } from "./env";
8import { DEFAULT_MODEL } from "./model-logic";8import { DEFAULT_MODEL } from "./model-logic";
9import { StoreProvider, useStore } from "./store";9import { StoreProvider, useStore } from "./store";
10import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";10import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
11import { onServerWelcome } from "./wsNativeApi";11import { onServerWelcome } from "./wsNativeApi";
12import { useNativeApi } from "./hooks/useNativeApi";12import { useNativeApi } from "./hooks/useNativeApi";
1313
...@@ -27,18 +27,6 @@ function EventRouter() {...@@ -27,18 +27,6 @@ function EventRouter() {
27 });27 });
28 }, [api, dispatch]);28 }, [api, dispatch]);
2929
30 useEffect(() => {
31 if (!api) return;
32 return api.terminal.onEvent((event) => {
33 if (event.type !== "exited") return;
34 dispatch({
35 type: "SET_THREAD_TERMINAL_OPEN",
36 threadId: event.threadId,
37 open: false,
38 });
39 });
40 }, [api, dispatch]);
41
42 return null;30 return null;
43}31}
4432
...@@ -93,6 +81,10 @@ function AutoProjectBootstrap() {...@@ -93,6 +81,10 @@ function AutoProjectBootstrap() {
93 model: DEFAULT_MODEL,81 model: DEFAULT_MODEL,
94 terminalOpen: false,82 terminalOpen: false,
95 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,83 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
84 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
85 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
86 terminalLayout: "single",
87 splitTerminalIds: [],
96 session: null,88 session: null,
97 messages: [],89 messages: [],
98 events: [],90 events: [],
apps/web/src/components/ChatView.tsx+37-7
...@@ -269,6 +269,36 @@ export default function ChatView() {...@@ -269,6 +269,36 @@ export default function ChatView() {
269 open: !isOpen,269 open: !isOpen,
270 });270 });
271 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);271 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);
272 const splitTerminal = useCallback(() => {
273 if (!activeThreadId) return;
274 dispatch({
275 type: "SPLIT_THREAD_TERMINAL",
276 threadId: activeThreadId,
277 terminalId: `terminal-${crypto.randomUUID()}`,
278 });
279 setTerminalFocusRequestId((value) => value + 1);
280 }, [activeThreadId, dispatch]);
281 const createNewTerminal = useCallback(() => {
282 if (!activeThreadId) return;
283 dispatch({
284 type: "NEW_THREAD_TERMINAL",
285 threadId: activeThreadId,
286 terminalId: `terminal-${crypto.randomUUID()}`,
287 });
288 setTerminalFocusRequestId((value) => value + 1);
289 }, [activeThreadId, dispatch]);
290 const activateTerminal = useCallback(
291 (terminalId: string) => {
292 if (!activeThreadId) return;
293 dispatch({
294 type: "SET_THREAD_ACTIVE_TERMINAL",
295 threadId: activeThreadId,
296 terminalId,
297 });
298 setTerminalFocusRequestId((value) => value + 1);
299 },
300 [activeThreadId, dispatch],
301 );
272302
273 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {303 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
274 if (mode === state.runtimeMode) return;304 if (mode === state.runtimeMode) return;
...@@ -1451,7 +1481,14 @@ export default function ChatView() {...@@ -1451,7 +1481,14 @@ export default function ChatView() {
1451 threadId={activeThread.id}1481 threadId={activeThread.id}
1452 cwd={activeProject.cwd}1482 cwd={activeProject.cwd}
1453 height={activeThread.terminalHeight}1483 height={activeThread.terminalHeight}
1484 terminalIds={activeThread.terminalIds}
1485 activeTerminalId={activeThread.activeTerminalId}
1486 terminalLayout={activeThread.terminalLayout}
1487 splitTerminalIds={activeThread.splitTerminalIds}
1454 focusRequestId={terminalFocusRequestId}1488 focusRequestId={terminalFocusRequestId}
1489 onSplitTerminal={splitTerminal}
1490 onNewTerminal={createNewTerminal}
1491 onActiveTerminalChange={activateTerminal}
1455 onHeightChange={(height) =>1492 onHeightChange={(height) =>
1456 dispatch({1493 dispatch({
1457 type: "SET_THREAD_TERMINAL_HEIGHT",1494 type: "SET_THREAD_TERMINAL_HEIGHT",
...@@ -1459,13 +1496,6 @@ export default function ChatView() {...@@ -1459,13 +1496,6 @@ export default function ChatView() {
1459 height,1496 height,
1460 })1497 })
1461 }1498 }
1462 onThreadExited={() =>
1463 dispatch({
1464 type: "SET_THREAD_TERMINAL_OPEN",
1465 threadId: activeThread.id,
1466 open: false,
1467 })
1468 }
1469 />1499 />
1470 )}1500 )}
14711501
apps/web/src/components/Sidebar.tsx+10-1
...@@ -5,7 +5,12 @@ import { useTheme } from "../hooks/useTheme";...@@ -5,7 +5,12 @@ import { useTheme } from "../hooks/useTheme";
5import { DEFAULT_MODEL } from "../model-logic";5import { DEFAULT_MODEL } from "../model-logic";
6import { derivePendingApprovals } from "../session-logic";6import { derivePendingApprovals } from "../session-logic";
7import { useStore } from "../store";7import { useStore } from "../store";
8import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project, type Thread } from "../types";8import {
9 DEFAULT_THREAD_TERMINAL_HEIGHT,
10 DEFAULT_THREAD_TERMINAL_ID,
11 type Project,
12 type Thread,
13} from "../types";
9import { useNativeApi } from "../hooks/useNativeApi";14import { useNativeApi } from "../hooks/useNativeApi";
1015
11const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;16const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
...@@ -112,6 +117,10 @@ export default function Sidebar() {...@@ -112,6 +117,10 @@ export default function Sidebar() {
112 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,117 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
113 terminalOpen: false,118 terminalOpen: false,
114 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,119 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
120 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
121 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
122 terminalLayout: "single",
123 splitTerminalIds: [],
115 session: null,124 session: null,
116 messages: [],125 messages: [],
117 events: [],126 events: [],
apps/web/src/components/ThreadTerminalDrawer.tsx+303-159
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type PointerEvent as ReactPointerEvent,5 type PointerEvent as ReactPointerEvent,
6 useCallback,6 useCallback,
7 useEffect,7 useEffect,
8 useMemo,
8 useRef,9 useRef,
9 useState,10 useState,
10} from "react";11} from "react";
...@@ -15,7 +16,11 @@ import {...@@ -15,7 +16,11 @@ import {
15 resolvePathLinkTarget,16 resolvePathLinkTarget,
16} from "../terminal-links";17} from "../terminal-links";
17import { isTerminalClearShortcut } from "../terminal-shortcuts";18import { isTerminalClearShortcut } from "../terminal-shortcuts";
18import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types";19import {
20 DEFAULT_THREAD_TERMINAL_HEIGHT,
21 DEFAULT_THREAD_TERMINAL_ID,
22 type ThreadTerminalLayout,
23} from "../types";
1924
20const MIN_DRAWER_HEIGHT = 180;25const MIN_DRAWER_HEIGHT = 180;
21const MAX_DRAWER_HEIGHT_RATIO = 0.75;26const MAX_DRAWER_HEIGHT_RATIO = 0.75;
...@@ -103,162 +108,30 @@ function terminalThemeFromApp(): ITheme {...@@ -103,162 +108,30 @@ function terminalThemeFromApp(): ITheme {
103 };108 };
104}109}
105110
106interface ThreadTerminalDrawerProps {111interface TerminalViewportProps {
107 api: NativeApi;112 api: NativeApi;
108 threadId: string;113 threadId: string;
114 terminalId: string;
109 cwd: string;115 cwd: string;
110 height: number;
111 focusRequestId: number;116 focusRequestId: number;
112 onHeightChange: (height: number) => void;117 autoFocus: boolean;
113 onThreadExited: () => void;118 resizeEpoch: number;
119 drawerHeight: number;
114}120}
115121
116export default function ThreadTerminalDrawer({122function TerminalViewport({
117 api,123 api,
118 threadId,124 threadId,
125 terminalId,
119 cwd,126 cwd,
120 height,
121 focusRequestId,127 focusRequestId,
122 onHeightChange,128 autoFocus,
123 onThreadExited,129 resizeEpoch,
124}: ThreadTerminalDrawerProps) {130 drawerHeight,
125 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));131}: TerminalViewportProps) {
126 const containerRef = useRef<HTMLDivElement>(null);132 const containerRef = useRef<HTMLDivElement>(null);
127 const terminalRef = useRef<Terminal | null>(null);133 const terminalRef = useRef<Terminal | null>(null);
128 const fitAddonRef = useRef<FitAddon | null>(null);134 const fitAddonRef = useRef<FitAddon | null>(null);
129 const drawerHeightRef = useRef(drawerHeight);
130 const lastSyncedHeightRef = useRef(clampDrawerHeight(height));
131 const onHeightChangeRef = useRef(onHeightChange);
132 const onThreadExitedRef = useRef(onThreadExited);
133 const resizeStateRef = useRef<{
134 pointerId: number;
135 startY: number;
136 startHeight: number;
137 } | null>(null);
138 const didResizeDuringDragRef = useRef(false);
139
140 useEffect(() => {
141 onHeightChangeRef.current = onHeightChange;
142 }, [onHeightChange]);
143
144 useEffect(() => {
145 onThreadExitedRef.current = onThreadExited;
146 }, [onThreadExited]);
147
148 useEffect(() => {
149 drawerHeightRef.current = drawerHeight;
150 }, [drawerHeight]);
151
152 const syncHeight = useCallback(
153 (nextHeight: number) => {
154 const clampedHeight = clampDrawerHeight(nextHeight);
155 if (lastSyncedHeightRef.current === clampedHeight) return;
156 lastSyncedHeightRef.current = clampedHeight;
157 onHeightChangeRef.current(clampedHeight);
158 },
159 [],
160 );
161
162 const fitAndResizeTerminal = useCallback((preserveBottom = false) => {
163 const activeTerminal = terminalRef.current;
164 const activeFitAddon = fitAddonRef.current;
165 if (!activeTerminal || !activeFitAddon) return;
166 const wasAtBottom =
167 preserveBottom &&
168 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
169 activeFitAddon.fit();
170 if (wasAtBottom) {
171 activeTerminal.scrollToBottom();
172 }
173 void api.terminal
174 .resize({
175 threadId,
176 cols: activeTerminal.cols,
177 rows: activeTerminal.rows,
178 })
179 .catch(() => undefined);
180 }, [api, threadId]);
181
182 useEffect(() => {
183 const clampedHeight = clampDrawerHeight(height);
184 setDrawerHeight(clampedHeight);
185 lastSyncedHeightRef.current = clampedHeight;
186 }, [height, threadId]);
187
188 const handleResizePointerDown = useCallback(
189 (event: ReactPointerEvent<HTMLDivElement>) => {
190 if (event.button !== 0) return;
191 event.preventDefault();
192 event.currentTarget.setPointerCapture(event.pointerId);
193 didResizeDuringDragRef.current = false;
194 resizeStateRef.current = {
195 pointerId: event.pointerId,
196 startY: event.clientY,
197 startHeight: drawerHeightRef.current,
198 };
199 },
200 [],
201 );
202
203 const handleResizePointerMove = useCallback(
204 (event: ReactPointerEvent<HTMLDivElement>) => {
205 const resizeState = resizeStateRef.current;
206 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
207 event.preventDefault();
208 const clampedHeight = clampDrawerHeight(
209 resizeState.startHeight + (resizeState.startY - event.clientY),
210 );
211 if (clampedHeight === drawerHeightRef.current) {
212 return;
213 }
214 didResizeDuringDragRef.current = true;
215 drawerHeightRef.current = clampedHeight;
216 setDrawerHeight(clampedHeight);
217 },
218 [],
219 );
220
221 const handleResizePointerEnd = useCallback(
222 (event: ReactPointerEvent<HTMLDivElement>) => {
223 const resizeState = resizeStateRef.current;
224 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
225 resizeStateRef.current = null;
226 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
227 event.currentTarget.releasePointerCapture(event.pointerId);
228 }
229 if (!didResizeDuringDragRef.current) {
230 return;
231 }
232 syncHeight(drawerHeightRef.current);
233 fitAndResizeTerminal(true);
234 },
235 [fitAndResizeTerminal, syncHeight],
236 );
237
238 useEffect(() => {
239 const onWindowResize = () => {
240 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
241 const changed = clampedHeight !== drawerHeightRef.current;
242 if (changed) {
243 setDrawerHeight(clampedHeight);
244 } else {
245 fitAndResizeTerminal(true);
246 }
247 if (!resizeStateRef.current) {
248 syncHeight(clampedHeight);
249 }
250 };
251 window.addEventListener("resize", onWindowResize);
252 return () => {
253 window.removeEventListener("resize", onWindowResize);
254 };
255 }, [fitAndResizeTerminal, syncHeight]);
256
257 useEffect(() => {
258 return () => {
259 syncHeight(drawerHeightRef.current);
260 };
261 }, [syncHeight]);
262135
263 useEffect(() => {136 useEffect(() => {
264 const mount = containerRef.current;137 const mount = containerRef.current;
...@@ -287,7 +160,7 @@ export default function ThreadTerminalDrawer({...@@ -287,7 +160,7 @@ export default function ThreadTerminalDrawer({
287 const activeTerminal = terminalRef.current;160 const activeTerminal = terminalRef.current;
288 if (!activeTerminal) return;161 if (!activeTerminal) return;
289 try {162 try {
290 await api.terminal.write({ threadId, data: "\u000c" });163 await api.terminal.write({ threadId, terminalId, data: "\u000c" });
291 } catch (error) {164 } catch (error) {
292 writeSystemMessage(165 writeSystemMessage(
293 activeTerminal,166 activeTerminal,
...@@ -367,7 +240,7 @@ export default function ThreadTerminalDrawer({...@@ -367,7 +240,7 @@ export default function ThreadTerminalDrawer({
367240
368 const inputDisposable = terminal.onData((data) => {241 const inputDisposable = terminal.onData((data) => {
369 void api.terminal242 void api.terminal
370 .write({ threadId, data })243 .write({ threadId, terminalId, data })
371 .catch((err) =>244 .catch((err) =>
372 writeSystemMessage(245 writeSystemMessage(
373 terminal,246 terminal,
...@@ -395,6 +268,7 @@ export default function ThreadTerminalDrawer({...@@ -395,6 +268,7 @@ export default function ThreadTerminalDrawer({
395 activeFitAddon.fit();268 activeFitAddon.fit();
396 const snapshot = await api.terminal.open({269 const snapshot = await api.terminal.open({
397 threadId,270 threadId,
271 terminalId,
398 cwd,272 cwd,
399 cols: activeTerminal.cols,273 cols: activeTerminal.cols,
400 rows: activeTerminal.rows,274 rows: activeTerminal.rows,
...@@ -404,9 +278,11 @@ export default function ThreadTerminalDrawer({...@@ -404,9 +278,11 @@ export default function ThreadTerminalDrawer({
404 if (snapshot.history.length > 0) {278 if (snapshot.history.length > 0) {
405 activeTerminal.write(snapshot.history);279 activeTerminal.write(snapshot.history);
406 }280 }
407 window.requestAnimationFrame(() => {281 if (autoFocus) {
408 activeTerminal.focus();282 window.requestAnimationFrame(() => {
409 });283 activeTerminal.focus();
284 });
285 }
410 } catch (err) {286 } catch (err) {
411 if (disposed) return;287 if (disposed) return;
412 writeSystemMessage(288 writeSystemMessage(
...@@ -417,7 +293,7 @@ export default function ThreadTerminalDrawer({...@@ -417,7 +293,7 @@ export default function ThreadTerminalDrawer({
417 };293 };
418294
419 const unsubscribe = api.terminal.onEvent((event) => {295 const unsubscribe = api.terminal.onEvent((event) => {
420 if (event.threadId !== threadId) return;296 if (event.threadId !== threadId || event.terminalId !== terminalId) return;
421 const activeTerminal = terminalRef.current;297 const activeTerminal = terminalRef.current;
422 if (!activeTerminal) return;298 if (!activeTerminal) return;
423299
...@@ -446,12 +322,37 @@ export default function ThreadTerminalDrawer({...@@ -446,12 +322,37 @@ export default function ThreadTerminalDrawer({
446 }322 }
447323
448 if (event.type === "exited") {324 if (event.type === "exited") {
449 onThreadExitedRef.current();325 const details = [
326 typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,
327 typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,
328 ]
329 .filter((value): value is string => value !== null)
330 .join(", ");
331 writeSystemMessage(
332 activeTerminal,
333 details.length > 0 ? `Process exited (${details})` : "Process exited",
334 );
450 }335 }
451 });336 });
452337
453 const fitTimer = window.setTimeout(() => {338 const fitTimer = window.setTimeout(() => {
454 fitAndResizeTerminal(true);339 const activeTerminal = terminalRef.current;
340 const activeFitAddon = fitAddonRef.current;
341 if (!activeTerminal || !activeFitAddon) return;
342 const wasAtBottom =
343 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
344 activeFitAddon.fit();
345 if (wasAtBottom) {
346 activeTerminal.scrollToBottom();
347 }
348 void api.terminal
349 .resize({
350 threadId,
351 terminalId,
352 cols: activeTerminal.cols,
353 rows: activeTerminal.rows,
354 })
355 .catch(() => undefined);
455 }, 30);356 }, 30);
456 void openTerminal();357 void openTerminal();
457358
...@@ -466,9 +367,10 @@ export default function ThreadTerminalDrawer({...@@ -466,9 +367,10 @@ export default function ThreadTerminalDrawer({
466 fitAddonRef.current = null;367 fitAddonRef.current = null;
467 terminal.dispose();368 terminal.dispose();
468 };369 };
469 }, [api, cwd, fitAndResizeTerminal, threadId]);370 }, [api, autoFocus, cwd, terminalId, threadId]);
470371
471 useEffect(() => {372 useEffect(() => {
373 if (!autoFocus) return;
472 const terminal = terminalRef.current;374 const terminal = terminalRef.current;
473 if (!terminal) return;375 if (!terminal) return;
474 const frame = window.requestAnimationFrame(() => {376 const frame = window.requestAnimationFrame(() => {
...@@ -477,7 +379,7 @@ export default function ThreadTerminalDrawer({...@@ -477,7 +379,7 @@ export default function ThreadTerminalDrawer({
477 return () => {379 return () => {
478 window.cancelAnimationFrame(frame);380 window.cancelAnimationFrame(frame);
479 };381 };
480 }, [focusRequestId]);382 }, [autoFocus, focusRequestId]);
481383
482 useEffect(() => {384 useEffect(() => {
483 const terminal = terminalRef.current;385 const terminal = terminalRef.current;
...@@ -492,6 +394,7 @@ export default function ThreadTerminalDrawer({...@@ -492,6 +394,7 @@ export default function ThreadTerminalDrawer({
492 void api.terminal394 void api.terminal
493 .resize({395 .resize({
494 threadId,396 threadId,
397 terminalId,
495 cols: terminal.cols,398 cols: terminal.cols,
496 rows: terminal.rows,399 rows: terminal.rows,
497 })400 })
...@@ -500,7 +403,176 @@ export default function ThreadTerminalDrawer({...@@ -500,7 +403,176 @@ export default function ThreadTerminalDrawer({
500 return () => {403 return () => {
501 window.cancelAnimationFrame(frame);404 window.cancelAnimationFrame(frame);
502 };405 };
503 }, [api, cwd, drawerHeight, threadId]);406 }, [api, drawerHeight, resizeEpoch, terminalId, threadId]);
407
408 return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;
409}
410
411interface ThreadTerminalDrawerProps {
412 api: NativeApi;
413 threadId: string;
414 cwd: string;
415 height: number;
416 terminalIds: string[];
417 activeTerminalId: string;
418 terminalLayout: ThreadTerminalLayout;
419 splitTerminalIds: string[];
420 focusRequestId: number;
421 onSplitTerminal: () => void;
422 onNewTerminal: () => void;
423 onActiveTerminalChange: (terminalId: string) => void;
424 onHeightChange: (height: number) => void;
425}
426
427export default function ThreadTerminalDrawer({
428 api,
429 threadId,
430 cwd,
431 height,
432 terminalIds,
433 activeTerminalId,
434 terminalLayout,
435 splitTerminalIds,
436 focusRequestId,
437 onSplitTerminal,
438 onNewTerminal,
439 onActiveTerminalChange,
440 onHeightChange,
441}: ThreadTerminalDrawerProps) {
442 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
443 const [resizeEpoch, setResizeEpoch] = useState(0);
444 const drawerHeightRef = useRef(drawerHeight);
445 const lastSyncedHeightRef = useRef(clampDrawerHeight(height));
446 const onHeightChangeRef = useRef(onHeightChange);
447 const resizeStateRef = useRef<{
448 pointerId: number;
449 startY: number;
450 startHeight: number;
451 } | null>(null);
452 const didResizeDuringDragRef = useRef(false);
453
454 const normalizedTerminalIds = useMemo(() => {
455 const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
456 return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID];
457 }, [terminalIds]);
458
459 const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId)
460 ? activeTerminalId
461 : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
462
463 const resolvedSplitTerminalIds = useMemo(() => {
464 if (terminalLayout !== "split") return [];
465 const validSplitIds = [...new Set(splitTerminalIds)].filter((id) =>
466 normalizedTerminalIds.includes(id),
467 );
468 if (validSplitIds.length >= 2) {
469 return validSplitIds.slice(0, 2);
470 }
471
472 const fallbackPartner = normalizedTerminalIds.find((id) => id !== resolvedActiveTerminalId);
473 if (!fallbackPartner) {
474 return [];
475 }
476 return [resolvedActiveTerminalId, fallbackPartner];
477 }, [normalizedTerminalIds, resolvedActiveTerminalId, splitTerminalIds, terminalLayout]);
478
479 const visibleTerminalIds =
480 terminalLayout === "split" && resolvedSplitTerminalIds.length === 2
481 ? resolvedSplitTerminalIds
482 : [resolvedActiveTerminalId];
483
484 const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1;
485
486 useEffect(() => {
487 onHeightChangeRef.current = onHeightChange;
488 }, [onHeightChange]);
489
490 useEffect(() => {
491 drawerHeightRef.current = drawerHeight;
492 }, [drawerHeight]);
493
494 const syncHeight = useCallback((nextHeight: number) => {
495 const clampedHeight = clampDrawerHeight(nextHeight);
496 if (lastSyncedHeightRef.current === clampedHeight) return;
497 lastSyncedHeightRef.current = clampedHeight;
498 onHeightChangeRef.current(clampedHeight);
499 }, []);
500
501 useEffect(() => {
502 const clampedHeight = clampDrawerHeight(height);
503 setDrawerHeight(clampedHeight);
504 drawerHeightRef.current = clampedHeight;
505 lastSyncedHeightRef.current = clampedHeight;
506 }, [height, threadId]);
507
508 const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
509 if (event.button !== 0) return;
510 event.preventDefault();
511 event.currentTarget.setPointerCapture(event.pointerId);
512 didResizeDuringDragRef.current = false;
513 resizeStateRef.current = {
514 pointerId: event.pointerId,
515 startY: event.clientY,
516 startHeight: drawerHeightRef.current,
517 };
518 }, []);
519
520 const handleResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
521 const resizeState = resizeStateRef.current;
522 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
523 event.preventDefault();
524 const clampedHeight = clampDrawerHeight(
525 resizeState.startHeight + (resizeState.startY - event.clientY),
526 );
527 if (clampedHeight === drawerHeightRef.current) {
528 return;
529 }
530 didResizeDuringDragRef.current = true;
531 drawerHeightRef.current = clampedHeight;
532 setDrawerHeight(clampedHeight);
533 }, []);
534
535 const handleResizePointerEnd = useCallback(
536 (event: ReactPointerEvent<HTMLDivElement>) => {
537 const resizeState = resizeStateRef.current;
538 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
539 resizeStateRef.current = null;
540 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
541 event.currentTarget.releasePointerCapture(event.pointerId);
542 }
543 if (!didResizeDuringDragRef.current) {
544 return;
545 }
546 syncHeight(drawerHeightRef.current);
547 setResizeEpoch((value) => value + 1);
548 },
549 [syncHeight],
550 );
551
552 useEffect(() => {
553 const onWindowResize = () => {
554 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
555 const changed = clampedHeight !== drawerHeightRef.current;
556 if (changed) {
557 setDrawerHeight(clampedHeight);
558 drawerHeightRef.current = clampedHeight;
559 }
560 if (!resizeStateRef.current) {
561 syncHeight(clampedHeight);
562 }
563 setResizeEpoch((value) => value + 1);
564 };
565 window.addEventListener("resize", onWindowResize);
566 return () => {
567 window.removeEventListener("resize", onWindowResize);
568 };
569 }, [syncHeight]);
570
571 useEffect(() => {
572 return () => {
573 syncHeight(drawerHeightRef.current);
574 };
575 }, [syncHeight]);
504576
505 return (577 return (
506 <aside578 <aside
...@@ -516,11 +588,83 @@ export default function ThreadTerminalDrawer({...@@ -516,11 +588,83 @@ export default function ThreadTerminalDrawer({
516 >588 >
517 <div className="h-px w-10 rounded-full bg-border/80" />589 <div className="h-px w-10 rounded-full bg-border/80" />
518 </div>590 </div>
591
592 <div className="flex items-center gap-2 px-2 pb-1">
593 {showTabBar ? (
594 <div className="min-w-0 flex-1 overflow-x-auto">
595 <div className="inline-flex rounded-md border border-border/80 bg-muted/40 p-0.5">
596 {normalizedTerminalIds.map((terminalId, index) => {
597 const isActive = terminalId === resolvedActiveTerminalId;
598 return (
599 <button
600 key={terminalId}
601 type="button"
602 className={`rounded px-2.5 py-1 text-xs transition-colors ${
603 isActive
604 ? "bg-background text-foreground shadow-sm"
605 : "text-muted-foreground hover:text-foreground"
606 }`}
607 onClick={() => onActiveTerminalChange(terminalId)}
608 >
609 {`Terminal ${index + 1}`}
610 </button>
611 );
612 })}
613 </div>
614 </div>
615 ) : (
616 <div className="flex-1" />
617 )}
618
619 <div className="inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-muted/30">
620 <button
621 type="button"
622 className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent"
623 onClick={onSplitTerminal}
624 >
625 Split Terminal
626 </button>
627 <div className="h-5 w-px bg-border/80" />
628 <button
629 type="button"
630 className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent"
631 onClick={onNewTerminal}
632 >
633 New Terminal
634 </button>
635 </div>
636 </div>
637
519 <div className="min-h-0 w-full flex-1 px-1.5 pb-1">638 <div className="min-h-0 w-full flex-1 px-1.5 pb-1">
520 <div639 {terminalLayout === "split" && visibleTerminalIds.length === 2 ? (
521 ref={containerRef}640 <div className="grid h-full w-full grid-cols-2 gap-1">
522 className="h-full w-full overflow-hidden rounded-[4px]"641 {visibleTerminalIds.map((terminalId) => (
523 />642 <TerminalViewport
643 key={terminalId}
644 api={api}
645 threadId={threadId}
646 terminalId={terminalId}
647 cwd={cwd}
648 focusRequestId={focusRequestId}
649 autoFocus={terminalId === resolvedActiveTerminalId}
650 resizeEpoch={resizeEpoch}
651 drawerHeight={drawerHeight}
652 />
653 ))}
654 </div>
655 ) : (
656 <TerminalViewport
657 key={resolvedActiveTerminalId}
658 api={api}
659 threadId={threadId}
660 terminalId={resolvedActiveTerminalId}
661 cwd={cwd}
662 focusRequestId={focusRequestId}
663 autoFocus
664 resizeEpoch={resizeEpoch}
665 drawerHeight={drawerHeight}
666 />
667 )}
524 </div>668 </div>
525 </aside>669 </aside>
526 );670 );
apps/web/src/persistenceSchema.test.ts+28-1
...@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";...@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
22
3import { DEFAULT_MODEL } from "./model-logic";3import { DEFAULT_MODEL } from "./model-logic";
4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
6import type { Thread } from "./types";6import type { Thread } from "./types";
77
8describe("hydratePersistedState", () => {8describe("hydratePersistedState", () => {
...@@ -50,6 +50,10 @@ describe("hydratePersistedState", () => {...@@ -50,6 +50,10 @@ describe("hydratePersistedState", () => {
50 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();50 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
51 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);51 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
52 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);52 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
53 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
54 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
55 expect(hydrated?.threads[0]?.terminalLayout).toBe("single");
56 expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]);
53 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);57 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
54 expect(hydrated?.runtimeMode).toBe("full-access");58 expect(hydrated?.runtimeMode).toBe("full-access");
55 });59 });
...@@ -92,6 +96,10 @@ describe("hydratePersistedState", () => {...@@ -92,6 +96,10 @@ describe("hydratePersistedState", () => {
92 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();96 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
93 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);97 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
94 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);98 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
99 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
100 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
101 expect(hydrated?.threads[0]?.terminalLayout).toBe("single");
102 expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]);
95 expect(hydrated?.activeThreadId).toBe("t-1");103 expect(hydrated?.activeThreadId).toBe("t-1");
96 expect(hydrated?.runtimeMode).toBe("full-access");104 expect(hydrated?.runtimeMode).toBe("full-access");
97 });105 });
...@@ -139,6 +147,10 @@ describe("hydratePersistedState", () => {...@@ -139,6 +147,10 @@ describe("hydratePersistedState", () => {
139 model: "gpt-5.3-codex",147 model: "gpt-5.3-codex",
140 terminalOpen: true,148 terminalOpen: true,
141 terminalHeight: 360,149 terminalHeight: 360,
150 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
151 activeTerminalId: "term-2",
152 terminalLayout: "tabs",
153 splitTerminalIds: [],
142 messages: [],154 messages: [],
143 createdAt: "2026-02-08T10:00:00.000Z",155 createdAt: "2026-02-08T10:00:00.000Z",
144 lastVisitedAt: "2026-02-08T10:01:00.000Z",156 lastVisitedAt: "2026-02-08T10:01:00.000Z",
...@@ -150,6 +162,10 @@ describe("hydratePersistedState", () => {...@@ -150,6 +162,10 @@ describe("hydratePersistedState", () => {
150 const hydrated = hydratePersistedState(payload, false);162 const hydrated = hydratePersistedState(payload, false);
151 expect(hydrated?.threads[0]?.terminalOpen).toBe(true);163 expect(hydrated?.threads[0]?.terminalOpen).toBe(true);
152 expect(hydrated?.threads[0]?.terminalHeight).toBe(360);164 expect(hydrated?.threads[0]?.terminalHeight).toBe(360);
165 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
166 expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2");
167 expect(hydrated?.threads[0]?.terminalLayout).toBe("tabs");
168 expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]);
153 expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z");169 expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z");
154 });170 });
155171
...@@ -183,6 +199,9 @@ describe("hydratePersistedState", () => {...@@ -183,6 +199,9 @@ describe("hydratePersistedState", () => {
183199
184 const hydrated = hydratePersistedState(payload, false);200 const hydrated = hydratePersistedState(payload, false);
185 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);201 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
202 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
203 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
204 expect(hydrated?.threads[0]?.terminalLayout).toBe("single");
186 });205 });
187});206});
188207
...@@ -196,6 +215,10 @@ describe("toPersistedState", () => {...@@ -196,6 +215,10 @@ describe("toPersistedState", () => {
196 model: "gpt-5.3-codex",215 model: "gpt-5.3-codex",
197 terminalOpen: true,216 terminalOpen: true,
198 terminalHeight: 320,217 terminalHeight: 320,
218 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
219 activeTerminalId: "term-2",
220 terminalLayout: "tabs",
221 splitTerminalIds: [],
199 session: null,222 session: null,
200 messages: [223 messages: [
201 {224 {
...@@ -249,6 +272,10 @@ describe("toPersistedState", () => {...@@ -249,6 +272,10 @@ describe("toPersistedState", () => {
249 model: "gpt-5.3-codex",272 model: "gpt-5.3-codex",
250 terminalOpen: true,273 terminalOpen: true,
251 terminalHeight: 320,274 terminalHeight: 320,
275 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
276 activeTerminalId: "term-2",
277 terminalLayout: "tabs",
278 splitTerminalIds: [],
252 messages: [279 messages: [
253 {280 {
254 id: "m-1",281 id: "m-1",
apps/web/src/persistenceSchema.ts+40
...@@ -3,6 +3,7 @@ import { z } from "zod";...@@ -3,6 +3,7 @@ import { z } from "zod";
3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import {4import {
5 DEFAULT_THREAD_TERMINAL_HEIGHT,5 DEFAULT_THREAD_TERMINAL_HEIGHT,
6 DEFAULT_THREAD_TERMINAL_ID,
6 DEFAULT_RUNTIME_MODE,7 DEFAULT_RUNTIME_MODE,
7 type Project,8 type Project,
8 type RuntimeMode,9 type RuntimeMode,
...@@ -48,6 +49,10 @@ const persistedThreadSchema = z.object({...@@ -48,6 +49,10 @@ const persistedThreadSchema = z.object({
48 terminalHeight: z.number().int().min(120).max(4_096).default(49 terminalHeight: z.number().int().min(120).max(4_096).default(
49 DEFAULT_THREAD_TERMINAL_HEIGHT,50 DEFAULT_THREAD_TERMINAL_HEIGHT,
50 ),51 ),
52 terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]),
53 activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID),
54 terminalLayout: z.enum(["single", "split", "tabs"]).default("single"),
55 splitTerminalIds: z.array(z.string().trim().min(1)).default([]),
51 messages: z.array(persistedMessageSchema),56 messages: z.array(persistedMessageSchema),
52 createdAt: z.string().min(1),57 createdAt: z.string().min(1),
53 lastVisitedAt: z.string().min(1).optional(),58 lastVisitedAt: z.string().min(1).optional(),
...@@ -129,6 +134,33 @@ function hydrateThread(...@@ -129,6 +134,33 @@ function hydrateThread(
129 thread: z.infer<typeof persistedThreadSchema>,134 thread: z.infer<typeof persistedThreadSchema>,
130 isLegacyPayload: boolean,135 isLegacyPayload: boolean,
131): Thread {136): Thread {
137 const terminalIds = [...new Set(thread.terminalIds)]
138 .map((id) => id.trim())
139 .filter((id) => id.length > 0);
140 const safeTerminalIds =
141 terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
142 const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId)
143 ? thread.activeTerminalId
144 : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
145 let terminalLayout = thread.terminalLayout;
146 let splitTerminalIds = [...new Set(thread.splitTerminalIds)].filter((id) =>
147 safeTerminalIds.includes(id),
148 );
149 if (terminalLayout === "split") {
150 splitTerminalIds = splitTerminalIds.slice(0, 2);
151 if (splitTerminalIds.length < 2) {
152 terminalLayout = safeTerminalIds.length > 1 ? "tabs" : "single";
153 splitTerminalIds = [];
154 }
155 } else {
156 splitTerminalIds = [];
157 }
158 if (terminalLayout === "single" && safeTerminalIds.length > 1) {
159 terminalLayout = "tabs";
160 } else if (terminalLayout === "tabs" && safeTerminalIds.length < 2) {
161 terminalLayout = "single";
162 }
163
132 return {164 return {
133 id: thread.id,165 id: thread.id,
134 codexThreadId: thread.codexThreadId,166 codexThreadId: thread.codexThreadId,
...@@ -137,6 +169,10 @@ function hydrateThread(...@@ -137,6 +169,10 @@ function hydrateThread(
137 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),169 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
138 terminalOpen: thread.terminalOpen ?? false,170 terminalOpen: thread.terminalOpen ?? false,
139 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,171 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
172 terminalIds: safeTerminalIds,
173 activeTerminalId,
174 terminalLayout,
175 splitTerminalIds,
140 session: null,176 session: null,
141 messages: thread.messages.map((message) => {177 messages: thread.messages.map((message) => {
142 const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment }));178 const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment }));
...@@ -211,6 +247,10 @@ export function toPersistedState(...@@ -211,6 +247,10 @@ export function toPersistedState(
211 model: thread.model,247 model: thread.model,
212 terminalOpen: thread.terminalOpen,248 terminalOpen: thread.terminalOpen,
213 terminalHeight: thread.terminalHeight,249 terminalHeight: thread.terminalHeight,
250 terminalIds: thread.terminalIds,
251 activeTerminalId: thread.activeTerminalId,
252 terminalLayout: thread.terminalLayout,
253 splitTerminalIds: thread.splitTerminalIds,
214 messages: thread.messages.map((message) => ({254 messages: thread.messages.map((message) => ({
215 id: message.id,255 id: message.id,
216 role: message.role,256 role: message.role,
apps/web/src/store.test.ts+50-1
...@@ -2,7 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";...@@ -2,7 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { type AppState, reducer } from "./store";4import { type AppState, reducer } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
6import type { Thread } from "./types";6import type { Thread } from "./types";
77
8function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {8function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
...@@ -37,6 +37,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -37,6 +37,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
37 model: "gpt-5.3-codex",37 model: "gpt-5.3-codex",
38 terminalOpen: false,38 terminalOpen: false,
39 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,39 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
40 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
41 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
42 terminalLayout: "single",
43 splitTerminalIds: [],
40 session: makeSession(),44 session: makeSession(),
41 messages: [],45 messages: [],
42 events: [],46 events: [],
...@@ -111,6 +115,51 @@ describe("store reducer thread continuity", () => {...@@ -111,6 +115,51 @@ describe("store reducer thread continuity", () => {
111 expect(next.threads[0]?.terminalHeight).toBe(360);115 expect(next.threads[0]?.terminalHeight).toBe(360);
112 });116 });
113117
118 it("splits the active terminal into side-by-side mode", () => {
119 const state = makeState(makeThread());
120 const next = reducer(state, {
121 type: "SPLIT_THREAD_TERMINAL",
122 threadId: "thread-local-1",
123 terminalId: "term-2",
124 });
125
126 expect(next.threads[0]?.terminalLayout).toBe("split");
127 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
128 expect(next.threads[0]?.splitTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
129 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
130 });
131
132 it("creates a new full-width terminal and switches to tab mode", () => {
133 const state = makeState(makeThread());
134 const next = reducer(state, {
135 type: "NEW_THREAD_TERMINAL",
136 threadId: "thread-local-1",
137 terminalId: "term-2",
138 });
139
140 expect(next.threads[0]?.terminalLayout).toBe("tabs");
141 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
142 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
143 expect(next.threads[0]?.splitTerminalIds).toEqual([]);
144 });
145
146 it("switches the active tab terminal", () => {
147 const state = makeState(
148 makeThread({
149 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
150 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
151 terminalLayout: "tabs",
152 }),
153 );
154 const next = reducer(state, {
155 type: "SET_THREAD_ACTIVE_TERMINAL",
156 threadId: "thread-local-1",
157 terminalId: "term-2",
158 });
159
160 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
161 });
162
114 it("backfills codexThreadId from routed provider events", () => {163 it("backfills codexThreadId from routed provider events", () => {
115 const state = makeState(makeThread({ codexThreadId: null }));164 const state = makeState(makeThread({ codexThreadId: null }));
116 const next = reducer(state, {165 const next = reducer(state, {
apps/web/src/store.ts+127-11
...@@ -14,6 +14,7 @@ import { hydratePersistedState, toPersistedState } from "./persistenceSchema";...@@ -14,6 +14,7 @@ import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
15import {15import {
16 type ChatAttachment,16 type ChatAttachment,
17 DEFAULT_THREAD_TERMINAL_ID,
17 DEFAULT_RUNTIME_MODE,18 DEFAULT_RUNTIME_MODE,
18 type Project,19 type Project,
19 type RuntimeMode,20 type RuntimeMode,
...@@ -31,6 +32,9 @@ type Action =...@@ -31,6 +32,9 @@ type Action =
31 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }32 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
32 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }33 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
33 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }34 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
35 | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }
36 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
37 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
34 | { type: "TOGGLE_DIFF" }38 | { type: "TOGGLE_DIFF" }
35 | {39 | {
36 type: "APPLY_EVENT";40 type: "APPLY_EVENT";
...@@ -133,6 +137,58 @@ function updateThread(...@@ -133,6 +137,58 @@ function updateThread(
133 return threads.map((t) => (t.id === threadId ? updater(t) : t));137 return threads.map((t) => (t.id === threadId ? updater(t) : t));
134}138}
135139
140function normalizeTerminalIds(terminalIds: string[]): string[] {
141 const ids = terminalIds
142 .map((id) => id.trim())
143 .filter((id) => id.length > 0);
144 const unique = [...new Set(ids)];
145 if (unique.length > 0) {
146 return unique;
147 }
148 return [DEFAULT_THREAD_TERMINAL_ID];
149}
150
151function normalizeThreadTerminals(thread: Thread): Thread {
152 const terminalIds = normalizeTerminalIds(thread.terminalIds);
153 const activeTerminalId = terminalIds.includes(thread.activeTerminalId)
154 ? thread.activeTerminalId
155 : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
156
157 if (thread.terminalLayout === "split") {
158 const validSplitIds = [...new Set(thread.splitTerminalIds)].filter((id) =>
159 terminalIds.includes(id),
160 );
161 if (validSplitIds.length >= 2) {
162 const splitTerminalIds = validSplitIds.slice(0, 2);
163 const splitActiveTerminalId = splitTerminalIds.includes(activeTerminalId)
164 ? activeTerminalId
165 : (splitTerminalIds[1] ?? splitTerminalIds[0] ?? activeTerminalId);
166 return {
167 ...thread,
168 terminalIds,
169 activeTerminalId: splitActiveTerminalId,
170 terminalLayout: "split",
171 splitTerminalIds,
172 };
173 }
174 }
175
176 const terminalLayout =
177 terminalIds.length > 1 && thread.terminalLayout === "single"
178 ? "tabs"
179 : (thread.terminalLayout === "tabs" && terminalIds.length < 2
180 ? "single"
181 : thread.terminalLayout);
182
183 return {
184 ...thread,
185 terminalIds,
186 activeTerminalId,
187 terminalLayout,
188 splitTerminalIds: [],
189 };
190}
191
136function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {192function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
137 return threads.find((t) => t.session?.sessionId === sessionId);193 return threads.find((t) => t.session?.sessionId === sessionId);
138}194}
...@@ -259,10 +315,10 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -259,10 +315,10 @@ export function reducer(state: AppState, action: Action): AppState {
259 if (!previousProject) return null;315 if (!previousProject) return null;
260 const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd);316 const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd);
261 if (!mappedProjectId) return null;317 if (!mappedProjectId) return null;
262 return {318 return normalizeThreadTerminals({
263 ...thread,319 ...thread,
264 projectId: mappedProjectId,320 projectId: mappedProjectId,
265 };321 });
266 })322 })
267 .filter((thread): thread is Thread => thread !== null);323 .filter((thread): thread is Thread => thread !== null);
268 const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)324 const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)
...@@ -285,19 +341,18 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -285,19 +341,18 @@ export function reducer(state: AppState, action: Action): AppState {
285 ),341 ),
286 };342 };
287343
288 case "ADD_THREAD":344 case "ADD_THREAD": {
345 const nextThread = normalizeThreadTerminals({
346 ...action.thread,
347 model: resolveModelSlug(action.thread.model),
348 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
349 });
289 return {350 return {
290 ...state,351 ...state,
291 threads: [352 threads: [...state.threads, nextThread],
292 ...state.threads,
293 {
294 ...action.thread,
295 model: resolveModelSlug(action.thread.model),
296 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
297 },
298 ],
299 activeThreadId: action.thread.id,353 activeThreadId: action.thread.id,
300 };354 };
355 }
301356
302 case "SET_ACTIVE_THREAD": {357 case "SET_ACTIVE_THREAD": {
303 const visitedAt = new Date().toISOString();358 const visitedAt = new Date().toISOString();
...@@ -338,6 +393,67 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -338,6 +393,67 @@ export function reducer(state: AppState, action: Action): AppState {
338 })),393 })),
339 };394 };
340395
396 case "SPLIT_THREAD_TERMINAL":
397 return {
398 ...state,
399 threads: updateThread(state.threads, action.threadId, (thread) => {
400 const terminalIds = thread.terminalIds.includes(action.terminalId)
401 ? thread.terminalIds
402 : [...thread.terminalIds, action.terminalId];
403 const firstTerminalId =
404 terminalIds.includes(thread.activeTerminalId)
405 ? thread.activeTerminalId
406 : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
407 const secondTerminalId =
408 action.terminalId === firstTerminalId
409 ? (terminalIds.find((id) => id !== firstTerminalId) ?? firstTerminalId)
410 : action.terminalId;
411 const splitTerminalIds =
412 firstTerminalId === secondTerminalId
413 ? []
414 : [firstTerminalId, secondTerminalId];
415
416 return normalizeThreadTerminals({
417 ...thread,
418 terminalIds,
419 activeTerminalId: secondTerminalId,
420 terminalLayout: splitTerminalIds.length === 2 ? "split" : "single",
421 splitTerminalIds,
422 });
423 }),
424 };
425
426 case "NEW_THREAD_TERMINAL":
427 return {
428 ...state,
429 threads: updateThread(state.threads, action.threadId, (thread) => {
430 const terminalIds = thread.terminalIds.includes(action.terminalId)
431 ? thread.terminalIds
432 : [...thread.terminalIds, action.terminalId];
433 return normalizeThreadTerminals({
434 ...thread,
435 terminalIds,
436 activeTerminalId: action.terminalId,
437 terminalLayout: "tabs",
438 splitTerminalIds: [],
439 });
440 }),
441 };
442
443 case "SET_THREAD_ACTIVE_TERMINAL":
444 return {
445 ...state,
446 threads: updateThread(state.threads, action.threadId, (thread) => {
447 if (!thread.terminalIds.includes(action.terminalId)) {
448 return thread;
449 }
450 return normalizeThreadTerminals({
451 ...thread,
452 activeTerminalId: action.terminalId,
453 });
454 }),
455 };
456
341 case "TOGGLE_DIFF":457 case "TOGGLE_DIFF":
342 return { ...state, diffOpen: !state.diffOpen };458 return { ...state, diffOpen: !state.diffOpen };
343459
apps/web/src/types.ts+6
...@@ -4,6 +4,8 @@ export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";...@@ -4,6 +4,8 @@ export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
4export type RuntimeMode = "approval-required" | "full-access";4export type RuntimeMode = "approval-required" | "full-access";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
7export const DEFAULT_THREAD_TERMINAL_ID = "default";
8export type ThreadTerminalLayout = "single" | "split" | "tabs";
79
8export interface ChatImageAttachment {10export interface ChatImageAttachment {
9 type: "image";11 type: "image";
...@@ -41,6 +43,10 @@ export interface Thread {...@@ -41,6 +43,10 @@ export interface Thread {
41 model: string;43 model: string;
42 terminalOpen: boolean;44 terminalOpen: boolean;
43 terminalHeight: number;45 terminalHeight: number;
46 terminalIds: string[];
47 activeTerminalId: string;
48 terminalLayout: ThreadTerminalLayout;
49 splitTerminalIds: string[];
44 session: ProviderSession | null;50 session: ProviderSession | null;
45 messages: ChatMessage[];51 messages: ChatMessage[];
46 events: ProviderEvent[];52 events: ProviderEvent[];
packages/contracts/src/ipc.ts+2-2
...@@ -30,12 +30,12 @@ import type {...@@ -30,12 +30,12 @@ import type {
30 ProjectRemoveInput,30 ProjectRemoveInput,
31} from "./project";31} from "./project";
32import type {32import type {
33 TerminalClearInput,
33 TerminalCloseInput,34 TerminalCloseInput,
34 TerminalEvent,35 TerminalEvent,
35 TerminalOpenInput,36 TerminalOpenInput,
36 TerminalResizeInput,37 TerminalResizeInput,
37 TerminalSessionSnapshot,38 TerminalSessionSnapshot,
38 TerminalThreadInput,
39 TerminalWriteInput,39 TerminalWriteInput,
40} from "./terminal";40} from "./terminal";
41import type { NewTodoInput, Todo } from "./todo";41import type { NewTodoInput, Todo } from "./todo";
...@@ -61,7 +61,7 @@ export interface NativeApi {...@@ -61,7 +61,7 @@ export interface NativeApi {
61 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;61 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
62 write: (input: TerminalWriteInput) => Promise<void>;62 write: (input: TerminalWriteInput) => Promise<void>;
63 resize: (input: TerminalResizeInput) => Promise<void>;63 resize: (input: TerminalResizeInput) => Promise<void>;
64 clear: (input: TerminalThreadInput) => Promise<void>;64 clear: (input: TerminalClearInput) => Promise<void>;
65 restart: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;65 restart: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
66 close: (input: TerminalCloseInput) => Promise<void>;66 close: (input: TerminalCloseInput) => Promise<void>;
67 onEvent: (callback: (event: TerminalEvent) => void) => () => void;67 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
packages/contracts/src/terminal.test.ts+24
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 DEFAULT_TERMINAL_ID,
5 terminalClearInputSchema,
4 terminalCloseInputSchema,6 terminalCloseInputSchema,
5 terminalEventSchema,7 terminalEventSchema,
6 terminalOpenInputSchema,8 terminalOpenInputSchema,
...@@ -30,6 +32,16 @@ describe("terminalOpenInputSchema", () => {...@@ -30,6 +32,16 @@ describe("terminalOpenInputSchema", () => {
30 });32 });
31 expect(result.success).toBe(false);33 expect(result.success).toBe(false);
32 });34 });
35
36 it("defaults terminalId when missing", () => {
37 const parsed = terminalOpenInputSchema.parse({
38 threadId: "thread-1",
39 cwd: "/tmp/project",
40 cols: 100,
41 rows: 24,
42 });
43 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
44 });
33});45});
3446
35describe("terminalWriteInputSchema", () => {47describe("terminalWriteInputSchema", () => {
...@@ -68,6 +80,15 @@ describe("terminalResizeInputSchema", () => {...@@ -68,6 +80,15 @@ describe("terminalResizeInputSchema", () => {
68 });80 });
69});81});
7082
83describe("terminalClearInputSchema", () => {
84 it("defaults terminal id", () => {
85 const parsed = terminalClearInputSchema.parse({
86 threadId: "thread-1",
87 });
88 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
89 });
90});
91
71describe("terminalCloseInputSchema", () => {92describe("terminalCloseInputSchema", () => {
72 it("accepts optional deleteHistory", () => {93 it("accepts optional deleteHistory", () => {
73 const result = terminalCloseInputSchema.safeParse({94 const result = terminalCloseInputSchema.safeParse({
...@@ -82,6 +103,7 @@ describe("terminalSessionSnapshotSchema", () => {...@@ -82,6 +103,7 @@ describe("terminalSessionSnapshotSchema", () => {
82 it("accepts running snapshots", () => {103 it("accepts running snapshots", () => {
83 const result = terminalSessionSnapshotSchema.safeParse({104 const result = terminalSessionSnapshotSchema.safeParse({
84 threadId: "thread-1",105 threadId: "thread-1",
106 terminalId: DEFAULT_TERMINAL_ID,
85 cwd: "/tmp/project",107 cwd: "/tmp/project",
86 status: "running",108 status: "running",
87 pid: 1234,109 pid: 1234,
...@@ -99,6 +121,7 @@ describe("terminalEventSchema", () => {...@@ -99,6 +121,7 @@ describe("terminalEventSchema", () => {
99 const result = terminalEventSchema.safeParse({121 const result = terminalEventSchema.safeParse({
100 type: "output",122 type: "output",
101 threadId: "thread-1",123 threadId: "thread-1",
124 terminalId: DEFAULT_TERMINAL_ID,
102 createdAt: new Date().toISOString(),125 createdAt: new Date().toISOString(),
103 data: "line\n",126 data: "line\n",
104 });127 });
...@@ -109,6 +132,7 @@ describe("terminalEventSchema", () => {...@@ -109,6 +132,7 @@ describe("terminalEventSchema", () => {
109 const result = terminalEventSchema.safeParse({132 const result = terminalEventSchema.safeParse({
110 type: "exited",133 type: "exited",
111 threadId: "thread-1",134 threadId: "thread-1",
135 terminalId: DEFAULT_TERMINAL_ID,
112 createdAt: new Date().toISOString(),136 createdAt: new Date().toISOString(),
113 exitCode: 0,137 exitCode: 0,
114 exitSignal: null,138 exitSignal: null,
packages/contracts/src/terminal.ts+17-3
...@@ -1,28 +1,38 @@...@@ -1,28 +1,38 @@
1import { z } from "zod";1import { z } from "zod";
22
3export const DEFAULT_TERMINAL_ID = "default";
4
3const terminalColsSchema = z.number().int().min(20).max(400);5const terminalColsSchema = z.number().int().min(20).max(400);
4const terminalRowsSchema = z.number().int().min(5).max(200);6const terminalRowsSchema = z.number().int().min(5).max(200);
7const terminalIdSchema = z.string().trim().min(1).max(128);
58
6export const terminalThreadInputSchema = z.object({9export const terminalThreadInputSchema = z.object({
7 threadId: z.string().trim().min(1),10 threadId: z.string().trim().min(1),
8});11});
912
10export const terminalOpenInputSchema = terminalThreadInputSchema.extend({13export const terminalSessionInputSchema = terminalThreadInputSchema.extend({
14 terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID),
15});
16
17export const terminalOpenInputSchema = terminalSessionInputSchema.extend({
11 cwd: z.string().trim().min(1),18 cwd: z.string().trim().min(1),
12 cols: terminalColsSchema,19 cols: terminalColsSchema,
13 rows: terminalRowsSchema,20 rows: terminalRowsSchema,
14});21});
1522
16export const terminalWriteInputSchema = terminalThreadInputSchema.extend({23export const terminalWriteInputSchema = terminalSessionInputSchema.extend({
17 data: z.string().min(1).max(65_536),24 data: z.string().min(1).max(65_536),
18});25});
1926
20export const terminalResizeInputSchema = terminalThreadInputSchema.extend({27export const terminalResizeInputSchema = terminalSessionInputSchema.extend({
21 cols: terminalColsSchema,28 cols: terminalColsSchema,
22 rows: terminalRowsSchema,29 rows: terminalRowsSchema,
23});30});
2431
32export const terminalClearInputSchema = terminalSessionInputSchema;
33
25export const terminalCloseInputSchema = terminalThreadInputSchema.extend({34export const terminalCloseInputSchema = terminalThreadInputSchema.extend({
35 terminalId: terminalIdSchema.optional(),
26 deleteHistory: z.boolean().optional(),36 deleteHistory: z.boolean().optional(),
27});37});
2838
...@@ -35,6 +45,7 @@ export const terminalSessionStatusSchema = z.enum([...@@ -35,6 +45,7 @@ export const terminalSessionStatusSchema = z.enum([
3545
36export const terminalSessionSnapshotSchema = z.object({46export const terminalSessionSnapshotSchema = z.object({
37 threadId: z.string().min(1),47 threadId: z.string().min(1),
48 terminalId: z.string().min(1),
38 cwd: z.string().min(1),49 cwd: z.string().min(1),
39 status: terminalSessionStatusSchema,50 status: terminalSessionStatusSchema,
40 pid: z.number().int().positive().nullable(),51 pid: z.number().int().positive().nullable(),
...@@ -46,6 +57,7 @@ export const terminalSessionSnapshotSchema = z.object({...@@ -46,6 +57,7 @@ export const terminalSessionSnapshotSchema = z.object({
4657
47const terminalEventBaseSchema = z.object({58const terminalEventBaseSchema = z.object({
48 threadId: z.string().min(1),59 threadId: z.string().min(1),
60 terminalId: z.string().min(1),
49 createdAt: z.string().datetime(),61 createdAt: z.string().datetime(),
50});62});
5163
...@@ -89,9 +101,11 @@ export const terminalEventSchema = z.discriminatedUnion("type", [...@@ -89,9 +101,11 @@ export const terminalEventSchema = z.discriminatedUnion("type", [
89]);101]);
90102
91export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>;103export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>;
104export type TerminalSessionInput = z.input<typeof terminalSessionInputSchema>;
92export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>;105export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>;
93export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>;106export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>;
94export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>;107export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>;
108export type TerminalClearInput = z.input<typeof terminalClearInputSchema>;
95export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>;109export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>;
96export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>;110export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>;
97export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>;111export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>;