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";
22import os from "node:os";
33import path from "node:path";
44
5import type {
6 TerminalEvent,
7 TerminalOpenInput,
8} from "@t3tools/contracts";
5import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput } from "@t3tools/contracts";
96import { afterEach, describe, expect, it } from "vitest";
107
118import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
......@@ -109,10 +106,26 @@ function historyLogName(threadId: string): string {
109106 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;
110107}
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
112117function historyLogPath(logsDir: string, threadId = "thread-1"): string {
113118 return path.join(logsDir, historyLogName(threadId));
114119}
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
116129describe("TerminalManager", () => {
117130 const tempDirs: string[] = [];
118131
......@@ -144,6 +157,7 @@ describe("TerminalManager", () => {
144157 const third = await manager.open(openInput());
145158
146159 expect(first.threadId).toBe("thread-1");
160 expect(first.terminalId).toBe("default");
147161 expect(second.threadId).toBe("thread-1");
148162 expect(third.threadId).toBe("thread-1");
149163 expect(ptyAdapter.spawnInputs).toHaveLength(1);
......@@ -167,6 +181,27 @@ describe("TerminalManager", () => {
167181 manager.dispose();
168182 });
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
170205 it("clears transcript and emits cleared event", async () => {
171206 const { manager, ptyAdapter, logsDir } = makeManager();
172207 const events: TerminalEvent[] = [];
......@@ -184,6 +219,14 @@ describe("TerminalManager", () => {
184219 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
185220
186221 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
188231 manager.dispose();
189232 });
......@@ -262,6 +305,35 @@ describe("TerminalManager", () => {
262305 manager.dispose();
263306 });
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
265337 it("loads existing legacy transcript filenames and keeps new naming for writes", async () => {
266338 const { manager, logsDir } = makeManager();
267339 const legacyPath = path.join(logsDir, "thread-1.log");
apps/server/src/terminalManager.ts+184-70
......@@ -3,17 +3,19 @@ import fs from "node:fs";
33import path from "node:path";
44
55import {
6 DEFAULT_TERMINAL_ID,
7 type TerminalClearInput,
68 type TerminalCloseInput,
79 type TerminalEvent,
810 type TerminalOpenInput,
11 type TerminalResizeInput,
912 type TerminalSessionSnapshot,
1013 type TerminalSessionStatus,
11 type TerminalThreadInput,
1214 type TerminalWriteInput,
15 terminalClearInputSchema,
1316 terminalCloseInputSchema,
1417 terminalOpenInputSchema,
1518 terminalResizeInputSchema,
16 terminalThreadInputSchema,
1719 terminalWriteInputSchema,
1820} from "@t3tools/contracts";
1921
......@@ -36,6 +38,7 @@ export interface TerminalManagerOptions {
3638
3739interface TerminalSessionState {
3840 threadId: string;
41 terminalId: string;
3942 cwd: string;
4043 status: TerminalSessionStatus;
4144 pid: number | null;
......@@ -139,6 +142,14 @@ function toSafeThreadId(threadId: string): string {
139142 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
140143}
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
142153export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
143154 private readonly sessions = new Map<string, TerminalSessionState>();
144155 private readonly logsDir: string;
......@@ -167,12 +178,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
167178 return this.runWithThreadLock(input.threadId, async () => {
168179 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);
171183 if (!existing) {
172 await this.flushPersistQueue(input.threadId);
173 const history = await this.readHistory(input.threadId);
184 await this.flushPersistQueue(input.threadId, input.terminalId);
185 const history = await this.readHistory(input.threadId, input.terminalId);
174186 const session: TerminalSessionState = {
175187 threadId: input.threadId,
188 terminalId: input.terminalId,
176189 cwd: input.cwd,
177190 status: "starting",
178191 pid: null,
......@@ -186,7 +199,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
186199 unsubscribeData: null,
187200 unsubscribeExit: null,
188201 };
189 this.sessions.set(input.threadId, session);
202 this.sessions.set(sessionKey, session);
190203 this.startSession(session, input, "started");
191204 return this.snapshot(session);
192205 }
......@@ -195,10 +208,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
195208 this.stopProcess(existing);
196209 existing.cwd = input.cwd;
197210 existing.history = "";
198 await this.persistHistory(existing.threadId, existing.history);
211 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
199212 } else if (existing.status === "exited" || existing.status === "error") {
200213 existing.history = "";
201 await this.persistHistory(existing.threadId, existing.history);
214 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
202215 }
203216
204217 if (!existing.process) {
......@@ -219,18 +232,22 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
219232
220233 async write(raw: TerminalWriteInput): Promise<void> {
221234 const input = terminalWriteInputSchema.parse(raw);
222 const session = this.requireSession(input.threadId);
235 const session = this.requireSession(input.threadId, input.terminalId);
223236 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 );
225240 }
226241 session.process.write(input.data);
227242 }
228243
229 async resize(raw: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {
244 async resize(raw: TerminalResizeInput): Promise<void> {
230245 const input = terminalResizeInputSchema.parse(raw);
231 const session = this.requireSession(input.threadId);
246 const session = this.requireSession(input.threadId, input.terminalId);
232247 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 );
234251 }
235252 session.cols = input.cols;
236253 session.rows = input.rows;
......@@ -238,16 +255,17 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
238255 session.process.resize(input.cols, input.rows);
239256 }
240257
241 async clear(raw: TerminalThreadInput): Promise<void> {
242 const input = terminalThreadInputSchema.parse(raw);
258 async clear(raw: TerminalClearInput): Promise<void> {
259 const input = terminalClearInputSchema.parse(raw);
243260 await this.runWithThreadLock(input.threadId, async () => {
244 const session = this.requireSession(input.threadId);
261 const session = this.requireSession(input.threadId, input.terminalId);
245262 session.history = "";
246263 session.updatedAt = new Date().toISOString();
247 await this.persistHistory(input.threadId, session.history);
264 await this.persistHistory(input.threadId, input.terminalId, session.history);
248265 this.emitEvent({
249266 type: "cleared",
250267 threadId: input.threadId,
268 terminalId: input.terminalId,
251269 createdAt: new Date().toISOString(),
252270 });
253271 });
......@@ -258,10 +276,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
258276 return this.runWithThreadLock(input.threadId, async () => {
259277 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);
262281 if (!session) {
263282 session = {
264283 threadId: input.threadId,
284 terminalId: input.terminalId,
265285 cwd: input.cwd,
266286 status: "starting",
267287 pid: null,
......@@ -275,14 +295,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
275295 unsubscribeData: null,
276296 unsubscribeExit: null,
277297 };
278 this.sessions.set(input.threadId, session);
298 this.sessions.set(sessionKey, session);
279299 } else {
280300 this.stopProcess(session);
281301 session.cwd = input.cwd;
282302 }
283303
284304 session.history = "";
285 await this.persistHistory(input.threadId, session.history);
305 await this.persistHistory(input.threadId, input.terminalId, session.history);
286306 this.startSession(session, input, "restarted");
287307 return this.snapshot(session);
288308 });
......@@ -291,14 +311,28 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
291311 async close(raw: TerminalCloseInput): Promise<void> {
292312 const input = terminalCloseInputSchema.parse(raw);
293313 await this.runWithThreadLock(input.threadId, async () => {
294 const session = this.sessions.get(input.threadId);
295 if (session) {
314 if (input.terminalId) {
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) {
296325 this.stopProcess(session);
297 this.sessions.delete(input.threadId);
326 this.sessions.delete(toSessionKey(session.threadId, session.terminalId));
298327 }
299 await this.flushPersistQueue(input.threadId);
328 await Promise.all(
329 threadSessions.map((session) =>
330 this.flushPersistQueue(session.threadId, session.terminalId),
331 ),
332 );
333
300334 if (input.deleteHistory) {
301 await this.deleteHistory(input.threadId);
335 await this.deleteAllHistoryForThread(input.threadId);
302336 }
303337 });
304338 }
......@@ -382,6 +416,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
382416 this.emitEvent({
383417 type: eventType,
384418 threadId: session.threadId,
419 terminalId: session.terminalId,
385420 createdAt: new Date().toISOString(),
386421 snapshot: this.snapshot(session),
387422 });
......@@ -401,11 +436,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
401436 this.emitEvent({
402437 type: "error",
403438 threadId: session.threadId,
439 terminalId: session.terminalId,
404440 createdAt: new Date().toISOString(),
405441 message,
406442 });
407443 this.logger.error("failed to start terminal", {
408444 threadId: session.threadId,
445 terminalId: session.terminalId,
409446 error: message,
410447 ...(startedShell ? { shell: startedShell } : {}),
411448 });
......@@ -415,10 +452,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
415452 private onProcessData(session: TerminalSessionState, data: string): void {
416453 session.history = capHistory(`${session.history}${data}`, this.historyLineLimit);
417454 session.updatedAt = new Date().toISOString();
418 this.queuePersist(session.threadId, session.history);
455 this.queuePersist(session.threadId, session.terminalId, session.history);
419456 this.emitEvent({
420457 type: "output",
421458 threadId: session.threadId,
459 terminalId: session.terminalId,
422460 createdAt: new Date().toISOString(),
423461 data,
424462 });
......@@ -435,6 +473,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
435473 this.emitEvent({
436474 type: "exited",
437475 threadId: session.threadId,
476 terminalId: session.terminalId,
438477 createdAt: new Date().toISOString(),
439478 exitCode: session.exitCode,
440479 exitSignal: session.exitSignal,
......@@ -455,6 +494,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
455494 const message = error instanceof Error ? error.message : String(error);
456495 this.logger.warn("failed to kill terminal process", {
457496 threadId: session.threadId,
497 terminalId: session.terminalId,
458498 error: message,
459499 });
460500 }
......@@ -467,69 +507,87 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
467507 session.unsubscribeExit = null;
468508 }
469509
470 private queuePersist(threadId: string, history: string): void {
471 this.pendingPersistHistory.set(threadId, history);
472 this.schedulePersist(threadId);
510 private queuePersist(threadId: string, terminalId: string, history: string): void {
511 const persistenceKey = toSessionKey(threadId, terminalId);
512 this.pendingPersistHistory.set(persistenceKey, history);
513 this.schedulePersist(threadId, terminalId);
473514 }
474515
475 private async persistHistory(threadId: string, history: string): Promise<void> {
476 this.clearPersistTimer(threadId);
477 this.pendingPersistHistory.delete(threadId);
478 await this.enqueuePersistWrite(threadId, history);
516 private async persistHistory(
517 threadId: string,
518 terminalId: string,
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);
479525 }
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);
482533 const task = async () => {
483 await fs.promises.writeFile(this.historyPath(threadId), history, "utf8");
534 await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8");
484535 };
485 const previous = this.persistQueues.get(threadId) ?? Promise.resolve();
536 const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve();
486537 const next = previous
487538 .catch(() => undefined)
488539 .then(task)
489540 .catch((error) => {
490541 this.logger.warn("failed to persist terminal history", {
491542 threadId,
543 terminalId,
492544 error: error instanceof Error ? error.message : String(error),
493545 });
494546 });
495 this.persistQueues.set(threadId, next);
547 this.persistQueues.set(persistenceKey, next);
496548 const finalized = next.finally(() => {
497 if (this.persistQueues.get(threadId) === next) {
498 this.persistQueues.delete(threadId);
549 if (this.persistQueues.get(persistenceKey) === next) {
550 this.persistQueues.delete(persistenceKey);
499551 }
500 if (this.pendingPersistHistory.has(threadId) && !this.persistTimers.has(threadId)) {
501 this.schedulePersist(threadId);
552 if (
553 this.pendingPersistHistory.has(persistenceKey) &&
554 !this.persistTimers.has(persistenceKey)
555 ) {
556 this.schedulePersist(threadId, terminalId);
502557 }
503558 });
504559 void finalized.catch(() => undefined);
505560 return finalized;
506561 }
507562
508 private schedulePersist(threadId: string): void {
509 if (this.persistTimers.has(threadId)) return;
563 private schedulePersist(threadId: string, terminalId: string): void {
564 const persistenceKey = toSessionKey(threadId, terminalId);
565 if (this.persistTimers.has(persistenceKey)) return;
510566 const timer = setTimeout(() => {
511 this.persistTimers.delete(threadId);
512 const pendingHistory = this.pendingPersistHistory.get(threadId);
567 this.persistTimers.delete(persistenceKey);
568 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);
513569 if (pendingHistory === undefined) return;
514 this.pendingPersistHistory.delete(threadId);
515 void this.enqueuePersistWrite(threadId, pendingHistory);
570 this.pendingPersistHistory.delete(persistenceKey);
571 void this.enqueuePersistWrite(threadId, terminalId, pendingHistory);
516572 }, this.persistDebounceMs);
517 this.persistTimers.set(threadId, timer);
573 this.persistTimers.set(persistenceKey, timer);
518574 }
519575
520 private clearPersistTimer(threadId: string): void {
521 const timer = this.persistTimers.get(threadId);
576 private clearPersistTimer(threadId: string, terminalId: string): void {
577 const persistenceKey = toSessionKey(threadId, terminalId);
578 const timer = this.persistTimers.get(persistenceKey);
522579 if (!timer) return;
523580 clearTimeout(timer);
524 this.persistTimers.delete(threadId);
581 this.persistTimers.delete(persistenceKey);
525582 }
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);
528586 try {
529 const raw = await fs.promises.readFile(this.historyPath(threadId), "utf8");
587 const raw = await fs.promises.readFile(historyPath, "utf8");
530588 const capped = capHistory(raw, this.historyLineLimit);
531589 if (capped !== raw) {
532 await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8");
590 await fs.promises.writeFile(historyPath, capped, "utf8");
533591 }
534592 return capped;
535593 } catch (error) {
......@@ -538,6 +596,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
538596 }
539597 }
540598
599 if (terminalId !== DEFAULT_TERMINAL_ID) {
600 return "";
601 }
602
541603 try {
542604 const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8");
543605 const capped = capHistory(raw, this.historyLineLimit);
......@@ -553,31 +615,34 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
553615 }
554616 }
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 }
557623 try {
558 await Promise.all([
559 fs.promises.rm(this.historyPath(threadId), { force: true }),
560 fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }),
561 ]);
624 await Promise.all(deletions);
562625 } catch (error) {
563626 this.logger.warn("failed to delete terminal history", {
564627 threadId,
628 terminalId,
565629 error: error instanceof Error ? error.message : String(error),
566630 });
567631 }
568632 }
569633
570 private async flushPersistQueue(threadId: string): Promise<void> {
571 this.clearPersistTimer(threadId);
634 private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> {
635 const persistenceKey = toSessionKey(threadId, terminalId);
636 this.clearPersistTimer(threadId, terminalId);
572637
573638 while (true) {
574 const pendingHistory = this.pendingPersistHistory.get(threadId);
639 const pendingHistory = this.pendingPersistHistory.get(persistenceKey);
575640 if (pendingHistory !== undefined) {
576 this.pendingPersistHistory.delete(threadId);
577 await this.enqueuePersistWrite(threadId, pendingHistory);
641 this.pendingPersistHistory.delete(persistenceKey);
642 await this.enqueuePersistWrite(threadId, terminalId, pendingHistory);
578643 }
579644
580 const pending = this.persistQueues.get(threadId);
645 const pending = this.persistQueues.get(persistenceKey);
581646 if (!pending) {
582647 return;
583648 }
......@@ -600,10 +665,54 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
600665 }
601666 }
602667
603 private requireSession(threadId: string): TerminalSessionState {
604 const session = this.sessions.get(threadId);
668 private async closeSession(
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));
605714 if (!session) {
606 throw new Error(`Unknown terminal thread: ${threadId}`);
715 throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`);
607716 }
608717 return session;
609718 }
......@@ -611,6 +720,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
611720 private snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
612721 return {
613722 threadId: session.threadId,
723 terminalId: session.terminalId,
614724 cwd: session.cwd,
615725 status: session.status,
616726 pid: session.pid,
......@@ -625,8 +735,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
625735 this.emit("event", event);
626736 }
627737
628 private historyPath(threadId: string): string {
629 return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`);
738 private historyPath(threadId: string, terminalId: string): string {
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`);
630744 }
631745
632746 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";
77import { createServer } from "./wsServer";
88import 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";
1117import { ProjectRegistry } from "./projectRegistry";
1218import type {
19 TerminalClearInput,
1320 TerminalCloseInput,
1421 TerminalEvent,
1522 TerminalOpenInput,
23 TerminalResizeInput,
1624 TerminalSessionSnapshot,
17 TerminalThreadInput,
1825 TerminalWriteInput,
1926} from "@t3tools/contracts";
2027import type { TerminalManager } from "./terminalManager";
......@@ -29,10 +36,16 @@ const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
2936class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
3037 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
3138
39 private key(threadId: string, terminalId: string): string {
40 return `${threadId}\u0000${terminalId}`;
41 }
42
3243 async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
3344 const now = new Date().toISOString();
45 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
3446 const snapshot: TerminalSessionSnapshot = {
3547 threadId: input.threadId,
48 terminalId,
3649 cwd: input.cwd,
3750 status: "running",
3851 pid: 4242,
......@@ -41,11 +54,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
4154 exitSignal: null,
4255 updatedAt: now,
4356 };
44 this.sessions.set(input.threadId, snapshot);
57 this.sessions.set(this.key(input.threadId, terminalId), snapshot);
4558 queueMicrotask(() => {
4659 this.emit("event", {
4760 type: "started",
4861 threadId: input.threadId,
62 terminalId,
4963 createdAt: now,
5064 snapshot,
5165 });
......@@ -54,7 +68,8 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
5468 }
5569
5670 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));
5873 if (!existing) {
5974 throw new Error(`Unknown terminal thread: ${input.threadId}`);
6075 }
......@@ -62,19 +77,22 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
6277 this.emit("event", {
6378 type: "output",
6479 threadId: input.threadId,
80 terminalId,
6581 createdAt: new Date().toISOString(),
6682 data: input.data,
6783 });
6884 });
6985 }
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;
7491 queueMicrotask(() => {
7592 this.emit("event", {
7693 type: "cleared",
7794 threadId: input.threadId,
95 terminalId,
7896 createdAt: new Date().toISOString(),
7997 });
8098 });
......@@ -82,8 +100,10 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
82100
83101 async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
84102 const now = new Date().toISOString();
103 const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID;
85104 const snapshot: TerminalSessionSnapshot = {
86105 threadId: input.threadId,
106 terminalId,
87107 cwd: input.cwd,
88108 status: "running",
89109 pid: 5252,
......@@ -92,11 +112,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
92112 exitSignal: null,
93113 updatedAt: now,
94114 };
95 this.sessions.set(input.threadId, snapshot);
115 this.sessions.set(this.key(input.threadId, terminalId), snapshot);
96116 queueMicrotask(() => {
97117 this.emit("event", {
98118 type: "restarted",
99119 threadId: input.threadId,
120 terminalId,
100121 createdAt: now,
101122 snapshot,
102123 });
......@@ -105,7 +126,15 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }
105126 }
106127
107128 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 }
109138 }
110139
111140 dispose(): void {}
......@@ -340,6 +369,7 @@ describe("WebSocket Server", () => {
340369 });
341370 expect(open.error).toBeUndefined();
342371 expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
372 expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID);
343373
344374 const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
345375 threadId: "thread-1",
......@@ -376,6 +406,7 @@ describe("WebSocket Server", () => {
376406 const manualEvent: TerminalEvent = {
377407 type: "output",
378408 threadId: "thread-1",
409 terminalId: DEFAULT_TERMINAL_ID,
379410 createdAt: new Date().toISOString(),
380411 data: "manual test output\n",
381412 };
apps/web/src/App.tsx+5-13
......@@ -7,7 +7,7 @@ import Sidebar from "./components/Sidebar";
77import { isElectron } from "./env";
88import { DEFAULT_MODEL } from "./model-logic";
99import { StoreProvider, useStore } from "./store";
10import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
10import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
1111import { onServerWelcome } from "./wsNativeApi";
1212import { useNativeApi } from "./hooks/useNativeApi";
1313
......@@ -27,18 +27,6 @@ function EventRouter() {
2727 });
2828 }, [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
4230 return null;
4331}
4432
......@@ -93,6 +81,10 @@ function AutoProjectBootstrap() {
9381 model: DEFAULT_MODEL,
9482 terminalOpen: false,
9583 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
84 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
85 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
86 terminalLayout: "single",
87 splitTerminalIds: [],
9688 session: null,
9789 messages: [],
9890 events: [],
apps/web/src/components/ChatView.tsx+37-7
......@@ -269,6 +269,36 @@ export default function ChatView() {
269269 open: !isOpen,
270270 });
271271 }, [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
273303 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
274304 if (mode === state.runtimeMode) return;
......@@ -1451,7 +1481,14 @@ export default function ChatView() {
14511481 threadId={activeThread.id}
14521482 cwd={activeProject.cwd}
14531483 height={activeThread.terminalHeight}
1484 terminalIds={activeThread.terminalIds}
1485 activeTerminalId={activeThread.activeTerminalId}
1486 terminalLayout={activeThread.terminalLayout}
1487 splitTerminalIds={activeThread.splitTerminalIds}
14541488 focusRequestId={terminalFocusRequestId}
1489 onSplitTerminal={splitTerminal}
1490 onNewTerminal={createNewTerminal}
1491 onActiveTerminalChange={activateTerminal}
14551492 onHeightChange={(height) =>
14561493 dispatch({
14571494 type: "SET_THREAD_TERMINAL_HEIGHT",
......@@ -1459,13 +1496,6 @@ export default function ChatView() {
14591496 height,
14601497 })
14611498 }
1462 onThreadExited={() =>
1463 dispatch({
1464 type: "SET_THREAD_TERMINAL_OPEN",
1465 threadId: activeThread.id,
1466 open: false,
1467 })
1468 }
14691499 />
14701500 )}
14711501
apps/web/src/components/Sidebar.tsx+10-1
......@@ -5,7 +5,12 @@ import { useTheme } from "../hooks/useTheme";
55import { DEFAULT_MODEL } from "../model-logic";
66import { derivePendingApprovals } from "../session-logic";
77import { 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";
914import { useNativeApi } from "../hooks/useNativeApi";
1015
1116const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
......@@ -112,6 +117,10 @@ export default function Sidebar() {
112117 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
113118 terminalOpen: false,
114119 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
120 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
121 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
122 terminalLayout: "single",
123 splitTerminalIds: [],
115124 session: null,
116125 messages: [],
117126 events: [],
apps/web/src/components/ThreadTerminalDrawer.tsx+303-159
......@@ -5,6 +5,7 @@ import {
55 type PointerEvent as ReactPointerEvent,
66 useCallback,
77 useEffect,
8 useMemo,
89 useRef,
910 useState,
1011} from "react";
......@@ -15,7 +16,11 @@ import {
1516 resolvePathLinkTarget,
1617} from "../terminal-links";
1718import { 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
2025const MIN_DRAWER_HEIGHT = 180;
2126const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -103,162 +108,30 @@ function terminalThemeFromApp(): ITheme {
103108 };
104109}
105110
106interface ThreadTerminalDrawerProps {
111interface TerminalViewportProps {
107112 api: NativeApi;
108113 threadId: string;
114 terminalId: string;
109115 cwd: string;
110 height: number;
111116 focusRequestId: number;
112 onHeightChange: (height: number) => void;
113 onThreadExited: () => void;
117 autoFocus: boolean;
118 resizeEpoch: number;
119 drawerHeight: number;
114120}
115121
116export default function ThreadTerminalDrawer({
122function TerminalViewport({
117123 api,
118124 threadId,
125 terminalId,
119126 cwd,
120 height,
121127 focusRequestId,
122 onHeightChange,
123 onThreadExited,
124}: ThreadTerminalDrawerProps) {
125 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
128 autoFocus,
129 resizeEpoch,
130 drawerHeight,
131}: TerminalViewportProps) {
126132 const containerRef = useRef<HTMLDivElement>(null);
127133 const terminalRef = useRef<Terminal | null>(null);
128134 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
263136 useEffect(() => {
264137 const mount = containerRef.current;
......@@ -287,7 +160,7 @@ export default function ThreadTerminalDrawer({
287160 const activeTerminal = terminalRef.current;
288161 if (!activeTerminal) return;
289162 try {
290 await api.terminal.write({ threadId, data: "\u000c" });
163 await api.terminal.write({ threadId, terminalId, data: "\u000c" });
291164 } catch (error) {
292165 writeSystemMessage(
293166 activeTerminal,
......@@ -367,7 +240,7 @@ export default function ThreadTerminalDrawer({
367240
368241 const inputDisposable = terminal.onData((data) => {
369242 void api.terminal
370 .write({ threadId, data })
243 .write({ threadId, terminalId, data })
371244 .catch((err) =>
372245 writeSystemMessage(
373246 terminal,
......@@ -395,6 +268,7 @@ export default function ThreadTerminalDrawer({
395268 activeFitAddon.fit();
396269 const snapshot = await api.terminal.open({
397270 threadId,
271 terminalId,
398272 cwd,
399273 cols: activeTerminal.cols,
400274 rows: activeTerminal.rows,
......@@ -404,9 +278,11 @@ export default function ThreadTerminalDrawer({
404278 if (snapshot.history.length > 0) {
405279 activeTerminal.write(snapshot.history);
406280 }
407 window.requestAnimationFrame(() => {
408 activeTerminal.focus();
409 });
281 if (autoFocus) {
282 window.requestAnimationFrame(() => {
283 activeTerminal.focus();
284 });
285 }
410286 } catch (err) {
411287 if (disposed) return;
412288 writeSystemMessage(
......@@ -417,7 +293,7 @@ export default function ThreadTerminalDrawer({
417293 };
418294
419295 const unsubscribe = api.terminal.onEvent((event) => {
420 if (event.threadId !== threadId) return;
296 if (event.threadId !== threadId || event.terminalId !== terminalId) return;
421297 const activeTerminal = terminalRef.current;
422298 if (!activeTerminal) return;
423299
......@@ -446,12 +322,37 @@ export default function ThreadTerminalDrawer({
446322 }
447323
448324 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 );
450335 }
451336 });
452337
453338 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);
455356 }, 30);
456357 void openTerminal();
457358
......@@ -466,9 +367,10 @@ export default function ThreadTerminalDrawer({
466367 fitAddonRef.current = null;
467368 terminal.dispose();
468369 };
469 }, [api, cwd, fitAndResizeTerminal, threadId]);
370 }, [api, autoFocus, cwd, terminalId, threadId]);
470371
471372 useEffect(() => {
373 if (!autoFocus) return;
472374 const terminal = terminalRef.current;
473375 if (!terminal) return;
474376 const frame = window.requestAnimationFrame(() => {
......@@ -477,7 +379,7 @@ export default function ThreadTerminalDrawer({
477379 return () => {
478380 window.cancelAnimationFrame(frame);
479381 };
480 }, [focusRequestId]);
382 }, [autoFocus, focusRequestId]);
481383
482384 useEffect(() => {
483385 const terminal = terminalRef.current;
......@@ -492,6 +394,7 @@ export default function ThreadTerminalDrawer({
492394 void api.terminal
493395 .resize({
494396 threadId,
397 terminalId,
495398 cols: terminal.cols,
496399 rows: terminal.rows,
497400 })
......@@ -500,7 +403,176 @@ export default function ThreadTerminalDrawer({
500403 return () => {
501404 window.cancelAnimationFrame(frame);
502405 };
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
505577 return (
506578 <aside
......@@ -516,11 +588,83 @@ export default function ThreadTerminalDrawer({
516588 >
517589 <div className="h-px w-10 rounded-full bg-border/80" />
518590 </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
519638 <div className="min-h-0 w-full flex-1 px-1.5 pb-1">
520 <div
521 ref={containerRef}
522 className="h-full w-full overflow-hidden rounded-[4px]"
523 />
639 {terminalLayout === "split" && visibleTerminalIds.length === 2 ? (
640 <div className="grid h-full w-full grid-cols-2 gap-1">
641 {visibleTerminalIds.map((terminalId) => (
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 )}
524668 </div>
525669 </aside>
526670 );
apps/web/src/persistenceSchema.test.ts+28-1
......@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
22
33import { DEFAULT_MODEL } from "./model-logic";
44import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
66import type { Thread } from "./types";
77
88describe("hydratePersistedState", () => {
......@@ -50,6 +50,10 @@ describe("hydratePersistedState", () => {
5050 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
5151 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
5252 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([]);
5357 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
5458 expect(hydrated?.runtimeMode).toBe("full-access");
5559 });
......@@ -92,6 +96,10 @@ describe("hydratePersistedState", () => {
9296 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
9397 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
9498 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([]);
95103 expect(hydrated?.activeThreadId).toBe("t-1");
96104 expect(hydrated?.runtimeMode).toBe("full-access");
97105 });
......@@ -139,6 +147,10 @@ describe("hydratePersistedState", () => {
139147 model: "gpt-5.3-codex",
140148 terminalOpen: true,
141149 terminalHeight: 360,
150 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
151 activeTerminalId: "term-2",
152 terminalLayout: "tabs",
153 splitTerminalIds: [],
142154 messages: [],
143155 createdAt: "2026-02-08T10:00:00.000Z",
144156 lastVisitedAt: "2026-02-08T10:01:00.000Z",
......@@ -150,6 +162,10 @@ describe("hydratePersistedState", () => {
150162 const hydrated = hydratePersistedState(payload, false);
151163 expect(hydrated?.threads[0]?.terminalOpen).toBe(true);
152164 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([]);
153169 expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z");
154170 });
155171
......@@ -183,6 +199,9 @@ describe("hydratePersistedState", () => {
183199
184200 const hydrated = hydratePersistedState(payload, false);
185201 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");
186205 });
187206});
188207
......@@ -196,6 +215,10 @@ describe("toPersistedState", () => {
196215 model: "gpt-5.3-codex",
197216 terminalOpen: true,
198217 terminalHeight: 320,
218 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
219 activeTerminalId: "term-2",
220 terminalLayout: "tabs",
221 splitTerminalIds: [],
199222 session: null,
200223 messages: [
201224 {
......@@ -249,6 +272,10 @@ describe("toPersistedState", () => {
249272 model: "gpt-5.3-codex",
250273 terminalOpen: true,
251274 terminalHeight: 320,
275 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
276 activeTerminalId: "term-2",
277 terminalLayout: "tabs",
278 splitTerminalIds: [],
252279 messages: [
253280 {
254281 id: "m-1",
apps/web/src/persistenceSchema.ts+40
......@@ -3,6 +3,7 @@ import { z } from "zod";
33import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
44import {
55 DEFAULT_THREAD_TERMINAL_HEIGHT,
6 DEFAULT_THREAD_TERMINAL_ID,
67 DEFAULT_RUNTIME_MODE,
78 type Project,
89 type RuntimeMode,
......@@ -48,6 +49,10 @@ const persistedThreadSchema = z.object({
4849 terminalHeight: z.number().int().min(120).max(4_096).default(
4950 DEFAULT_THREAD_TERMINAL_HEIGHT,
5051 ),
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([]),
5156 messages: z.array(persistedMessageSchema),
5257 createdAt: z.string().min(1),
5358 lastVisitedAt: z.string().min(1).optional(),
......@@ -129,6 +134,33 @@ function hydrateThread(
129134 thread: z.infer<typeof persistedThreadSchema>,
130135 isLegacyPayload: boolean,
131136): 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
132164 return {
133165 id: thread.id,
134166 codexThreadId: thread.codexThreadId,
......@@ -137,6 +169,10 @@ function hydrateThread(
137169 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
138170 terminalOpen: thread.terminalOpen ?? false,
139171 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
172 terminalIds: safeTerminalIds,
173 activeTerminalId,
174 terminalLayout,
175 splitTerminalIds,
140176 session: null,
141177 messages: thread.messages.map((message) => {
142178 const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment }));
......@@ -211,6 +247,10 @@ export function toPersistedState(
211247 model: thread.model,
212248 terminalOpen: thread.terminalOpen,
213249 terminalHeight: thread.terminalHeight,
250 terminalIds: thread.terminalIds,
251 activeTerminalId: thread.activeTerminalId,
252 terminalLayout: thread.terminalLayout,
253 splitTerminalIds: thread.splitTerminalIds,
214254 messages: thread.messages.map((message) => ({
215255 id: message.id,
216256 role: message.role,
apps/web/src/store.test.ts+50-1
......@@ -2,7 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import { type AppState, reducer } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
66import type { Thread } from "./types";
77
88function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
......@@ -37,6 +37,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3737 model: "gpt-5.3-codex",
3838 terminalOpen: false,
3939 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
40 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
41 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
42 terminalLayout: "single",
43 splitTerminalIds: [],
4044 session: makeSession(),
4145 messages: [],
4246 events: [],
......@@ -111,6 +115,51 @@ describe("store reducer thread continuity", () => {
111115 expect(next.threads[0]?.terminalHeight).toBe(360);
112116 });
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
114163 it("backfills codexThreadId from routed provider events", () => {
115164 const state = makeState(makeThread({ codexThreadId: null }));
116165 const next = reducer(state, {
apps/web/src/store.ts+127-11
......@@ -14,6 +14,7 @@ import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1414import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
1515import {
1616 type ChatAttachment,
17 DEFAULT_THREAD_TERMINAL_ID,
1718 DEFAULT_RUNTIME_MODE,
1819 type Project,
1920 type RuntimeMode,
......@@ -31,6 +32,9 @@ type Action =
3132 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
3233 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
3334 | { 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 }
3438 | { type: "TOGGLE_DIFF" }
3539 | {
3640 type: "APPLY_EVENT";
......@@ -133,6 +137,58 @@ function updateThread(
133137 return threads.map((t) => (t.id === threadId ? updater(t) : t));
134138}
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
136192function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
137193 return threads.find((t) => t.session?.sessionId === sessionId);
138194}
......@@ -259,10 +315,10 @@ export function reducer(state: AppState, action: Action): AppState {
259315 if (!previousProject) return null;
260316 const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd);
261317 if (!mappedProjectId) return null;
262 return {
318 return normalizeThreadTerminals({
263319 ...thread,
264320 projectId: mappedProjectId,
265 };
321 });
266322 })
267323 .filter((thread): thread is Thread => thread !== null);
268324 const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)
......@@ -285,19 +341,18 @@ export function reducer(state: AppState, action: Action): AppState {
285341 ),
286342 };
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 });
289350 return {
290351 ...state,
291 threads: [
292 ...state.threads,
293 {
294 ...action.thread,
295 model: resolveModelSlug(action.thread.model),
296 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
297 },
298 ],
352 threads: [...state.threads, nextThread],
299353 activeThreadId: action.thread.id,
300354 };
355 }
301356
302357 case "SET_ACTIVE_THREAD": {
303358 const visitedAt = new Date().toISOString();
......@@ -338,6 +393,67 @@ export function reducer(state: AppState, action: Action): AppState {
338393 })),
339394 };
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
341457 case "TOGGLE_DIFF":
342458 return { ...state, diffOpen: !state.diffOpen };
343459
apps/web/src/types.ts+6
......@@ -4,6 +4,8 @@ export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
44export type RuntimeMode = "approval-required" | "full-access";
55export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
66export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
7export const DEFAULT_THREAD_TERMINAL_ID = "default";
8export type ThreadTerminalLayout = "single" | "split" | "tabs";
79
810export interface ChatImageAttachment {
911 type: "image";
......@@ -41,6 +43,10 @@ export interface Thread {
4143 model: string;
4244 terminalOpen: boolean;
4345 terminalHeight: number;
46 terminalIds: string[];
47 activeTerminalId: string;
48 terminalLayout: ThreadTerminalLayout;
49 splitTerminalIds: string[];
4450 session: ProviderSession | null;
4551 messages: ChatMessage[];
4652 events: ProviderEvent[];
packages/contracts/src/ipc.ts+2-2
......@@ -30,12 +30,12 @@ import type {
3030 ProjectRemoveInput,
3131} from "./project";
3232import type {
33 TerminalClearInput,
3334 TerminalCloseInput,
3435 TerminalEvent,
3536 TerminalOpenInput,
3637 TerminalResizeInput,
3738 TerminalSessionSnapshot,
38 TerminalThreadInput,
3939 TerminalWriteInput,
4040} from "./terminal";
4141import type { NewTodoInput, Todo } from "./todo";
......@@ -61,7 +61,7 @@ export interface NativeApi {
6161 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
6262 write: (input: TerminalWriteInput) => Promise<void>;
6363 resize: (input: TerminalResizeInput) => Promise<void>;
64 clear: (input: TerminalThreadInput) => Promise<void>;
64 clear: (input: TerminalClearInput) => Promise<void>;
6565 restart: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
6666 close: (input: TerminalCloseInput) => Promise<void>;
6767 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
packages/contracts/src/terminal.test.ts+24
......@@ -1,6 +1,8 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 DEFAULT_TERMINAL_ID,
5 terminalClearInputSchema,
46 terminalCloseInputSchema,
57 terminalEventSchema,
68 terminalOpenInputSchema,
......@@ -30,6 +32,16 @@ describe("terminalOpenInputSchema", () => {
3032 });
3133 expect(result.success).toBe(false);
3234 });
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 });
3345});
3446
3547describe("terminalWriteInputSchema", () => {
......@@ -68,6 +80,15 @@ describe("terminalResizeInputSchema", () => {
6880 });
6981});
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
7192describe("terminalCloseInputSchema", () => {
7293 it("accepts optional deleteHistory", () => {
7394 const result = terminalCloseInputSchema.safeParse({
......@@ -82,6 +103,7 @@ describe("terminalSessionSnapshotSchema", () => {
82103 it("accepts running snapshots", () => {
83104 const result = terminalSessionSnapshotSchema.safeParse({
84105 threadId: "thread-1",
106 terminalId: DEFAULT_TERMINAL_ID,
85107 cwd: "/tmp/project",
86108 status: "running",
87109 pid: 1234,
......@@ -99,6 +121,7 @@ describe("terminalEventSchema", () => {
99121 const result = terminalEventSchema.safeParse({
100122 type: "output",
101123 threadId: "thread-1",
124 terminalId: DEFAULT_TERMINAL_ID,
102125 createdAt: new Date().toISOString(),
103126 data: "line\n",
104127 });
......@@ -109,6 +132,7 @@ describe("terminalEventSchema", () => {
109132 const result = terminalEventSchema.safeParse({
110133 type: "exited",
111134 threadId: "thread-1",
135 terminalId: DEFAULT_TERMINAL_ID,
112136 createdAt: new Date().toISOString(),
113137 exitCode: 0,
114138 exitSignal: null,
packages/contracts/src/terminal.ts+17-3
......@@ -1,28 +1,38 @@
11import { z } from "zod";
22
3export const DEFAULT_TERMINAL_ID = "default";
4
35const terminalColsSchema = z.number().int().min(20).max(400);
46const terminalRowsSchema = z.number().int().min(5).max(200);
7const terminalIdSchema = z.string().trim().min(1).max(128);
58
69export const terminalThreadInputSchema = z.object({
710 threadId: z.string().trim().min(1),
811});
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({
1118 cwd: z.string().trim().min(1),
1219 cols: terminalColsSchema,
1320 rows: terminalRowsSchema,
1421});
1522
16export const terminalWriteInputSchema = terminalThreadInputSchema.extend({
23export const terminalWriteInputSchema = terminalSessionInputSchema.extend({
1724 data: z.string().min(1).max(65_536),
1825});
1926
20export const terminalResizeInputSchema = terminalThreadInputSchema.extend({
27export const terminalResizeInputSchema = terminalSessionInputSchema.extend({
2128 cols: terminalColsSchema,
2229 rows: terminalRowsSchema,
2330});
2431
32export const terminalClearInputSchema = terminalSessionInputSchema;
33
2534export const terminalCloseInputSchema = terminalThreadInputSchema.extend({
35 terminalId: terminalIdSchema.optional(),
2636 deleteHistory: z.boolean().optional(),
2737});
2838
......@@ -35,6 +45,7 @@ export const terminalSessionStatusSchema = z.enum([
3545
3646export const terminalSessionSnapshotSchema = z.object({
3747 threadId: z.string().min(1),
48 terminalId: z.string().min(1),
3849 cwd: z.string().min(1),
3950 status: terminalSessionStatusSchema,
4051 pid: z.number().int().positive().nullable(),
......@@ -46,6 +57,7 @@ export const terminalSessionSnapshotSchema = z.object({
4657
4758const terminalEventBaseSchema = z.object({
4859 threadId: z.string().min(1),
60 terminalId: z.string().min(1),
4961 createdAt: z.string().datetime(),
5062});
5163
......@@ -89,9 +101,11 @@ export const terminalEventSchema = z.discriminatedUnion("type", [
89101]);
90102
91103export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>;
104export type TerminalSessionInput = z.input<typeof terminalSessionInputSchema>;
92105export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>;
93106export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>;
94107export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>;
108export type TerminalClearInput = z.input<typeof terminalClearInputSchema>;
95109export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>;
96110export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>;
97111export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>;