Skip to content

Commit ec9432f

Browse files
dorlugasigalCopilot
andcommitted
feat(frontend): theme-aware particle dissolve + spinner spawn loader
Replace abrupt session removal with a canvas-based "Thanos snap" particle dispersion: edge-biased, omnidirectional, theme-coloured fine dust drifting outward. Two presets — `card` (long & dramatic, ~260 particles, 1500ms) for SessionsHub, `tab` (short & snappy, ~90 particles, 700ms) for TabBar + SidePanel. Particle palette is built live from session.color + the active theme's --accent and --text CSS vars (exponential weight decay) so the dust stays coherent with whatever theme is active. New particleDissolve preference (default true) gates the canvas effect; the host CSS fade still plays so the row always exits smoothly. Replace the blank pre-paint state on freshly spawned terminal sessions with a Braille dots spinner — the same loader idiom used by npm/cargo/yarn/gh — gated by a 150ms show-delay so fast localhost spawns never flicker. Drives off the first xterm onWriteParsed event (true first paint) rather than the WebSocket attach ack, and fades gracefully on first byte. Coordinated via a shared dissolvingIds Set on useSessionStore + a useDissolveDelete hook that fires API DELETE in parallel with the visual, removes the row from local/store state once the host fade completes, and shields the polling loops in TerminalApp + SessionsHub so the row stays mounted long enough for the animation to play out. Hero-source rule means only the surface the user clicked emits canvas particles; mirror surfaces play the CSS fade only. Concurrency cap at 3 active canvases. Honors prefers-reduced-motion throughout. Also fixes two flaky test categories surfaced while developing this: * macOS dev-machine flake — file-tree.test.js, preview.test.js, config.test.js, tunnel-token-renewal.test.js all called createTermBeamServer without isolating configDir, so on developer machines they would read ~/.termbeam/prefs.json and auto-spawn workspace sessions whose cwd overrode the test fixture. Each test now creates a per-server mkdtempSync configDir cleaned up in after(). * Windows CI 180s timeout — node-pty ConPTY threads persist after pty.kill() and keep the per-file worker alive past the test-timeout. npm test + npm run test:coverage now pass --test-force-exit, Node's supported flag for forcing worker exit after all tests complete. Documentation updates in copilot-instructions.md cover both invariants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent db315cc commit ec9432f

24 files changed

Lines changed: 1046 additions & 31 deletions

.github/copilot-instructions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Pre-commit hooks (Husky + lint-staged) auto-format and syntax-check staged files
3535
- **`test/cli/resume.test.js`** — uses `TERMBEAM_CONFIG_DIR` env var pointing to a temp directory for isolation.
3636
- **WebSocket connections** — close in `finally` blocks or `after()` hooks to prevent connection leaks.
3737
- **Windows temp directory cleanup** — on Windows, node-pty ConPTY holds directory locks after `pty.kill()`. Use `await safeCleanup(dir)` (async `fs.promises.rm` with `maxRetries`) instead of `fs.rmSync` in `after()` hooks and `finally` blocks when the temp dir was used as a PTY CWD. See the `safeCleanup()` helper in `test/server/routes.test.js`.
38+
- **`configDir` isolation** — every test that calls `createTermBeamServer({ config })` MUST set `configDir` (or `TERMBEAM_CONFIG_DIR` env var) to a fresh `mkdtempSync` temp directory. Without this, the server reads the developer's real `~/.termbeam/prefs.json` and may auto-spawn workspace sessions with a different `cwd`, silently breaking assertions about the default session. `routes.test.js`'s `startServer()` is the canonical example.
39+
- **`--test-force-exit`**`npm test` passes this flag so dangling Windows ConPTY threads can't keep the worker alive past the 180 s per-file timeout. Tests that legitimately need to leak resources for assertions should be flagged in the suite description.
3840

3941
**Port isolation:** Integration tests use port `0` (OS-assigned random port) to avoid conflicts. Never hardcode ports in tests.
4042

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
"scripts": {
1010
"start": "node bin/termbeam.js",
1111
"dev": "node bin/termbeam.js --generate-password",
12-
"test": "node -e \"const{execFileSync:r}=require('child_process'),{readdirSync:d,statSync:s}=require('fs'),{join:j}=require('path');function f(p){let a=[];for(const e of d(p)){const c=j(p,e);s(c).isDirectory()?a.push(...f(c)):e.endsWith('.test.js')&&!e.startsWith('e2e-')&&e!=='devtunnel-install.test.js'&&a.push(c)}return a}r(process.execPath,['--test','--test-timeout=180000',...f('test')],{stdio:'inherit'})\"",
13-
"test:coverage": "c8 --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node -e \"const{execFileSync:r}=require('child_process'),{readdirSync:d,statSync:s}=require('fs'),{join:j}=require('path');function f(p){let a=[];for(const e of d(p)){const c=j(p,e);s(c).isDirectory()?a.push(...f(c)):e.endsWith('.test.js')&&!e.startsWith('e2e-')&&e!=='devtunnel-install.test.js'&&a.push(c)}return a}r(process.execPath,['--test','--test-timeout=180000','--test-reporter=spec','--test-reporter-destination=stdout',...f('test')],{stdio:'inherit'})\"",
12+
"test": "node -e \"const{execFileSync:r}=require('child_process'),{readdirSync:d,statSync:s}=require('fs'),{join:j}=require('path');function f(p){let a=[];for(const e of d(p)){const c=j(p,e);s(c).isDirectory()?a.push(...f(c)):e.endsWith('.test.js')&&!e.startsWith('e2e-')&&e!=='devtunnel-install.test.js'&&a.push(c)}return a}r(process.execPath,['--test','--test-timeout=180000','--test-force-exit',...f('test')],{stdio:'inherit'})\"",
13+
"test:coverage": "c8 --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node -e \"const{execFileSync:r}=require('child_process'),{readdirSync:d,statSync:s}=require('fs'),{join:j}=require('path');function f(p){let a=[];for(const e of d(p)){const c=j(p,e);s(c).isDirectory()?a.push(...f(c)):e.endsWith('.test.js')&&!e.startsWith('e2e-')&&e!=='devtunnel-install.test.js'&&a.push(c)}return a}r(process.execPath,['--test','--test-timeout=180000','--test-force-exit','--test-reporter=spec','--test-reporter-destination=stdout',...f('test')],{stdio:'inherit'})\"",
1414
"prepare": "husky",
1515
"format": "prettier --write .",
1616
"lint": "node --check src/server/*.js src/cli/*.js src/tunnel/*.js src/utils/*.js bin/*.js",

src/frontend/src/components/SessionsHub/SessionCard.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { Session } from '@/types';
33
import { CopilotLogo } from '@/components/common/CopilotLogo';
44
import styles from './SessionCard.module.css';
55

6+
import dissolveStyles from '@/components/common/Disintegrate.module.css';
7+
68
interface SessionCardProps {
79
session: Session;
810
onSelect: (id: string) => void;
@@ -14,6 +16,12 @@ interface SessionCardProps {
1416
* entrance so cards cascade in instead of appearing all at once.
1517
*/
1618
index?: number;
19+
/**
20+
* When true, the card is playing the disintegrate (Thanos-snap)
21+
* animation prior to being removed. Disables interaction and applies
22+
* the dust effect via a CSS modifier class.
23+
*/
24+
dissolving?: boolean;
1725
}
1826

1927
function formatActivity(lastActivity: string | number): string {
@@ -96,6 +104,7 @@ export default function SessionCard({
96104
revealedId,
97105
onRevealChange,
98106
index = 0,
107+
dissolving = false,
99108
}: SessionCardProps) {
100109
const cardRef = useRef<HTMLDivElement>(null);
101110
const touchStartX = useRef(0);
@@ -213,14 +222,17 @@ export default function SessionCard({
213222

214223
return (
215224
<div
216-
className={styles.wrapper}
225+
className={`${styles.wrapper} ${dissolving ? dissolveStyles.dissolving : ''}`}
217226
style={{ ['--stagger-i' as string]: Math.min(index, 8) }}
227+
data-session-id={session.id}
228+
aria-hidden={dissolving || undefined}
218229
>
219230
<button
220231
className={styles.deleteBackground}
221232
onClick={handleDeleteClick}
222233
aria-label={`Delete session ${session.name}`}
223234
type="button"
235+
disabled={dissolving}
224236
>
225237
<TrashIcon />
226238
Delete
@@ -229,7 +241,10 @@ export default function SessionCard({
229241
ref={cardRef}
230242
className={styles.card}
231243
data-testid="session-card"
232-
onClick={() => onSelect(session.id)}
244+
onClick={() => {
245+
if (dissolving) return;
246+
onSelect(session.id);
247+
}}
233248
onTouchStart={handleTouchStart}
234249
onTouchMove={handleTouchMove}
235250
onTouchEnd={handleTouchEnd}

src/frontend/src/components/SessionsHub/SessionsHub.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
22
import { toast } from 'sonner';
33
import { fetchSessions, deleteSession, fetchVersion, getShareUrl } from '@/services/api';
44
import { useUIStore } from '@/stores/uiStore';
5+
import { useSessionStore } from '@/stores/sessionStore';
6+
import { useDissolveDelete } from '@/hooks/useDissolveDelete';
57
import ThemePicker from '@/components/common/ThemePicker';
68
import type { Session } from '@/types';
79
import UpdateBanner from '@/components/common/UpdateBanner';
@@ -87,6 +89,7 @@ export default function SessionsHub() {
8789
const [revealedId, setRevealedId] = useState<string | null>(null);
8890
const [filter, setFilter] = useState<SessionFilterState>(() => loadFilterFromStorage());
8991
const [workspaceLauncherOpen, setWorkspaceLauncherOpen] = useState(false);
92+
const listRef = useRef<HTMLDivElement>(null);
9093
const {
9194
openNewSessionModal,
9295
openResumeBrowser,
@@ -95,10 +98,23 @@ export default function SessionsHub() {
9598
closeThemePicker,
9699
} = useUIStore();
97100

101+
const dissolvingIds = useSessionStore((s) => s.dissolvingIds);
102+
const dissolveDelete = useDissolveDelete();
103+
98104
const loadSessions = useCallback(async () => {
99105
try {
100106
const list = await fetchSessions();
101-
setSessions(list.filter((s) => !s.hidden));
107+
const visible = list.filter((s) => !s.hidden);
108+
setSessions((prev) => {
109+
// Re-include any sessions currently mid-dissolve so the row
110+
// stays mounted long enough for the disintegrate animation to
111+
// play. The server has already removed them by this point.
112+
const visibleIds = new Set(visible.map((s) => s.id));
113+
const dissolveExtras = prev.filter(
114+
(s) => useSessionStore.getState().dissolvingIds.has(s.id) && !visibleIds.has(s.id),
115+
);
116+
return [...visible, ...dissolveExtras];
117+
});
102118
} catch {
103119
// Silently retry on next poll
104120
} finally {
@@ -149,9 +165,16 @@ export default function SessionsHub() {
149165

150166
async function handleDelete(id: string) {
151167
const session = sessions.find((s) => s.id === id);
168+
const element = listRef.current?.querySelector<HTMLElement>(
169+
`[data-session-id="${id}"]`,
170+
);
152171
try {
153-
await deleteSession(id);
154-
setSessions((prev) => prev.filter((s) => s.id !== id));
172+
await dissolveDelete(id, {
173+
element: element ?? null,
174+
color: session?.color ?? '#6ec1e4',
175+
apiDelete: () => deleteSession(id),
176+
finalize: () => setSessions((prev) => prev.filter((s) => s.id !== id)),
177+
});
155178
toast.success(`Session "${session?.name ?? id}" deleted`);
156179
} catch (err) {
157180
toast.error(err instanceof Error ? err.message : 'Failed to delete session');
@@ -355,6 +378,7 @@ export default function SessionsHub() {
355378
</div>
356379
) : (
357380
<div
381+
ref={listRef}
358382
className={styles.sessionsList}
359383
data-testid="sessions-list"
360384
data-filter-active={filterActive || undefined}
@@ -368,6 +392,7 @@ export default function SessionsHub() {
368392
revealedId={revealedId}
369393
onRevealChange={setRevealedId}
370394
index={i}
395+
dissolving={dissolvingIds.has(session.id)}
371396
/>
372397
))}
373398
</div>

src/frontend/src/components/SettingsPanel/SettingsPanel.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,21 @@ export default function SettingsPanel() {
438438
onChange={(v) => setPreference('showSplash', v)}
439439
/>
440440
</div>
441+
442+
<div className={styles.row}>
443+
<span className={styles.rowLabel}>
444+
Particle dissolve
445+
<span className={styles.rowHint}>
446+
Play the canvas particle effect when deleting a session. Turn off for a plain
447+
fade only — useful on low-power devices.
448+
</span>
449+
</span>
450+
<Toggle
451+
on={prefs.particleDissolve}
452+
ariaLabel="Toggle particle dissolve effect"
453+
onChange={(v) => setPreference('particleDissolve', v)}
454+
/>
455+
</div>
441456
</section>
442457

443458
{/* ── Touch Bar ─────────────────────────────────────────── */}

src/frontend/src/components/SidePanel/SidePanel.tsx

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useRef, useState } from 'react';
22
import type { ManagedSession } from '@/stores/sessionStore';
33
import { useSessionStore } from '@/stores/sessionStore';
44
import { useUIStore } from '@/stores/uiStore';
5+
import { useDissolveDelete } from '@/hooks/useDissolveDelete';
56
import { fetchVersion, deleteSession } from '@/services/api';
67
import { FileBrowser } from '@/components/FileBrowser/FileBrowser';
8+
import dissolveStyles from '@/components/common/Disintegrate.module.css';
79
import styles from './SidePanel.module.css';
810

911
function getActivityLabel(ts: string | number | undefined): string {
@@ -59,13 +61,16 @@ export function SidePanel() {
5961
const sessions = useSessionStore((s) => s.sessions);
6062
const activeId = useSessionStore((s) => s.activeId);
6163
const tabOrder = useSessionStore((s) => s.tabOrder);
64+
const dissolvingIds = useSessionStore((s) => s.dissolvingIds);
6265
const setActiveId = useSessionStore((s) => s.setActiveId);
6366
const removeSession = useSessionStore((s) => s.removeSession);
67+
const dissolveDelete = useDissolveDelete();
6468

6569
const [closing, setClosing] = useState(false);
6670
const [version, setVersion] = useState('');
6771
const [showFiles, setShowFiles] = useState(false);
6872
const panelRef = useRef<HTMLDivElement>(null);
73+
const listRef = useRef<HTMLDivElement>(null);
6974

7075
const animateClose = useCallback(() => {
7176
setClosing(true);
@@ -107,9 +112,21 @@ export function SidePanel() {
107112

108113
const handleClose = (e: React.MouseEvent, id: string) => {
109114
e.stopPropagation();
115+
if (dissolvingIds.has(id)) return;
110116
if (confirm('Close this session?')) {
111-
deleteSession(id).catch(() => {});
112-
removeSession(id);
117+
if (id === activeId) {
118+
const nextActive = tabOrder.find((tid) => tid !== id && sessions.has(tid));
119+
if (nextActive) setActiveId(nextActive);
120+
}
121+
const session = sessions.get(id);
122+
const rowEl = listRef.current?.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
123+
void dissolveDelete(id, {
124+
element: rowEl ?? null,
125+
color: session?.color || '#6ec1e4',
126+
variant: 'tab',
127+
apiDelete: () => deleteSession(id),
128+
finalize: () => removeSession(id),
129+
});
113130
}
114131
};
115132

@@ -164,23 +181,49 @@ export function SidePanel() {
164181
<div className={styles.sectionTitle}>Sessions</div>
165182

166183
{/* Session list */}
167-
<div className={styles.list} data-testid="side-panel-list">
184+
<div ref={listRef} className={styles.list} data-testid="side-panel-list">
168185
{orderedSessions.map((session) => {
169186
const activity = getActivityLabel(session.lastActivity);
170187
const git = session.git;
188+
const isDissolvingRow = dissolvingIds.has(session.id);
171189

172190
return (
173191
<div
174192
key={session.id}
175-
className={`${styles.card} ${session.id === activeId ? styles.cardActive : ''}`}
193+
className={`${styles.card} ${session.id === activeId ? styles.cardActive : ''} ${isDissolvingRow ? dissolveStyles.dissolving : ''}`}
176194
data-testid="side-panel-card"
177-
onClick={() => selectSession(session.id)}
195+
data-session-id={session.id}
196+
aria-hidden={isDissolvingRow || undefined}
197+
style={
198+
isDissolvingRow
199+
? ({ ['--termbeam-fragment-ms' as string]: '280ms' } as React.CSSProperties)
200+
: undefined
201+
}
202+
onClick={() => {
203+
if (isDissolvingRow) return;
204+
selectSession(session.id);
205+
}}
178206
onAuxClick={(e) => {
207+
if (isDissolvingRow) return;
179208
if (e.button === 1) {
180209
e.preventDefault();
181210
if (confirm('Close this session?')) {
182-
deleteSession(session.id).catch(() => {});
183-
removeSession(session.id);
211+
if (session.id === activeId) {
212+
const nextActive = tabOrder.find(
213+
(tid) => tid !== session.id && sessions.has(tid),
214+
);
215+
if (nextActive) setActiveId(nextActive);
216+
}
217+
const rowEl = listRef.current?.querySelector<HTMLElement>(
218+
`[data-session-id="${session.id}"]`,
219+
);
220+
void dissolveDelete(session.id, {
221+
element: rowEl ?? null,
222+
color: session.color || '#6ec1e4',
223+
variant: 'tab',
224+
apiDelete: () => deleteSession(session.id),
225+
finalize: () => removeSession(session.id),
226+
});
184227
}
185228
}
186229
}}

src/frontend/src/components/TabBar/SortableTab.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import { useSortable } from '@dnd-kit/sortable';
33
import { CSS } from '@dnd-kit/utilities';
44
import type { ManagedSession } from '@/stores/sessionStore';
55
import { CopilotLogo } from '@/components/common/CopilotLogo';
6+
import dissolveStyles from '@/components/common/Disintegrate.module.css';
67
import styles from './TabBar.module.css';
78

89
interface SortableTabProps {
910
session: ManagedSession;
1011
isActive: boolean;
1112
isSplit?: boolean;
13+
/** When true, this tab is currently playing the disintegrate animation. */
14+
dissolving?: boolean;
1215
onActivate: () => void;
1316
onClose: () => void;
1417
onMouseEnter?: (e: React.MouseEvent<HTMLDivElement>) => void;
@@ -32,6 +35,7 @@ export function SortableTab({
3235
session,
3336
isActive,
3437
isSplit = false,
38+
dissolving = false,
3539
onActivate,
3640
onClose,
3741
onMouseEnter,
@@ -65,6 +69,9 @@ export function SortableTab({
6569
transform: CSS.Transform.toString(transform),
6670
transition,
6771
opacity: isDragging ? 0.5 : 1,
72+
// Snappier host fade for tabs (280 ms) — matches the 'tab' variant
73+
// in useDissolveDelete. Cards (Hub) keep the default 600 ms.
74+
...(dissolving ? { ['--termbeam-fragment-ms' as string]: '280ms' } : null),
6875
};
6976

7077
const activity = formatTabActivity(session.lastActivity);
@@ -73,9 +80,10 @@ export function SortableTab({
7380
<div
7481
ref={setNodeRef}
7582
style={style}
76-
className={`${styles.tab} ${isActive ? styles.tabActive : ''} ${isSplit ? styles.tabSplit : ''} ${flash ? styles.tabFlash : ''}`}
83+
className={`${styles.tab} ${isActive ? styles.tabActive : ''} ${isSplit ? styles.tabSplit : ''} ${flash ? styles.tabFlash : ''} ${dissolving ? dissolveStyles.dissolving : ''}`}
7784
data-testid="session-tab"
7885
{...(isActive ? { 'data-active': 'true' } : {})}
86+
aria-hidden={dissolving || undefined}
7987
{...attributes}
8088
{...listeners}
8189
onPointerDown={(e) => {

0 commit comments

Comments
 (0)