Skip to content

Commit 56093dc

Browse files
OpenSource03claude
andcommitted
feat: terminal persistence, chat scroll overhaul, and notification reliability
- Terminal sessions survive tab/panel switching with snapshot-based hydration and seq ordering - Terminal tab state persisted to localStorage and reconciled with live PTY processes on startup - Chat scroll now uses user-intent-based bottom lock instead of proximity-only auto-follow - Notification system prevents false completion alerts on stops, interrupts, reverts, and chat switches - Tool group animations only trigger when the view previously rendered a tool as standalone - Extracted pure utilities with tests: chat-scroll, notification-utils, terminal-history, terminal-tabs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6ff4609 commit 56093dc

26 files changed

Lines changed: 962 additions & 140 deletions

electron/src/ipc/terminal.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { BrowserWindow, ipcMain } from "electron";
22
import crypto from "crypto";
33
import { log } from "../lib/logger";
44
import { safeSend } from "../lib/safe-send";
5+
import {
6+
appendTerminalHistory,
7+
EMPTY_TERMINAL_HISTORY,
8+
readTerminalHistory,
9+
} from "../lib/terminal-history";
10+
import type { TerminalHistoryState } from "../lib/terminal-history";
511

612
interface TerminalEntry {
713
pty: {
@@ -14,6 +20,12 @@ interface TerminalEntry {
1420
cols: number;
1521
rows: number;
1622
spaceId: string;
23+
createdAt: number;
24+
history: TerminalHistoryState;
25+
seq: number;
26+
exited: boolean;
27+
exitCode: number | null;
28+
destroyed: boolean;
1729
}
1830

1931
export const terminals = new Map<string, TerminalEntry>();
@@ -46,15 +58,40 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
4658
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
4759
});
4860

49-
terminals.set(terminalId, { pty: ptyProcess, cols: cols || 80, rows: rows || 24, spaceId: spaceId || "default" });
61+
const entry: TerminalEntry = {
62+
pty: ptyProcess,
63+
cols: cols || 80,
64+
rows: rows || 24,
65+
spaceId: spaceId || "default",
66+
createdAt: Date.now(),
67+
history: EMPTY_TERMINAL_HISTORY,
68+
seq: 0,
69+
exited: false,
70+
exitCode: null,
71+
destroyed: false,
72+
};
73+
terminals.set(terminalId, entry);
5074

5175
ptyProcess.onData((data: string) => {
52-
safeSend(getMainWindow, "terminal:data", { terminalId, data });
76+
if (entry.destroyed) return;
77+
entry.history = appendTerminalHistory(entry.history, data);
78+
entry.seq += 1;
79+
safeSend(getMainWindow, "terminal:data", { terminalId, data, seq: entry.seq });
5380
});
5481

5582
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
83+
if (entry.destroyed) return;
5684
log("TERMINAL", `Terminal ${terminalId.slice(0, 8)} exited with code ${exitCode}`);
57-
terminals.delete(terminalId);
85+
entry.exited = true;
86+
entry.exitCode = exitCode;
87+
const exitNotice = "\r\n\x1b[2m[process exited]\x1b[0m\r\n";
88+
entry.history = appendTerminalHistory(entry.history, exitNotice);
89+
entry.seq += 1;
90+
safeSend(getMainWindow, "terminal:data", {
91+
terminalId,
92+
data: exitNotice,
93+
seq: entry.seq,
94+
});
5895
safeSend(getMainWindow, "terminal:exit", { terminalId, exitCode });
5996
});
6097

@@ -70,13 +107,19 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
70107
ipcMain.handle("terminal:write", (_event, { terminalId, data }: { terminalId: string; data: string }) => {
71108
const term = terminals.get(terminalId);
72109
if (!term) return { error: "Terminal not found" };
110+
if (term.exited) return { error: "Terminal has exited" };
73111
term.pty.write(data);
74112
return { ok: true };
75113
});
76114

77115
ipcMain.handle("terminal:resize", (_event, { terminalId, cols, rows }: { terminalId: string; cols: number; rows: number }) => {
78116
const term = terminals.get(terminalId);
79117
if (!term) return { error: "Terminal not found" };
118+
if (term.exited) {
119+
term.cols = cols;
120+
term.rows = rows;
121+
return { ok: true };
122+
}
80123
try {
81124
term.pty.resize(cols, rows);
82125
term.cols = cols;
@@ -87,10 +130,36 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
87130
return { ok: true };
88131
});
89132

133+
ipcMain.handle("terminal:snapshot", (_event, terminalId: string) => {
134+
const term = terminals.get(terminalId);
135+
if (!term) return { error: "Terminal not found" };
136+
return {
137+
output: readTerminalHistory(term.history),
138+
seq: term.seq,
139+
exited: term.exited,
140+
exitCode: term.exitCode,
141+
};
142+
});
143+
144+
ipcMain.handle("terminal:list", () => {
145+
return {
146+
terminals: Array.from(terminals.entries())
147+
.map(([terminalId, term]) => ({
148+
terminalId,
149+
spaceId: term.spaceId,
150+
createdAt: term.createdAt,
151+
exited: term.exited,
152+
exitCode: term.exitCode,
153+
}))
154+
.sort((a, b) => a.createdAt - b.createdAt),
155+
};
156+
});
157+
90158
ipcMain.handle("terminal:destroy", (_event, terminalId: string) => {
91159
const term = terminals.get(terminalId);
92160
if (term) {
93-
term.pty.kill();
161+
term.destroyed = true;
162+
if (!term.exited) term.pty.kill();
94163
terminals.delete(terminalId);
95164
log("TERMINAL", `Destroyed terminal ${terminalId.slice(0, 8)}`);
96165
}
@@ -100,7 +169,8 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
100169
ipcMain.handle("terminal:destroy-space", (_event, spaceId: string) => {
101170
for (const [terminalId, term] of terminals.entries()) {
102171
if (term.spaceId !== spaceId) continue;
103-
term.pty.kill();
172+
term.destroyed = true;
173+
if (!term.exited) term.pty.kill();
104174
terminals.delete(terminalId);
105175
log("TERMINAL", `Destroyed terminal ${terminalId.slice(0, 8)} for space ${spaceId}`);
106176
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
BOTTOM_LOCK_THRESHOLD_PX,
4+
getDistanceFromBottom,
5+
isWithinBottomLockThreshold,
6+
shouldUnlockBottomLock,
7+
} from "../../../../src/lib/chat-scroll";
8+
9+
describe("chat scroll helpers", () => {
10+
it("computes exact distance from the bottom", () => {
11+
expect(getDistanceFromBottom({
12+
scrollTop: 700,
13+
scrollHeight: 1000,
14+
clientHeight: 300,
15+
})).toBe(0);
16+
});
17+
18+
it("treats near-bottom viewports as bottom-locked", () => {
19+
expect(isWithinBottomLockThreshold({
20+
scrollTop: 660,
21+
scrollHeight: 1000,
22+
clientHeight: 300,
23+
})).toBe(true);
24+
});
25+
26+
it("does not unlock from passive content growth alone", () => {
27+
expect(shouldUnlockBottomLock({
28+
scrollTop: 600,
29+
scrollHeight: 1000,
30+
clientHeight: 300,
31+
hasRecentUserIntent: false,
32+
})).toBe(false);
33+
});
34+
35+
it("unlocks only after user-originated upward scroll leaves the threshold", () => {
36+
expect(shouldUnlockBottomLock({
37+
scrollTop: 500,
38+
scrollHeight: 1000,
39+
clientHeight: 300,
40+
hasRecentUserIntent: true,
41+
})).toBe(true);
42+
});
43+
44+
it("re-locks when the viewport returns within the threshold", () => {
45+
expect(isWithinBottomLockThreshold({
46+
scrollTop: 1000 - 300 - BOTTOM_LOCK_THRESHOLD_PX,
47+
scrollHeight: 1000,
48+
clientHeight: 300,
49+
})).toBe(true);
50+
});
51+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
advanceSessionCompletionTracker,
4+
consumeSuppressedSessionCompletion,
5+
resetNotificationStateForTesting,
6+
suppressNextSessionCompletion,
7+
shouldNotifyPermissionRequest,
8+
} from "../../../../src/lib/notification-utils";
9+
10+
beforeEach(() => {
11+
resetNotificationStateForTesting();
12+
});
13+
14+
describe("advanceSessionCompletionTracker", () => {
15+
it("marks a real completion in the same session", () => {
16+
expect(advanceSessionCompletionTracker(
17+
{ sessionId: "session-a", isProcessing: true },
18+
{ sessionId: "session-a", isProcessing: false },
19+
)).toEqual({
20+
completed: true,
21+
tracked: { sessionId: "session-a", isProcessing: false },
22+
});
23+
});
24+
25+
it("resets tracking when switching from a busy session to a different idle session", () => {
26+
expect(advanceSessionCompletionTracker(
27+
{ sessionId: "session-a", isProcessing: true },
28+
{ sessionId: "session-b", isProcessing: false },
29+
)).toEqual({
30+
completed: false,
31+
tracked: { sessionId: "session-b", isProcessing: false },
32+
});
33+
});
34+
35+
it("drops carried-over busy state on the first render after switching chats", () => {
36+
const firstRender = advanceSessionCompletionTracker(
37+
{ sessionId: "session-a", isProcessing: true },
38+
{ sessionId: "session-b", isProcessing: true },
39+
);
40+
41+
expect(firstRender).toEqual({
42+
completed: false,
43+
tracked: { sessionId: "session-b", isProcessing: false },
44+
});
45+
46+
expect(advanceSessionCompletionTracker(
47+
firstRender.tracked,
48+
{ sessionId: "session-b", isProcessing: false },
49+
)).toEqual({
50+
completed: false,
51+
tracked: { sessionId: "session-b", isProcessing: false },
52+
});
53+
});
54+
});
55+
56+
describe("shouldNotifyPermissionRequest", () => {
57+
it("fires once for a given session/request pair", () => {
58+
const seen = new Set<string>();
59+
60+
expect(shouldNotifyPermissionRequest(seen, {
61+
sessionId: "session-a",
62+
requestId: "req-1",
63+
})).toBe(true);
64+
65+
expect(shouldNotifyPermissionRequest(seen, {
66+
sessionId: "session-a",
67+
requestId: "req-1",
68+
})).toBe(false);
69+
});
70+
71+
it("suppresses replay when the same open request moves between foreground and background", () => {
72+
const seen = new Set<string>();
73+
74+
expect(shouldNotifyPermissionRequest(seen, {
75+
sessionId: "session-a",
76+
requestId: "req-1",
77+
})).toBe(true);
78+
79+
expect(shouldNotifyPermissionRequest(seen, {
80+
sessionId: "session-a",
81+
requestId: "req-1",
82+
})).toBe(false);
83+
});
84+
85+
it("allows different sessions or requests to notify independently", () => {
86+
const seen = new Set<string>();
87+
88+
expect(shouldNotifyPermissionRequest(seen, {
89+
sessionId: "session-a",
90+
requestId: "req-1",
91+
})).toBe(true);
92+
93+
expect(shouldNotifyPermissionRequest(seen, {
94+
sessionId: "session-a",
95+
requestId: "req-2",
96+
})).toBe(true);
97+
98+
expect(shouldNotifyPermissionRequest(seen, {
99+
sessionId: "session-b",
100+
requestId: "req-1",
101+
})).toBe(true);
102+
});
103+
});
104+
105+
describe("session completion suppression", () => {
106+
it("consumes one suppressed completion per session", () => {
107+
suppressNextSessionCompletion("session-a");
108+
109+
expect(consumeSuppressedSessionCompletion("session-a")).toBe(true);
110+
expect(consumeSuppressedSessionCompletion("session-a")).toBe(false);
111+
});
112+
113+
it("tracks repeated suppressions independently", () => {
114+
suppressNextSessionCompletion("session-a");
115+
suppressNextSessionCompletion("session-a");
116+
117+
expect(consumeSuppressedSessionCompletion("session-a")).toBe(true);
118+
expect(consumeSuppressedSessionCompletion("session-a")).toBe(true);
119+
expect(consumeSuppressedSessionCompletion("session-a")).toBe(false);
120+
});
121+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
appendTerminalHistory,
4+
EMPTY_TERMINAL_HISTORY,
5+
readTerminalHistory,
6+
} from "../terminal-history";
7+
8+
describe("terminal history", () => {
9+
it("accumulates output chunks in order", () => {
10+
const history = appendTerminalHistory(
11+
appendTerminalHistory(EMPTY_TERMINAL_HISTORY, "hello "),
12+
"world",
13+
);
14+
15+
expect(readTerminalHistory(history)).toBe("hello world");
16+
});
17+
18+
it("drops oldest chunks when the buffer exceeds the limit", () => {
19+
let history = EMPTY_TERMINAL_HISTORY;
20+
history = appendTerminalHistory(history, "abc", 5);
21+
history = appendTerminalHistory(history, "de", 5);
22+
history = appendTerminalHistory(history, "fg", 5);
23+
24+
expect(readTerminalHistory(history)).toBe("defg");
25+
});
26+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
parseStoredTerminalState,
4+
reconcileTerminalState,
5+
} from "../../../../src/lib/terminal-tabs";
6+
7+
describe("terminal tabs state", () => {
8+
it("keeps persisted tab metadata for live terminals and drops stale ones", () => {
9+
const persisted = parseStoredTerminalState(JSON.stringify({
10+
default: {
11+
tabs: [
12+
{ id: "term-a", terminalId: "term-a", label: "Build" },
13+
{ id: "term-stale", terminalId: "term-stale", label: "Old" },
14+
],
15+
activeTabId: "term-a",
16+
},
17+
}));
18+
19+
expect(reconcileTerminalState(persisted, [
20+
{ terminalId: "term-a", spaceId: "default", createdAt: 1 },
21+
])).toEqual({
22+
default: {
23+
tabs: [
24+
{ id: "term-a", terminalId: "term-a", label: "Build" },
25+
],
26+
activeTabId: "term-a",
27+
},
28+
});
29+
});
30+
31+
it("recovers live terminals missing from persisted state without duplicates", () => {
32+
expect(reconcileTerminalState({}, [
33+
{ terminalId: "term-a", spaceId: "default", createdAt: 1 },
34+
{ terminalId: "term-b", spaceId: "default", createdAt: 2 },
35+
])).toEqual({
36+
default: {
37+
tabs: [
38+
{ id: "term-a", terminalId: "term-a", label: "Terminal 1" },
39+
{ id: "term-b", terminalId: "term-b", label: "Terminal 2" },
40+
],
41+
activeTabId: "term-b",
42+
},
43+
});
44+
});
45+
});

0 commit comments

Comments
 (0)