| author | |
| committer | |
| log | 800eb3b547aa5e073248f1db9272efe556df6928 |
| tree | 622219eb2ba6bced6903e3c65e480aa772d2b352 |
| parent | 854068dc565bdbe8b9e7c6183eb4914674816fc3 |
- 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 propagation15 files changed, 948 insertions(+), 280 deletions(-)
apps/server/src/terminalManager.test.ts+76-4| ... | ... | @@ -2,10 +2,7 @@ import fs from "node:fs"; |
| 2 | 2 | import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | import type { | |
| 6 | TerminalEvent, | |
| 7 | TerminalOpenInput, | |
| 8 | } from "@t3tools/contracts"; | |
| 5 | import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput } from "@t3tools/contracts"; | |
| 9 | 6 | import { afterEach, describe, expect, it } from "vitest"; |
| 10 | 7 | |
| 11 | 8 | import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; |
| ... | ... | @@ -109,10 +106,26 @@ function historyLogName(threadId: string): string { |
| 109 | 106 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`; |
| 110 | 107 | } |
| 111 | 108 | |
| 109 | function 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 | ||
| 112 | 117 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { |
| 113 | 118 | return path.join(logsDir, historyLogName(threadId)); |
| 114 | 119 | } |
| 115 | 120 | |
| 121 | function multiTerminalHistoryLogPath( | |
| 122 | logsDir: string, | |
| 123 | threadId = "thread-1", | |
| 124 | terminalId = "default", | |
| 125 | ): string { | |
| 126 | return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); | |
| 127 | } | |
| 128 | ||
| 116 | 129 | describe("TerminalManager", () => { |
| 117 | 130 | const tempDirs: string[] = []; |
| 118 | 131 | |
| ... | ... | @@ -144,6 +157,7 @@ describe("TerminalManager", () => { |
| 144 | 157 | const third = await manager.open(openInput()); |
| 145 | 158 | |
| 146 | 159 | expect(first.threadId).toBe("thread-1"); |
| 160 | expect(first.terminalId).toBe("default"); | |
| 147 | 161 | expect(second.threadId).toBe("thread-1"); |
| 148 | 162 | expect(third.threadId).toBe("thread-1"); |
| 149 | 163 | expect(ptyAdapter.spawnInputs).toHaveLength(1); |
| ... | ... | @@ -167,6 +181,27 @@ describe("TerminalManager", () => { |
| 167 | 181 | manager.dispose(); |
| 168 | 182 | }); |
| 169 | 183 | |
| 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 | 205 | it("clears transcript and emits cleared event", async () => { |
| 171 | 206 | const { manager, ptyAdapter, logsDir } = makeManager(); |
| 172 | 207 | const events: TerminalEvent[] = []; |
| ... | ... | @@ -184,6 +219,14 @@ describe("TerminalManager", () => { |
| 184 | 219 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); |
| 185 | 220 | |
| 186 | 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); | |
| 187 | 230 | |
| 188 | 231 | manager.dispose(); |
| 189 | 232 | }); |
| ... | ... | @@ -262,6 +305,35 @@ describe("TerminalManager", () => { |
| 262 | 305 | manager.dispose(); |
| 263 | 306 | }); |
| 264 | 307 | |
| 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 | 337 | it("loads existing legacy transcript filenames and keeps new naming for writes", async () => { |
| 266 | 338 | const { manager, logsDir } = makeManager(); |
| 267 | 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 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | type TerminalClearInput, | |
| 6 | 8 | type TerminalCloseInput, |
| 7 | 9 | type TerminalEvent, |
| 8 | 10 | type TerminalOpenInput, |
| 11 | type TerminalResizeInput, | |
| 9 | 12 | type TerminalSessionSnapshot, |
| 10 | 13 | type TerminalSessionStatus, |
| 11 | type TerminalThreadInput, | |
| 12 | 14 | type TerminalWriteInput, |
| 15 | terminalClearInputSchema, | |
| 13 | 16 | terminalCloseInputSchema, |
| 14 | 17 | terminalOpenInputSchema, |
| 15 | 18 | terminalResizeInputSchema, |
| 16 | terminalThreadInputSchema, | |
| 17 | 19 | terminalWriteInputSchema, |
| 18 | 20 | } from "@t3tools/contracts"; |
| 19 | 21 | |
| ... | ... | @@ -36,6 +38,7 @@ export interface TerminalManagerOptions { |
| 36 | 38 | |
| 37 | 39 | interface TerminalSessionState { |
| 38 | 40 | threadId: string; |
| 41 | terminalId: string; | |
| 39 | 42 | cwd: string; |
| 40 | 43 | status: TerminalSessionStatus; |
| 41 | 44 | pid: number | null; |
| ... | ... | @@ -139,6 +142,14 @@ function toSafeThreadId(threadId: string): string { |
| 139 | 142 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; |
| 140 | 143 | } |
| 141 | 144 | |
| 145 | function toSafeTerminalId(terminalId: string): string { | |
| 146 | return Buffer.from(terminalId, "utf8").toString("base64url"); | |
| 147 | } | |
| 148 | ||
| 149 | function toSessionKey(threadId: string, terminalId: string): string { | |
| 150 | return `${threadId}\u0000${terminalId}`; | |
| 151 | } | |
| 152 | ||
| 142 | 153 | export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 143 | 154 | private readonly sessions = new Map<string, TerminalSessionState>(); |
| 144 | 155 | private readonly logsDir: string; |
| ... | ... | @@ -167,12 +178,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 167 | 178 | return this.runWithThreadLock(input.threadId, async () => { |
| 168 | 179 | await this.assertValidCwd(input.cwd); |
| 169 | 180 | |
| 170 | const existing = this.sessions.get(input.threadId); | |
| 181 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 182 | const existing = this.sessions.get(sessionKey); | |
| 171 | 183 | 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); | |
| 174 | 186 | const session: TerminalSessionState = { |
| 175 | 187 | threadId: input.threadId, |
| 188 | terminalId: input.terminalId, | |
| 176 | 189 | cwd: input.cwd, |
| 177 | 190 | status: "starting", |
| 178 | 191 | pid: null, |
| ... | ... | @@ -186,7 +199,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 186 | 199 | unsubscribeData: null, |
| 187 | 200 | unsubscribeExit: null, |
| 188 | 201 | }; |
| 189 | this.sessions.set(input.threadId, session); | |
| 202 | this.sessions.set(sessionKey, session); | |
| 190 | 203 | this.startSession(session, input, "started"); |
| 191 | 204 | return this.snapshot(session); |
| 192 | 205 | } |
| ... | ... | @@ -195,10 +208,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 195 | 208 | this.stopProcess(existing); |
| 196 | 209 | existing.cwd = input.cwd; |
| 197 | 210 | existing.history = ""; |
| 198 | await this.persistHistory(existing.threadId, existing.history); | |
| 211 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 199 | 212 | } else if (existing.status === "exited" || existing.status === "error") { |
| 200 | 213 | existing.history = ""; |
| 201 | await this.persistHistory(existing.threadId, existing.history); | |
| 214 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 202 | 215 | } |
| 203 | 216 | |
| 204 | 217 | if (!existing.process) { |
| ... | ... | @@ -219,18 +232,22 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 219 | 232 | |
| 220 | 233 | async write(raw: TerminalWriteInput): Promise<void> { |
| 221 | 234 | const input = terminalWriteInputSchema.parse(raw); |
| 222 | const session = this.requireSession(input.threadId); | |
| 235 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 223 | 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 | 241 | session.process.write(input.data); |
| 227 | 242 | } |
| 228 | 243 | |
| 229 | async resize(raw: TerminalThreadInput & { cols: number; rows: number }): Promise<void> { | |
| 244 | async resize(raw: TerminalResizeInput): Promise<void> { | |
| 230 | 245 | const input = terminalResizeInputSchema.parse(raw); |
| 231 | const session = this.requireSession(input.threadId); | |
| 246 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 232 | 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 | 252 | session.cols = input.cols; |
| 236 | 253 | session.rows = input.rows; |
| ... | ... | @@ -238,16 +255,17 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 238 | 255 | session.process.resize(input.cols, input.rows); |
| 239 | 256 | } |
| 240 | 257 | |
| 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); | |
| 243 | 260 | await this.runWithThreadLock(input.threadId, async () => { |
| 244 | const session = this.requireSession(input.threadId); | |
| 261 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 245 | 262 | session.history = ""; |
| 246 | 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 | 265 | this.emitEvent({ |
| 249 | 266 | type: "cleared", |
| 250 | 267 | threadId: input.threadId, |
| 268 | terminalId: input.terminalId, | |
| 251 | 269 | createdAt: new Date().toISOString(), |
| 252 | 270 | }); |
| 253 | 271 | }); |
| ... | ... | @@ -258,10 +276,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 258 | 276 | return this.runWithThreadLock(input.threadId, async () => { |
| 259 | 277 | await this.assertValidCwd(input.cwd); |
| 260 | 278 | |
| 261 | let session = this.sessions.get(input.threadId); | |
| 279 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 280 | let session = this.sessions.get(sessionKey); | |
| 262 | 281 | if (!session) { |
| 263 | 282 | session = { |
| 264 | 283 | threadId: input.threadId, |
| 284 | terminalId: input.terminalId, | |
| 265 | 285 | cwd: input.cwd, |
| 266 | 286 | status: "starting", |
| 267 | 287 | pid: null, |
| ... | ... | @@ -275,14 +295,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 275 | 295 | unsubscribeData: null, |
| 276 | 296 | unsubscribeExit: null, |
| 277 | 297 | }; |
| 278 | this.sessions.set(input.threadId, session); | |
| 298 | this.sessions.set(sessionKey, session); | |
| 279 | 299 | } else { |
| 280 | 300 | this.stopProcess(session); |
| 281 | 301 | session.cwd = input.cwd; |
| 282 | 302 | } |
| 283 | 303 | |
| 284 | 304 | session.history = ""; |
| 285 | await this.persistHistory(input.threadId, session.history); | |
| 305 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 286 | 306 | this.startSession(session, input, "restarted"); |
| 287 | 307 | return this.snapshot(session); |
| 288 | 308 | }); |
| ... | ... | @@ -291,14 +311,28 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 291 | 311 | async close(raw: TerminalCloseInput): Promise<void> { |
| 292 | 312 | const input = terminalCloseInputSchema.parse(raw); |
| 293 | 313 | 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) { | |
| 296 | 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 | 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 | 416 | this.emitEvent({ |
| 383 | 417 | type: eventType, |
| 384 | 418 | threadId: session.threadId, |
| 419 | terminalId: session.terminalId, | |
| 385 | 420 | createdAt: new Date().toISOString(), |
| 386 | 421 | snapshot: this.snapshot(session), |
| 387 | 422 | }); |
| ... | ... | @@ -401,11 +436,13 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 401 | 436 | this.emitEvent({ |
| 402 | 437 | type: "error", |
| 403 | 438 | threadId: session.threadId, |
| 439 | terminalId: session.terminalId, | |
| 404 | 440 | createdAt: new Date().toISOString(), |
| 405 | 441 | message, |
| 406 | 442 | }); |
| 407 | 443 | this.logger.error("failed to start terminal", { |
| 408 | 444 | threadId: session.threadId, |
| 445 | terminalId: session.terminalId, | |
| 409 | 446 | error: message, |
| 410 | 447 | ...(startedShell ? { shell: startedShell } : {}), |
| 411 | 448 | }); |
| ... | ... | @@ -415,10 +452,11 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 415 | 452 | private onProcessData(session: TerminalSessionState, data: string): void { |
| 416 | 453 | session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); |
| 417 | 454 | session.updatedAt = new Date().toISOString(); |
| 418 | this.queuePersist(session.threadId, session.history); | |
| 455 | this.queuePersist(session.threadId, session.terminalId, session.history); | |
| 419 | 456 | this.emitEvent({ |
| 420 | 457 | type: "output", |
| 421 | 458 | threadId: session.threadId, |
| 459 | terminalId: session.terminalId, | |
| 422 | 460 | createdAt: new Date().toISOString(), |
| 423 | 461 | data, |
| 424 | 462 | }); |
| ... | ... | @@ -435,6 +473,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 435 | 473 | this.emitEvent({ |
| 436 | 474 | type: "exited", |
| 437 | 475 | threadId: session.threadId, |
| 476 | terminalId: session.terminalId, | |
| 438 | 477 | createdAt: new Date().toISOString(), |
| 439 | 478 | exitCode: session.exitCode, |
| 440 | 479 | exitSignal: session.exitSignal, |
| ... | ... | @@ -455,6 +494,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 455 | 494 | const message = error instanceof Error ? error.message : String(error); |
| 456 | 495 | this.logger.warn("failed to kill terminal process", { |
| 457 | 496 | threadId: session.threadId, |
| 497 | terminalId: session.terminalId, | |
| 458 | 498 | error: message, |
| 459 | 499 | }); |
| 460 | 500 | } |
| ... | ... | @@ -467,69 +507,87 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 467 | 507 | session.unsubscribeExit = null; |
| 468 | 508 | } |
| 469 | 509 | |
| 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); | |
| 473 | 514 | } |
| 474 | 515 | |
| 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); | |
| 479 | 525 | } |
| 480 | 526 | |
| 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 | 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 | 537 | const next = previous |
| 487 | 538 | .catch(() => undefined) |
| 488 | 539 | .then(task) |
| 489 | 540 | .catch((error) => { |
| 490 | 541 | this.logger.warn("failed to persist terminal history", { |
| 491 | 542 | threadId, |
| 543 | terminalId, | |
| 492 | 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 | 548 | 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); | |
| 499 | 551 | } |
| 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); | |
| 502 | 557 | } |
| 503 | 558 | }); |
| 504 | 559 | void finalized.catch(() => undefined); |
| 505 | 560 | return finalized; |
| 506 | 561 | } |
| 507 | 562 | |
| 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; | |
| 510 | 566 | 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); | |
| 513 | 569 | 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); | |
| 516 | 572 | }, this.persistDebounceMs); |
| 517 | this.persistTimers.set(threadId, timer); | |
| 573 | this.persistTimers.set(persistenceKey, timer); | |
| 518 | 574 | } |
| 519 | 575 | |
| 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); | |
| 522 | 579 | if (!timer) return; |
| 523 | 580 | clearTimeout(timer); |
| 524 | this.persistTimers.delete(threadId); | |
| 581 | this.persistTimers.delete(persistenceKey); | |
| 525 | 582 | } |
| 526 | 583 | |
| 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 | 586 | try { |
| 529 | const raw = await fs.promises.readFile(this.historyPath(threadId), "utf8"); | |
| 587 | const raw = await fs.promises.readFile(historyPath, "utf8"); | |
| 530 | 588 | const capped = capHistory(raw, this.historyLineLimit); |
| 531 | 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 | 592 | return capped; |
| 535 | 593 | } catch (error) { |
| ... | ... | @@ -538,6 +596,10 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 538 | 596 | } |
| 539 | 597 | } |
| 540 | 598 | |
| 599 | if (terminalId !== DEFAULT_TERMINAL_ID) { | |
| 600 | return ""; | |
| 601 | } | |
| 602 | ||
| 541 | 603 | try { |
| 542 | 604 | const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8"); |
| 543 | 605 | const capped = capHistory(raw, this.historyLineLimit); |
| ... | ... | @@ -553,31 +615,34 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 553 | 615 | } |
| 554 | 616 | } |
| 555 | 617 | |
| 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 | 623 | 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); | |
| 562 | 625 | } catch (error) { |
| 563 | 626 | this.logger.warn("failed to delete terminal history", { |
| 564 | 627 | threadId, |
| 628 | terminalId, | |
| 565 | 629 | error: error instanceof Error ? error.message : String(error), |
| 566 | 630 | }); |
| 567 | 631 | } |
| 568 | 632 | } |
| 569 | 633 | |
| 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); | |
| 572 | 637 | |
| 573 | 638 | while (true) { |
| 574 | const pendingHistory = this.pendingPersistHistory.get(threadId); | |
| 639 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 575 | 640 | 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); | |
| 578 | 643 | } |
| 579 | 644 | |
| 580 | const pending = this.persistQueues.get(threadId); | |
| 645 | const pending = this.persistQueues.get(persistenceKey); | |
| 581 | 646 | if (!pending) { |
| 582 | 647 | return; |
| 583 | 648 | } |
| ... | ... | @@ -600,10 +665,54 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 600 | 665 | } |
| 601 | 666 | } |
| 602 | 667 | |
| 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)); | |
| 605 | 714 | if (!session) { |
| 606 | throw new Error(`Unknown terminal thread: ${threadId}`); | |
| 715 | throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); | |
| 607 | 716 | } |
| 608 | 717 | return session; |
| 609 | 718 | } |
| ... | ... | @@ -611,6 +720,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 611 | 720 | private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { |
| 612 | 721 | return { |
| 613 | 722 | threadId: session.threadId, |
| 723 | terminalId: session.terminalId, | |
| 614 | 724 | cwd: session.cwd, |
| 615 | 725 | status: session.status, |
| 616 | 726 | pid: session.pid, |
| ... | ... | @@ -625,8 +735,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 625 | 735 | this.emit("event", event); |
| 626 | 736 | } |
| 627 | 737 | |
| 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`); | |
| 630 | 744 | } |
| 631 | 745 | |
| 632 | 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 | 7 | import { createServer } from "./wsServer"; |
| 8 | 8 | import WebSocket from "ws"; |
| 9 | 9 | |
| 10 | import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts"; | |
| 10 | import { | |
| 11 | DEFAULT_TERMINAL_ID, | |
| 12 | WS_CHANNELS, | |
| 13 | WS_METHODS, | |
| 14 | type WsPush, | |
| 15 | type WsResponse, | |
| 16 | } from "@t3tools/contracts"; | |
| 11 | 17 | import { ProjectRegistry } from "./projectRegistry"; |
| 12 | 18 | import type { |
| 19 | TerminalClearInput, | |
| 13 | 20 | TerminalCloseInput, |
| 14 | 21 | TerminalEvent, |
| 15 | 22 | TerminalOpenInput, |
| 23 | TerminalResizeInput, | |
| 16 | 24 | TerminalSessionSnapshot, |
| 17 | TerminalThreadInput, | |
| 18 | 25 | TerminalWriteInput, |
| 19 | 26 | } from "@t3tools/contracts"; |
| 20 | 27 | import type { TerminalManager } from "./terminalManager"; |
| ... | ... | @@ -29,10 +36,16 @@ const pendingBySocket = new WeakMap<WebSocket, PendingMessages>(); |
| 29 | 36 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { |
| 30 | 37 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 31 | 38 | |
| 39 | private key(threadId: string, terminalId: string): string { | |
| 40 | return `${threadId}\u0000${terminalId}`; | |
| 41 | } | |
| 42 | ||
| 32 | 43 | async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { |
| 33 | 44 | const now = new Date().toISOString(); |
| 45 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 34 | 46 | const snapshot: TerminalSessionSnapshot = { |
| 35 | 47 | threadId: input.threadId, |
| 48 | terminalId, | |
| 36 | 49 | cwd: input.cwd, |
| 37 | 50 | status: "running", |
| 38 | 51 | pid: 4242, |
| ... | ... | @@ -41,11 +54,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } |
| 41 | 54 | exitSignal: null, |
| 42 | 55 | updatedAt: now, |
| 43 | 56 | }; |
| 44 | this.sessions.set(input.threadId, snapshot); | |
| 57 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 45 | 58 | queueMicrotask(() => { |
| 46 | 59 | this.emit("event", { |
| 47 | 60 | type: "started", |
| 48 | 61 | threadId: input.threadId, |
| 62 | terminalId, | |
| 49 | 63 | createdAt: now, |
| 50 | 64 | snapshot, |
| 51 | 65 | }); |
| ... | ... | @@ -54,7 +68,8 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } |
| 54 | 68 | } |
| 55 | 69 | |
| 56 | 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 | 73 | if (!existing) { |
| 59 | 74 | throw new Error(`Unknown terminal thread: ${input.threadId}`); |
| 60 | 75 | } |
| ... | ... | @@ -62,19 +77,22 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } |
| 62 | 77 | this.emit("event", { |
| 63 | 78 | type: "output", |
| 64 | 79 | threadId: input.threadId, |
| 80 | terminalId, | |
| 65 | 81 | createdAt: new Date().toISOString(), |
| 66 | 82 | data: input.data, |
| 67 | 83 | }); |
| 68 | 84 | }); |
| 69 | 85 | } |
| 70 | 86 | |
| 71 | async resize(_input: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {} | |
| 87 | async resize(_input: TerminalResizeInput): Promise<void> {} | |
| 72 | 88 | |
| 73 | async clear(input: TerminalThreadInput): Promise<void> { | |
| 89 | async clear(input: TerminalClearInput): Promise<void> { | |
| 90 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 74 | 91 | queueMicrotask(() => { |
| 75 | 92 | this.emit("event", { |
| 76 | 93 | type: "cleared", |
| 77 | 94 | threadId: input.threadId, |
| 95 | terminalId, | |
| 78 | 96 | createdAt: new Date().toISOString(), |
| 79 | 97 | }); |
| 80 | 98 | }); |
| ... | ... | @@ -82,8 +100,10 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } |
| 82 | 100 | |
| 83 | 101 | async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { |
| 84 | 102 | const now = new Date().toISOString(); |
| 103 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 85 | 104 | const snapshot: TerminalSessionSnapshot = { |
| 86 | 105 | threadId: input.threadId, |
| 106 | terminalId, | |
| 87 | 107 | cwd: input.cwd, |
| 88 | 108 | status: "running", |
| 89 | 109 | pid: 5252, |
| ... | ... | @@ -92,11 +112,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } |
| 92 | 112 | exitSignal: null, |
| 93 | 113 | updatedAt: now, |
| 94 | 114 | }; |
| 95 | this.sessions.set(input.threadId, snapshot); | |
| 115 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 96 | 116 | queueMicrotask(() => { |
| 97 | 117 | this.emit("event", { |
| 98 | 118 | type: "restarted", |
| 99 | 119 | threadId: input.threadId, |
| 120 | terminalId, | |
| 100 | 121 | createdAt: now, |
| 101 | 122 | snapshot, |
| 102 | 123 | }); |
| ... | ... | @@ -105,7 +126,15 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } |
| 105 | 126 | } |
| 106 | 127 | |
| 107 | 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 | } |
| 110 | 139 | |
| 111 | 140 | dispose(): void {} |
| ... | ... | @@ -340,6 +369,7 @@ describe("WebSocket Server", () => { |
| 340 | 369 | }); |
| 341 | 370 | expect(open.error).toBeUndefined(); |
| 342 | 371 | expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1"); |
| 372 | expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID); | |
| 343 | 373 | |
| 344 | 374 | const write = await sendRequest(ws, WS_METHODS.terminalWrite, { |
| 345 | 375 | threadId: "thread-1", |
| ... | ... | @@ -376,6 +406,7 @@ describe("WebSocket Server", () => { |
| 376 | 406 | const manualEvent: TerminalEvent = { |
| 377 | 407 | type: "output", |
| 378 | 408 | threadId: "thread-1", |
| 409 | terminalId: DEFAULT_TERMINAL_ID, | |
| 379 | 410 | createdAt: new Date().toISOString(), |
| 380 | 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 | import { isElectron } from "./env"; |
| 8 | 8 | import { DEFAULT_MODEL } from "./model-logic"; |
| 9 | 9 | import { StoreProvider, useStore } from "./store"; |
| 10 | import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; | |
| 10 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; | |
| 11 | 11 | import { onServerWelcome } from "./wsNativeApi"; |
| 12 | 12 | import { useNativeApi } from "./hooks/useNativeApi"; |
| 13 | 13 | |
| ... | ... | @@ -27,18 +27,6 @@ function EventRouter() { |
| 27 | 27 | }); |
| 28 | 28 | }, [api, dispatch]); |
| 29 | 29 | |
| 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 | 30 | return null; |
| 43 | 31 | } |
| 44 | 32 | |
| ... | ... | @@ -93,6 +81,10 @@ function AutoProjectBootstrap() { |
| 93 | 81 | model: DEFAULT_MODEL, |
| 94 | 82 | terminalOpen: false, |
| 95 | 83 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 84 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 85 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 86 | terminalLayout: "single", | |
| 87 | splitTerminalIds: [], | |
| 96 | 88 | session: null, |
| 97 | 89 | messages: [], |
| 98 | 90 | events: [], |
apps/web/src/components/ChatView.tsx+37-7| ... | ... | @@ -269,6 +269,36 @@ export default function ChatView() { |
| 269 | 269 | open: !isOpen, |
| 270 | 270 | }); |
| 271 | 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 | ); | |
| 272 | 302 | |
| 273 | 303 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 274 | 304 | if (mode === state.runtimeMode) return; |
| ... | ... | @@ -1451,7 +1481,14 @@ export default function ChatView() { |
| 1451 | 1481 | threadId={activeThread.id} |
| 1452 | 1482 | cwd={activeProject.cwd} |
| 1453 | 1483 | height={activeThread.terminalHeight} |
| 1484 | terminalIds={activeThread.terminalIds} | |
| 1485 | activeTerminalId={activeThread.activeTerminalId} | |
| 1486 | terminalLayout={activeThread.terminalLayout} | |
| 1487 | splitTerminalIds={activeThread.splitTerminalIds} | |
| 1454 | 1488 | focusRequestId={terminalFocusRequestId} |
| 1489 | onSplitTerminal={splitTerminal} | |
| 1490 | onNewTerminal={createNewTerminal} | |
| 1491 | onActiveTerminalChange={activateTerminal} | |
| 1455 | 1492 | onHeightChange={(height) => |
| 1456 | 1493 | dispatch({ |
| 1457 | 1494 | type: "SET_THREAD_TERMINAL_HEIGHT", |
| ... | ... | @@ -1459,13 +1496,6 @@ export default function ChatView() { |
| 1459 | 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 | )} |
| 1471 | 1501 |
apps/web/src/components/Sidebar.tsx+10-1| ... | ... | @@ -5,7 +5,12 @@ import { useTheme } from "../hooks/useTheme"; |
| 5 | 5 | import { DEFAULT_MODEL } from "../model-logic"; |
| 6 | 6 | import { derivePendingApprovals } from "../session-logic"; |
| 7 | 7 | import { useStore } from "../store"; |
| 8 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project, type Thread } from "../types"; | |
| 8 | import { | |
| 9 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 10 | DEFAULT_THREAD_TERMINAL_ID, | |
| 11 | type Project, | |
| 12 | type Thread, | |
| 13 | } from "../types"; | |
| 9 | 14 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 10 | 15 | |
| 11 | 16 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| ... | ... | @@ -112,6 +117,10 @@ export default function Sidebar() { |
| 112 | 117 | model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, |
| 113 | 118 | terminalOpen: false, |
| 114 | 119 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 120 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 121 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 122 | terminalLayout: "single", | |
| 123 | splitTerminalIds: [], | |
| 115 | 124 | session: null, |
| 116 | 125 | messages: [], |
| 117 | 126 | events: [], |
apps/web/src/components/ThreadTerminalDrawer.tsx+303-159| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | type PointerEvent as ReactPointerEvent, |
| 6 | 6 | useCallback, |
| 7 | 7 | useEffect, |
| 8 | useMemo, | |
| 8 | 9 | useRef, |
| 9 | 10 | useState, |
| 10 | 11 | } from "react"; |
| ... | ... | @@ -15,7 +16,11 @@ import { |
| 15 | 16 | resolvePathLinkTarget, |
| 16 | 17 | } from "../terminal-links"; |
| 17 | 18 | import { isTerminalClearShortcut } from "../terminal-shortcuts"; |
| 18 | import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types"; | |
| 19 | import { | |
| 20 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 21 | DEFAULT_THREAD_TERMINAL_ID, | |
| 22 | type ThreadTerminalLayout, | |
| 23 | } from "../types"; | |
| 19 | 24 | |
| 20 | 25 | const MIN_DRAWER_HEIGHT = 180; |
| 21 | 26 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | ... | @@ -103,162 +108,30 @@ function terminalThemeFromApp(): ITheme { |
| 103 | 108 | }; |
| 104 | 109 | } |
| 105 | 110 | |
| 106 | interface ThreadTerminalDrawerProps { | |
| 111 | interface TerminalViewportProps { | |
| 107 | 112 | api: NativeApi; |
| 108 | 113 | threadId: string; |
| 114 | terminalId: string; | |
| 109 | 115 | cwd: string; |
| 110 | height: number; | |
| 111 | 116 | focusRequestId: number; |
| 112 | onHeightChange: (height: number) => void; | |
| 113 | onThreadExited: () => void; | |
| 117 | autoFocus: boolean; | |
| 118 | resizeEpoch: number; | |
| 119 | drawerHeight: number; | |
| 114 | 120 | } |
| 115 | 121 | |
| 116 | export default function ThreadTerminalDrawer({ | |
| 122 | function TerminalViewport({ | |
| 117 | 123 | api, |
| 118 | 124 | threadId, |
| 125 | terminalId, | |
| 119 | 126 | cwd, |
| 120 | height, | |
| 121 | 127 | focusRequestId, |
| 122 | onHeightChange, | |
| 123 | onThreadExited, | |
| 124 | }: ThreadTerminalDrawerProps) { | |
| 125 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); | |
| 128 | autoFocus, | |
| 129 | resizeEpoch, | |
| 130 | drawerHeight, | |
| 131 | }: TerminalViewportProps) { | |
| 126 | 132 | const containerRef = useRef<HTMLDivElement>(null); |
| 127 | 133 | const terminalRef = useRef<Terminal | null>(null); |
| 128 | 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]); | |
| 262 | 135 | |
| 263 | 136 | useEffect(() => { |
| 264 | 137 | const mount = containerRef.current; |
| ... | ... | @@ -287,7 +160,7 @@ export default function ThreadTerminalDrawer({ |
| 287 | 160 | const activeTerminal = terminalRef.current; |
| 288 | 161 | if (!activeTerminal) return; |
| 289 | 162 | try { |
| 290 | await api.terminal.write({ threadId, data: "\u000c" }); | |
| 163 | await api.terminal.write({ threadId, terminalId, data: "\u000c" }); | |
| 291 | 164 | } catch (error) { |
| 292 | 165 | writeSystemMessage( |
| 293 | 166 | activeTerminal, |
| ... | ... | @@ -367,7 +240,7 @@ export default function ThreadTerminalDrawer({ |
| 367 | 240 | |
| 368 | 241 | const inputDisposable = terminal.onData((data) => { |
| 369 | 242 | void api.terminal |
| 370 | .write({ threadId, data }) | |
| 243 | .write({ threadId, terminalId, data }) | |
| 371 | 244 | .catch((err) => |
| 372 | 245 | writeSystemMessage( |
| 373 | 246 | terminal, |
| ... | ... | @@ -395,6 +268,7 @@ export default function ThreadTerminalDrawer({ |
| 395 | 268 | activeFitAddon.fit(); |
| 396 | 269 | const snapshot = await api.terminal.open({ |
| 397 | 270 | threadId, |
| 271 | terminalId, | |
| 398 | 272 | cwd, |
| 399 | 273 | cols: activeTerminal.cols, |
| 400 | 274 | rows: activeTerminal.rows, |
| ... | ... | @@ -404,9 +278,11 @@ export default function ThreadTerminalDrawer({ |
| 404 | 278 | if (snapshot.history.length > 0) { |
| 405 | 279 | activeTerminal.write(snapshot.history); |
| 406 | 280 | } |
| 407 | window.requestAnimationFrame(() => { | |
| 408 | activeTerminal.focus(); | |
| 409 | }); | |
| 281 | if (autoFocus) { | |
| 282 | window.requestAnimationFrame(() => { | |
| 283 | activeTerminal.focus(); | |
| 284 | }); | |
| 285 | } | |
| 410 | 286 | } catch (err) { |
| 411 | 287 | if (disposed) return; |
| 412 | 288 | writeSystemMessage( |
| ... | ... | @@ -417,7 +293,7 @@ export default function ThreadTerminalDrawer({ |
| 417 | 293 | }; |
| 418 | 294 | |
| 419 | 295 | const unsubscribe = api.terminal.onEvent((event) => { |
| 420 | if (event.threadId !== threadId) return; | |
| 296 | if (event.threadId !== threadId || event.terminalId !== terminalId) return; | |
| 421 | 297 | const activeTerminal = terminalRef.current; |
| 422 | 298 | if (!activeTerminal) return; |
| 423 | 299 | |
| ... | ... | @@ -446,12 +322,37 @@ export default function ThreadTerminalDrawer({ |
| 446 | 322 | } |
| 447 | 323 | |
| 448 | 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 | }); |
| 452 | 337 | |
| 453 | 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 | 356 | }, 30); |
| 456 | 357 | void openTerminal(); |
| 457 | 358 | |
| ... | ... | @@ -466,9 +367,10 @@ export default function ThreadTerminalDrawer({ |
| 466 | 367 | fitAddonRef.current = null; |
| 467 | 368 | terminal.dispose(); |
| 468 | 369 | }; |
| 469 | }, [api, cwd, fitAndResizeTerminal, threadId]); | |
| 370 | }, [api, autoFocus, cwd, terminalId, threadId]); | |
| 470 | 371 | |
| 471 | 372 | useEffect(() => { |
| 373 | if (!autoFocus) return; | |
| 472 | 374 | const terminal = terminalRef.current; |
| 473 | 375 | if (!terminal) return; |
| 474 | 376 | const frame = window.requestAnimationFrame(() => { |
| ... | ... | @@ -477,7 +379,7 @@ export default function ThreadTerminalDrawer({ |
| 477 | 379 | return () => { |
| 478 | 380 | window.cancelAnimationFrame(frame); |
| 479 | 381 | }; |
| 480 | }, [focusRequestId]); | |
| 382 | }, [autoFocus, focusRequestId]); | |
| 481 | 383 | |
| 482 | 384 | useEffect(() => { |
| 483 | 385 | const terminal = terminalRef.current; |
| ... | ... | @@ -492,6 +394,7 @@ export default function ThreadTerminalDrawer({ |
| 492 | 394 | void api.terminal |
| 493 | 395 | .resize({ |
| 494 | 396 | threadId, |
| 397 | terminalId, | |
| 495 | 398 | cols: terminal.cols, |
| 496 | 399 | rows: terminal.rows, |
| 497 | 400 | }) |
| ... | ... | @@ -500,7 +403,176 @@ export default function ThreadTerminalDrawer({ |
| 500 | 403 | return () => { |
| 501 | 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 | ||
| 411 | interface 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 | ||
| 427 | export 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]); | |
| 504 | 576 | |
| 505 | 577 | return ( |
| 506 | 578 | <aside |
| ... | ... | @@ -516,11 +588,83 @@ export default function ThreadTerminalDrawer({ |
| 516 | 588 | > |
| 517 | 589 | <div className="h-px w-10 rounded-full bg-border/80" /> |
| 518 | 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 | 638 | <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 | )} | |
| 524 | 668 | </div> |
| 525 | 669 | </aside> |
| 526 | 670 | ); |
apps/web/src/persistenceSchema.test.ts+28-1| ... | ... | @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { DEFAULT_MODEL } from "./model-logic"; |
| 4 | 4 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; | |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; | |
| 6 | 6 | import type { Thread } from "./types"; |
| 7 | 7 | |
| 8 | 8 | describe("hydratePersistedState", () => { |
| ... | ... | @@ -50,6 +50,10 @@ describe("hydratePersistedState", () => { |
| 50 | 50 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); |
| 51 | 51 | expect(hydrated?.threads[0]?.terminalOpen).toBe(false); |
| 52 | 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 | 57 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); |
| 54 | 58 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 55 | 59 | }); |
| ... | ... | @@ -92,6 +96,10 @@ describe("hydratePersistedState", () => { |
| 92 | 96 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); |
| 93 | 97 | expect(hydrated?.threads[0]?.terminalOpen).toBe(false); |
| 94 | 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 | 103 | expect(hydrated?.activeThreadId).toBe("t-1"); |
| 96 | 104 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 97 | 105 | }); |
| ... | ... | @@ -139,6 +147,10 @@ describe("hydratePersistedState", () => { |
| 139 | 147 | model: "gpt-5.3-codex", |
| 140 | 148 | terminalOpen: true, |
| 141 | 149 | terminalHeight: 360, |
| 150 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 151 | activeTerminalId: "term-2", | |
| 152 | terminalLayout: "tabs", | |
| 153 | splitTerminalIds: [], | |
| 142 | 154 | messages: [], |
| 143 | 155 | createdAt: "2026-02-08T10:00:00.000Z", |
| 144 | 156 | lastVisitedAt: "2026-02-08T10:01:00.000Z", |
| ... | ... | @@ -150,6 +162,10 @@ describe("hydratePersistedState", () => { |
| 150 | 162 | const hydrated = hydratePersistedState(payload, false); |
| 151 | 163 | expect(hydrated?.threads[0]?.terminalOpen).toBe(true); |
| 152 | 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 | 169 | expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); |
| 154 | 170 | }); |
| 155 | 171 | |
| ... | ... | @@ -183,6 +199,9 @@ describe("hydratePersistedState", () => { |
| 183 | 199 | |
| 184 | 200 | const hydrated = hydratePersistedState(payload, false); |
| 185 | 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 | }); |
| 188 | 207 | |
| ... | ... | @@ -196,6 +215,10 @@ describe("toPersistedState", () => { |
| 196 | 215 | model: "gpt-5.3-codex", |
| 197 | 216 | terminalOpen: true, |
| 198 | 217 | terminalHeight: 320, |
| 218 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 219 | activeTerminalId: "term-2", | |
| 220 | terminalLayout: "tabs", | |
| 221 | splitTerminalIds: [], | |
| 199 | 222 | session: null, |
| 200 | 223 | messages: [ |
| 201 | 224 | { |
| ... | ... | @@ -249,6 +272,10 @@ describe("toPersistedState", () => { |
| 249 | 272 | model: "gpt-5.3-codex", |
| 250 | 273 | terminalOpen: true, |
| 251 | 274 | terminalHeight: 320, |
| 275 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 276 | activeTerminalId: "term-2", | |
| 277 | terminalLayout: "tabs", | |
| 278 | splitTerminalIds: [], | |
| 252 | 279 | messages: [ |
| 253 | 280 | { |
| 254 | 281 | id: "m-1", |
apps/web/src/persistenceSchema.ts+40| ... | ... | @@ -3,6 +3,7 @@ import { z } from "zod"; |
| 3 | 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 4 | 4 | import { |
| 5 | 5 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 6 | DEFAULT_THREAD_TERMINAL_ID, | |
| 6 | 7 | DEFAULT_RUNTIME_MODE, |
| 7 | 8 | type Project, |
| 8 | 9 | type RuntimeMode, |
| ... | ... | @@ -48,6 +49,10 @@ const persistedThreadSchema = z.object({ |
| 48 | 49 | terminalHeight: z.number().int().min(120).max(4_096).default( |
| 49 | 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 | 56 | messages: z.array(persistedMessageSchema), |
| 52 | 57 | createdAt: z.string().min(1), |
| 53 | 58 | lastVisitedAt: z.string().min(1).optional(), |
| ... | ... | @@ -129,6 +134,33 @@ function hydrateThread( |
| 129 | 134 | thread: z.infer<typeof persistedThreadSchema>, |
| 130 | 135 | isLegacyPayload: boolean, |
| 131 | 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 | 164 | return { |
| 133 | 165 | id: thread.id, |
| 134 | 166 | codexThreadId: thread.codexThreadId, |
| ... | ... | @@ -137,6 +169,10 @@ function hydrateThread( |
| 137 | 169 | model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), |
| 138 | 170 | terminalOpen: thread.terminalOpen ?? false, |
| 139 | 171 | terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 172 | terminalIds: safeTerminalIds, | |
| 173 | activeTerminalId, | |
| 174 | terminalLayout, | |
| 175 | splitTerminalIds, | |
| 140 | 176 | session: null, |
| 141 | 177 | messages: thread.messages.map((message) => { |
| 142 | 178 | const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); |
| ... | ... | @@ -211,6 +247,10 @@ export function toPersistedState( |
| 211 | 247 | model: thread.model, |
| 212 | 248 | terminalOpen: thread.terminalOpen, |
| 213 | 249 | terminalHeight: thread.terminalHeight, |
| 250 | terminalIds: thread.terminalIds, | |
| 251 | activeTerminalId: thread.activeTerminalId, | |
| 252 | terminalLayout: thread.terminalLayout, | |
| 253 | splitTerminalIds: thread.splitTerminalIds, | |
| 214 | 254 | messages: thread.messages.map((message) => ({ |
| 215 | 255 | id: message.id, |
| 216 | 256 | role: message.role, |
apps/web/src/store.test.ts+50-1| ... | ... | @@ -2,7 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { type AppState, reducer } from "./store"; |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; | |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; | |
| 6 | 6 | import type { Thread } from "./types"; |
| 7 | 7 | |
| 8 | 8 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { |
| ... | ... | @@ -37,6 +37,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 37 | 37 | model: "gpt-5.3-codex", |
| 38 | 38 | terminalOpen: false, |
| 39 | 39 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 40 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 41 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 42 | terminalLayout: "single", | |
| 43 | splitTerminalIds: [], | |
| 40 | 44 | session: makeSession(), |
| 41 | 45 | messages: [], |
| 42 | 46 | events: [], |
| ... | ... | @@ -111,6 +115,51 @@ describe("store reducer thread continuity", () => { |
| 111 | 115 | expect(next.threads[0]?.terminalHeight).toBe(360); |
| 112 | 116 | }); |
| 113 | 117 | |
| 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 | 163 | it("backfills codexThreadId from routed provider events", () => { |
| 115 | 164 | const state = makeState(makeThread({ codexThreadId: null })); |
| 116 | 165 | const next = reducer(state, { |
apps/web/src/store.ts+127-11| ... | ... | @@ -14,6 +14,7 @@ import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; |
| 15 | 15 | import { |
| 16 | 16 | type ChatAttachment, |
| 17 | DEFAULT_THREAD_TERMINAL_ID, | |
| 17 | 18 | DEFAULT_RUNTIME_MODE, |
| 18 | 19 | type Project, |
| 19 | 20 | type RuntimeMode, |
| ... | ... | @@ -31,6 +32,9 @@ type Action = |
| 31 | 32 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 32 | 33 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| 33 | 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 | 38 | | { type: "TOGGLE_DIFF" } |
| 35 | 39 | | { |
| 36 | 40 | type: "APPLY_EVENT"; |
| ... | ... | @@ -133,6 +137,58 @@ function updateThread( |
| 133 | 137 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 134 | 138 | } |
| 135 | 139 | |
| 140 | function 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 | ||
| 151 | function 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 | ||
| 136 | 192 | function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { |
| 137 | 193 | return threads.find((t) => t.session?.sessionId === sessionId); |
| 138 | 194 | } |
| ... | ... | @@ -259,10 +315,10 @@ export function reducer(state: AppState, action: Action): AppState { |
| 259 | 315 | if (!previousProject) return null; |
| 260 | 316 | const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); |
| 261 | 317 | if (!mappedProjectId) return null; |
| 262 | return { | |
| 318 | return normalizeThreadTerminals({ | |
| 263 | 319 | ...thread, |
| 264 | 320 | projectId: mappedProjectId, |
| 265 | }; | |
| 321 | }); | |
| 266 | 322 | }) |
| 267 | 323 | .filter((thread): thread is Thread => thread !== null); |
| 268 | 324 | const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) |
| ... | ... | @@ -285,19 +341,18 @@ export function reducer(state: AppState, action: Action): AppState { |
| 285 | 341 | ), |
| 286 | 342 | }; |
| 287 | 343 | |
| 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 | 350 | return { |
| 290 | 351 | ...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], | |
| 299 | 353 | activeThreadId: action.thread.id, |
| 300 | 354 | }; |
| 355 | } | |
| 301 | 356 | |
| 302 | 357 | case "SET_ACTIVE_THREAD": { |
| 303 | 358 | const visitedAt = new Date().toISOString(); |
| ... | ... | @@ -338,6 +393,67 @@ export function reducer(state: AppState, action: Action): AppState { |
| 338 | 393 | })), |
| 339 | 394 | }; |
| 340 | 395 | |
| 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 | 457 | case "TOGGLE_DIFF": |
| 342 | 458 | return { ...state, diffOpen: !state.diffOpen }; |
| 343 | 459 |
apps/web/src/types.ts+6| ... | ... | @@ -4,6 +4,8 @@ export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 4 | 4 | export type RuntimeMode = "approval-required" | "full-access"; |
| 5 | 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | 6 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 7 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; | |
| 8 | export type ThreadTerminalLayout = "single" | "split" | "tabs"; | |
| 7 | 9 | |
| 8 | 10 | export interface ChatImageAttachment { |
| 9 | 11 | type: "image"; |
| ... | ... | @@ -41,6 +43,10 @@ export interface Thread { |
| 41 | 43 | model: string; |
| 42 | 44 | terminalOpen: boolean; |
| 43 | 45 | terminalHeight: number; |
| 46 | terminalIds: string[]; | |
| 47 | activeTerminalId: string; | |
| 48 | terminalLayout: ThreadTerminalLayout; | |
| 49 | splitTerminalIds: string[]; | |
| 44 | 50 | session: ProviderSession | null; |
| 45 | 51 | messages: ChatMessage[]; |
| 46 | 52 | events: ProviderEvent[]; |
packages/contracts/src/ipc.ts+2-2| ... | ... | @@ -30,12 +30,12 @@ import type { |
| 30 | 30 | ProjectRemoveInput, |
| 31 | 31 | } from "./project"; |
| 32 | 32 | import type { |
| 33 | TerminalClearInput, | |
| 33 | 34 | TerminalCloseInput, |
| 34 | 35 | TerminalEvent, |
| 35 | 36 | TerminalOpenInput, |
| 36 | 37 | TerminalResizeInput, |
| 37 | 38 | TerminalSessionSnapshot, |
| 38 | TerminalThreadInput, | |
| 39 | 39 | TerminalWriteInput, |
| 40 | 40 | } from "./terminal"; |
| 41 | 41 | import type { NewTodoInput, Todo } from "./todo"; |
| ... | ... | @@ -61,7 +61,7 @@ export interface NativeApi { |
| 61 | 61 | open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>; |
| 62 | 62 | write: (input: TerminalWriteInput) => Promise<void>; |
| 63 | 63 | resize: (input: TerminalResizeInput) => Promise<void>; |
| 64 | clear: (input: TerminalThreadInput) => Promise<void>; | |
| 64 | clear: (input: TerminalClearInput) => Promise<void>; | |
| 65 | 65 | restart: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>; |
| 66 | 66 | close: (input: TerminalCloseInput) => Promise<void>; |
| 67 | 67 | onEvent: (callback: (event: TerminalEvent) => void) => () => void; |
packages/contracts/src/terminal.test.ts+24| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | DEFAULT_TERMINAL_ID, | |
| 5 | terminalClearInputSchema, | |
| 4 | 6 | terminalCloseInputSchema, |
| 5 | 7 | terminalEventSchema, |
| 6 | 8 | terminalOpenInputSchema, |
| ... | ... | @@ -30,6 +32,16 @@ describe("terminalOpenInputSchema", () => { |
| 30 | 32 | }); |
| 31 | 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 | }); |
| 34 | 46 | |
| 35 | 47 | describe("terminalWriteInputSchema", () => { |
| ... | ... | @@ -68,6 +80,15 @@ describe("terminalResizeInputSchema", () => { |
| 68 | 80 | }); |
| 69 | 81 | }); |
| 70 | 82 | |
| 83 | describe("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 | ||
| 71 | 92 | describe("terminalCloseInputSchema", () => { |
| 72 | 93 | it("accepts optional deleteHistory", () => { |
| 73 | 94 | const result = terminalCloseInputSchema.safeParse({ |
| ... | ... | @@ -82,6 +103,7 @@ describe("terminalSessionSnapshotSchema", () => { |
| 82 | 103 | it("accepts running snapshots", () => { |
| 83 | 104 | const result = terminalSessionSnapshotSchema.safeParse({ |
| 84 | 105 | threadId: "thread-1", |
| 106 | terminalId: DEFAULT_TERMINAL_ID, | |
| 85 | 107 | cwd: "/tmp/project", |
| 86 | 108 | status: "running", |
| 87 | 109 | pid: 1234, |
| ... | ... | @@ -99,6 +121,7 @@ describe("terminalEventSchema", () => { |
| 99 | 121 | const result = terminalEventSchema.safeParse({ |
| 100 | 122 | type: "output", |
| 101 | 123 | threadId: "thread-1", |
| 124 | terminalId: DEFAULT_TERMINAL_ID, | |
| 102 | 125 | createdAt: new Date().toISOString(), |
| 103 | 126 | data: "line\n", |
| 104 | 127 | }); |
| ... | ... | @@ -109,6 +132,7 @@ describe("terminalEventSchema", () => { |
| 109 | 132 | const result = terminalEventSchema.safeParse({ |
| 110 | 133 | type: "exited", |
| 111 | 134 | threadId: "thread-1", |
| 135 | terminalId: DEFAULT_TERMINAL_ID, | |
| 112 | 136 | createdAt: new Date().toISOString(), |
| 113 | 137 | exitCode: 0, |
| 114 | 138 | exitSignal: null, |
packages/contracts/src/terminal.ts+17-3| ... | ... | @@ -1,28 +1,38 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | export const DEFAULT_TERMINAL_ID = "default"; | |
| 4 | ||
| 3 | 5 | const terminalColsSchema = z.number().int().min(20).max(400); |
| 4 | 6 | const terminalRowsSchema = z.number().int().min(5).max(200); |
| 7 | const terminalIdSchema = z.string().trim().min(1).max(128); | |
| 5 | 8 | |
| 6 | 9 | export const terminalThreadInputSchema = z.object({ |
| 7 | 10 | threadId: z.string().trim().min(1), |
| 8 | 11 | }); |
| 9 | 12 | |
| 10 | export const terminalOpenInputSchema = terminalThreadInputSchema.extend({ | |
| 13 | export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ | |
| 14 | terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID), | |
| 15 | }); | |
| 16 | ||
| 17 | export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ | |
| 11 | 18 | cwd: z.string().trim().min(1), |
| 12 | 19 | cols: terminalColsSchema, |
| 13 | 20 | rows: terminalRowsSchema, |
| 14 | 21 | }); |
| 15 | 22 | |
| 16 | export const terminalWriteInputSchema = terminalThreadInputSchema.extend({ | |
| 23 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ | |
| 17 | 24 | data: z.string().min(1).max(65_536), |
| 18 | 25 | }); |
| 19 | 26 | |
| 20 | export const terminalResizeInputSchema = terminalThreadInputSchema.extend({ | |
| 27 | export const terminalResizeInputSchema = terminalSessionInputSchema.extend({ | |
| 21 | 28 | cols: terminalColsSchema, |
| 22 | 29 | rows: terminalRowsSchema, |
| 23 | 30 | }); |
| 24 | 31 | |
| 32 | export const terminalClearInputSchema = terminalSessionInputSchema; | |
| 33 | ||
| 25 | 34 | export const terminalCloseInputSchema = terminalThreadInputSchema.extend({ |
| 35 | terminalId: terminalIdSchema.optional(), | |
| 26 | 36 | deleteHistory: z.boolean().optional(), |
| 27 | 37 | }); |
| 28 | 38 | |
| ... | ... | @@ -35,6 +45,7 @@ export const terminalSessionStatusSchema = z.enum([ |
| 35 | 45 | |
| 36 | 46 | export const terminalSessionSnapshotSchema = z.object({ |
| 37 | 47 | threadId: z.string().min(1), |
| 48 | terminalId: z.string().min(1), | |
| 38 | 49 | cwd: z.string().min(1), |
| 39 | 50 | status: terminalSessionStatusSchema, |
| 40 | 51 | pid: z.number().int().positive().nullable(), |
| ... | ... | @@ -46,6 +57,7 @@ export const terminalSessionSnapshotSchema = z.object({ |
| 46 | 57 | |
| 47 | 58 | const terminalEventBaseSchema = z.object({ |
| 48 | 59 | threadId: z.string().min(1), |
| 60 | terminalId: z.string().min(1), | |
| 49 | 61 | createdAt: z.string().datetime(), |
| 50 | 62 | }); |
| 51 | 63 | |
| ... | ... | @@ -89,9 +101,11 @@ export const terminalEventSchema = z.discriminatedUnion("type", [ |
| 89 | 101 | ]); |
| 90 | 102 | |
| 91 | 103 | export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>; |
| 104 | export type TerminalSessionInput = z.input<typeof terminalSessionInputSchema>; | |
| 92 | 105 | export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>; |
| 93 | 106 | export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>; |
| 94 | 107 | export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>; |
| 108 | export type TerminalClearInput = z.input<typeof terminalClearInputSchema>; | |
| 95 | 109 | export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>; |
| 96 | 110 | export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>; |
| 97 | 111 | export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>; |