From ff0764298d74826840087df537d7fa9849e26a99 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Thu, 27 Aug 2026 22:20:19 +0530 Subject: [PATCH 01/16] fix(terminal): retain and flush PTY size on ready (#101) Keep desired terminal geometry while the PTY is starting instead of dropping mid-start fits, then flush the fitted size on terminal-ready so tmux/remote sessions fill the viewport without a manual window resize. --- docs/TERMINAL.md | 5 +- src/components/terminal/useTerminalGhost.ts | 3 +- .../terminal/useTerminalLifecycle.ts | 1 + src/lib/terminal/index.ts | 6 +- src/lib/terminal/inputPipeline.ts | 3 + src/lib/terminal/ptyLifecycle.ts | 8 +- src/lib/terminal/terminalCache.ts | 3 + src/lib/terminal/terminalConnectionWakeup.ts | 3 +- .../terminal/terminalLifecycleListeners.ts | 3 +- src/lib/terminal/terminalResizeSync.ts | 81 ++++++++++- tests/terminalInputPipeline.test.mjs | 68 ++++++--- tests/terminalResizeSync.test.mjs | 137 ++++++++++++------ 12 files changed, 246 insertions(+), 75 deletions(-) diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 8763187c..8ea34b7c 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -376,13 +376,16 @@ Historical scrollback **does not reflow** when the window is resized. Lines writ **Scheduler:** `createResizeScheduler` — 60ms trailing edge in `terminalFit.ts`. +**Desired-size flush (issue #101):** `syncTerminalResize` always records `desiredResize` from xterm. While the PTY is `starting` (or not yet spawned), IPC is deferred — size is **not** dropped. On `terminal-ready`, `flushTerminalResize` fits and sends the latest size (same pending→flush pattern as input). Manual window resize still force-syncs via `lastResize` clear. + ### Regression watchlist (mitigated — re-check after layout/GPU changes) | Symptom | Mitigation | |---------|------------| | Black margins around xterm | `.terminal-container` fill CSS, `safeFitTerminal` | | Garbled text after resize | Trailing scheduler + post-fit refresh | -| PTY cols/rows drift | Hidden-tab gate; defer IPC until layout settle | +| PTY cols/rows drift | Hidden-tab gate; defer IPC until layout settle; desired-size flush on ready | +| tmux small until window resize (#101) | Retain `desiredResize` while starting; `flushTerminalResize` on ready | | Double framebuffer | Single active renderer path (WebGL **or** DOM) | --- diff --git a/src/components/terminal/useTerminalGhost.ts b/src/components/terminal/useTerminalGhost.ts index e0c353f6..ed506baa 100644 --- a/src/components/terminal/useTerminalGhost.ts +++ b/src/components/terminal/useTerminalGhost.ts @@ -25,6 +25,7 @@ import type { AppSettings } from '../../store/settingsSlice'; import { extractRecentCommands } from '../../lib/ghostSuggestions/recentCommands'; import { clearTerminalPendingInput, + clearTerminalResizeState, enqueueTerminalInputTask, getTerminalRecentLines, queueTerminalInput, @@ -273,7 +274,7 @@ export function useTerminalGhost({ : '[Terminal] Session ended, restarting on Enter', ); clearTerminalPendingInput(mountSessionId); - cached.lastResize = null; + clearTerminalResizeState(mountSessionId); cached.spawnBlocked = false; const store = useAppStore.getState(); spawnTerminalFromStoreContext({ diff --git a/src/components/terminal/useTerminalLifecycle.ts b/src/components/terminal/useTerminalLifecycle.ts index 71ef4cd6..f65df56f 100644 --- a/src/components/terminal/useTerminalLifecycle.ts +++ b/src/components/terminal/useTerminalLifecycle.ts @@ -573,6 +573,7 @@ export function useTerminalLifecycle({ pendingInput: '', pendingInputBytes: 0, inputFlushTimer: null, + desiredResize: null, lastResize: null, ligaturesAddon: undefined, ligaturesEnabled: false, diff --git a/src/lib/terminal/index.ts b/src/lib/terminal/index.ts index 4c22dedd..466d0645 100644 --- a/src/lib/terminal/index.ts +++ b/src/lib/terminal/index.ts @@ -117,7 +117,11 @@ export type { ResizeScheduler, ResizeScheduleOptions } from './terminalFit.js'; export type { TerminalSpawnTabState } from './spawnContext.js'; export { resolveTerminalSpawnParams } from './spawnContext.js'; -export { syncTerminalResize } from './terminalResizeSync.js'; +export { + clearTerminalResizeState, + flushTerminalResize, + syncTerminalResize, +} from './terminalResizeSync.js'; export type { SpawnTerminalFromStoreOptions } from './terminalSpawn.js'; export { spawnTerminalFromStoreContext } from './terminalSpawn.js'; diff --git a/src/lib/terminal/inputPipeline.ts b/src/lib/terminal/inputPipeline.ts index e5301099..06702aa1 100644 --- a/src/lib/terminal/inputPipeline.ts +++ b/src/lib/terminal/inputPipeline.ts @@ -11,6 +11,7 @@ import { import { terminalCache } from './terminalCache.js'; import { touchTerminalActivity } from './terminalActivity.js'; import { clearIdleHostSuspendNotice } from './terminalIdleSuspendNotice.js'; +import { flushTerminalResize } from './terminalResizeSync.js'; import { isWin32Platform, resolveLocalWindowsShellId } from './spawnContext.js'; const INPUT_BATCH_MS = 4; @@ -190,5 +191,7 @@ export function handleTerminalReady(termId: string, generation: number): boolean } clearIdleHostSuspendNotice(termId); flushPendingInput(termId); + // Geometry uses the same pending→flush contract as input (#101). + flushTerminalResize(termId); return true; } \ No newline at end of file diff --git a/src/lib/terminal/ptyLifecycle.ts b/src/lib/terminal/ptyLifecycle.ts index 48644a79..d8fbb284 100644 --- a/src/lib/terminal/ptyLifecycle.ts +++ b/src/lib/terminal/ptyLifecycle.ts @@ -8,6 +8,7 @@ import { } from './terminalSpawnErrors.js'; import { clearIdleHostSuspendNotice, writeIdleHostSuspendNotice } from './terminalIdleSuspendNotice.js'; import { attachTerminalOutputChannel } from './terminalOutputStream.js'; +import { clearTerminalResizeState } from './terminalResizeSync.js'; export interface SpawnTerminalSessionOptions { termId: string; @@ -44,6 +45,9 @@ export function spawnTerminalSession(options: SpawnTerminalSessionOptions): bool cached.spawned = true; cached.starting = true; cached.suspendedByPanel = false; + cached.desiredResize = { rows: term.rows, cols: term.cols }; + // Create tells the backend this size; lastResize stays null until ready flush + // or a live sync confirms the backend has caught up to desiredResize. if (clearBuffer) { term.clear(); @@ -123,7 +127,7 @@ export function resetTerminalPtyForReconnect(termId: string): void { cached.suspendedByIdle = false; clearIdleHostSuspendNotice(termId); cached.spawnBlocked = false; - cached.lastResize = null; + clearTerminalResizeState(termId); } /** Closes the backend PTY while preserving the cached xterm instance and scrollback. */ @@ -144,5 +148,5 @@ export function suspendTerminalPty(termId: string, options?: SuspendTerminalPtyO cached.spawned = false; cached.starting = false; cached.suspendedByPanel = options?.panelHide ?? false; - cached.lastResize = null; + clearTerminalResizeState(termId); } \ No newline at end of file diff --git a/src/lib/terminal/terminalCache.ts b/src/lib/terminal/terminalCache.ts index 5911ebc5..e4453fe2 100644 --- a/src/lib/terminal/terminalCache.ts +++ b/src/lib/terminal/terminalCache.ts @@ -21,6 +21,9 @@ export interface TerminalCache { pendingInput: string; pendingInputBytes: number; inputFlushTimer: ReturnType | null; + /** Latest fitted size from the UI (retained while PTY is starting). */ + desiredResize: { rows: number; cols: number } | null; + /** Last size successfully sent to the backend PTY. */ lastResize: { rows: number; cols: number } | null; unlisten?: UnlistenFn[]; /** Streaming PTY output channel passed to terminal:create. */ diff --git a/src/lib/terminal/terminalConnectionWakeup.ts b/src/lib/terminal/terminalConnectionWakeup.ts index fec248f6..13d391c3 100644 --- a/src/lib/terminal/terminalConnectionWakeup.ts +++ b/src/lib/terminal/terminalConnectionWakeup.ts @@ -1,5 +1,6 @@ import type { Terminal as XTerm } from '@xterm/xterm'; import { clearTerminalPendingInput, terminalCache } from './terminalCache.js'; +import { clearTerminalResizeState } from './terminalResizeSync.js'; import { spawnTerminalFromStoreContext } from './terminalSpawn.js'; import type { TerminalSpawnTabState } from './spawnContext.js'; @@ -38,7 +39,7 @@ export function tryWakeTerminalOnReconnect(ctx: ConnectionWakeupContext): boolea } clearTerminalPendingInput(ctx.sessionId); - cached.lastResize = null; + clearTerminalResizeState(ctx.sessionId); return spawnTerminalFromStoreContext({ sessionId: ctx.sessionId, connectionId: ctx.connectionId, diff --git a/src/lib/terminal/terminalLifecycleListeners.ts b/src/lib/terminal/terminalLifecycleListeners.ts index 2e8fa382..5cfb2210 100644 --- a/src/lib/terminal/terminalLifecycleListeners.ts +++ b/src/lib/terminal/terminalLifecycleListeners.ts @@ -4,6 +4,7 @@ import { clearTerminalInputQueue } from './inputQueue.js'; import { handleTerminalReady } from './inputPipeline.js'; import { clearTerminalPendingInput, terminalCache } from './terminalCache.js'; import { writeIdleHostSuspendNotice } from './terminalIdleSuspendNotice.js'; +import { clearTerminalResizeState } from './terminalResizeSync.js'; import { terminalService } from './terminalService.js'; @@ -61,7 +62,7 @@ export function attachTerminalLifecycleListeners(sessionId: string, _term: XTerm entry.spawned = false; clearTerminalPendingInput(sessionId); clearTerminalInputQueue(sessionId); - entry.lastResize = null; + clearTerminalResizeState(sessionId); if (suspendedForIdle) { writeIdleHostSuspendNotice(sessionId); diff --git a/src/lib/terminal/terminalResizeSync.ts b/src/lib/terminal/terminalResizeSync.ts index b60f2f5d..e3756ff6 100644 --- a/src/lib/terminal/terminalResizeSync.ts +++ b/src/lib/terminal/terminalResizeSync.ts @@ -1,24 +1,93 @@ import type { Terminal as XTerm } from '@xterm/xterm'; +import { safeFitTerminal } from './terminalFit.js'; import { terminalCache } from './terminalCache.js'; -/** Sends a terminal resize only when the row or column count actually changed. */ +export type TerminalSize = { rows: number; cols: number }; + +function sizesEqual(a: TerminalSize | null | undefined, b: TerminalSize | null | undefined): boolean { + return Boolean(a && b && a.rows === b.rows && a.cols === b.cols); +} + +function isPtyLive(cached: NonNullable>): boolean { + return Boolean(cached.spawned && !cached.starting); +} + +function sendResize(termId: string, size: TerminalSize): void { + window.ipcRenderer.send('terminal:resize', { termId, ...size }); +} + +/** + * Records the latest UI size and sends it to the backend when the PTY is live. + * While starting / not spawned, desired size is retained and flushed on ready. + */ export function syncTerminalResize(termId: string | null | undefined, term: XTerm): void { - const nextSize = { rows: term.rows, cols: term.cols }; if (!termId) { return; } const cached = terminalCache.get(termId); + if (!cached) { + return; + } + + const nextSize: TerminalSize = { rows: term.rows, cols: term.cols }; + cached.desiredResize = nextSize; - if (!cached || !cached.spawned || cached.starting) { + if (!isPtyLive(cached)) { return; } - if (cached.lastResize?.rows === nextSize.rows && cached.lastResize?.cols === nextSize.cols) { + if (sizesEqual(cached.lastResize, nextSize)) { return; } - window.ipcRenderer.send('terminal:resize', { termId, ...nextSize }); + sendResize(termId, nextSize); // Assign cache only after send (if send fails we will retry on next sync). cached.lastResize = nextSize; -} \ No newline at end of file +} + +/** + * Applies the current fitted size to a live PTY (e.g. after terminal-ready). + * Returns true when a resize IPC was sent. + */ +export function flushTerminalResize(termId: string | null | undefined): boolean { + if (!termId) { + return false; + } + + const cached = terminalCache.get(termId); + if (!cached || !isPtyLive(cached)) { + return false; + } + + safeFitTerminal(cached.fitAddon, cached.term); + + const nextSize: TerminalSize = { + rows: cached.term.rows, + cols: cached.term.cols, + }; + cached.desiredResize = nextSize; + + if (sizesEqual(cached.lastResize, nextSize)) { + return false; + } + + sendResize(termId, nextSize); + cached.lastResize = nextSize; + return true; +} + +/** Clears desired + last-sent resize state (reconnect / suspend / exit). */ +export function clearTerminalResizeState(termId: string | null | undefined): void { + if (!termId) { + return; + } + + const cached = terminalCache.get(termId); + if (!cached) { + return; + } + + cached.desiredResize = null; + cached.lastResize = null; +} diff --git a/tests/terminalInputPipeline.test.mjs b/tests/terminalInputPipeline.test.mjs index 5ee7a5e5..837db17c 100644 --- a/tests/terminalInputPipeline.test.mjs +++ b/tests/terminalInputPipeline.test.mjs @@ -8,7 +8,8 @@ import { import { terminalCache } from '../.tmp-agent-tests/src/lib/terminal/terminalCache.js'; const SESSION = 'input-pipeline-test'; -const ipcWrites = []; +/** Ordered IPC log: { channel, payload } — preserves write vs resize order. */ +const ipcCalls = []; function runTest(name, fn) { try { @@ -22,7 +23,7 @@ function runTest(name, fn) { function seedCache(overrides = {}) { terminalCache.set(SESSION, { - term: { rows: 24 }, + term: { rows: 24, cols: 80 }, fitAddon: {}, searchAddon: {}, generation: 1, @@ -32,6 +33,7 @@ function seedCache(overrides = {}) { pendingInput: '', pendingInputBytes: 0, inputFlushTimer: null, + desiredResize: null, lastResize: null, ligaturesEnabled: false, ...overrides, @@ -40,8 +42,8 @@ function seedCache(overrides = {}) { globalThis.window = { ipcRenderer: { - send: (_channel, payload) => { - ipcWrites.push(payload); + send: (channel, payload) => { + ipcCalls.push({ channel, payload }); }, }, setTimeout: (fn) => { @@ -53,28 +55,54 @@ globalThis.window = { runTest('canSendTerminalInput is false while starting', () => { terminalCache.clear(); - ipcWrites.length = 0; + ipcCalls.length = 0; seedCache({ starting: true }); assert.equal(canSendTerminalInput(SESSION), false); }); runTest('queueTerminalInput buffers without IPC while starting', () => { terminalCache.clear(); - ipcWrites.length = 0; + ipcCalls.length = 0; seedCache({ starting: true }); queueTerminalInput(SESSION, 'abc'); assert.equal(terminalCache.get(SESSION).pendingInput, 'abc'); - assert.equal(ipcWrites.length, 0); + assert.equal(ipcCalls.length, 0); }); -runTest('handleTerminalReady flushes buffered input', () => { +runTest('handleTerminalReady flushes buffered input before resize', () => { terminalCache.clear(); - ipcWrites.length = 0; - seedCache({ starting: true, pendingInput: 'ls\r', generation: 2 }); + ipcCalls.length = 0; + seedCache({ + starting: true, + pendingInput: 'ls\r', + generation: 2, + term: { rows: 48, cols: 140 }, + desiredResize: { rows: 48, cols: 140 }, + }); assert.equal(handleTerminalReady(SESSION, 2), true); assert.equal(terminalCache.get(SESSION).starting, false); - assert.equal(ipcWrites.length, 1); - assert.equal(ipcWrites[0].data, 'ls\r'); + assert.equal(ipcCalls.length, 2); + assert.equal(ipcCalls[0].channel, 'terminal:write'); + assert.equal(ipcCalls[0].payload.data, 'ls\r'); + assert.equal(ipcCalls[1].channel, 'terminal:resize'); + assert.deepEqual(ipcCalls[1].payload, { termId: SESSION, rows: 48, cols: 140 }); +}); + +runTest('handleTerminalReady flushes retained terminal size', () => { + terminalCache.clear(); + ipcCalls.length = 0; + seedCache({ + starting: true, + generation: 2, + term: { rows: 50, cols: 160 }, + desiredResize: { rows: 50, cols: 160 }, + lastResize: null, + }); + assert.equal(handleTerminalReady(SESSION, 2), true); + const resizes = ipcCalls.filter((call) => call.channel === 'terminal:resize'); + assert.equal(resizes.length, 1); + assert.deepEqual(resizes[0].payload, { termId: SESSION, rows: 50, cols: 160 }); + assert.deepEqual(terminalCache.get(SESSION).lastResize, { rows: 50, cols: 160 }); }); runTest('handleTerminalReady clears idle-suspend guard after successful spawn', () => { @@ -87,20 +115,20 @@ runTest('handleTerminalReady clears idle-suspend guard after successful spawn', runTest('flushPendingInput is a no-op while starting', () => { terminalCache.clear(); - ipcWrites.length = 0; + ipcCalls.length = 0; seedCache({ starting: true, pendingInput: 'pwd' }); flushPendingInput(SESSION); - assert.equal(ipcWrites.length, 0); + assert.equal(ipcCalls.length, 0); assert.equal(terminalCache.get(SESSION).pendingInput, 'pwd'); }); runTest('queueTerminalInput buffers without IPC when PTY is suspended', () => { terminalCache.clear(); - ipcWrites.length = 0; + ipcCalls.length = 0; seedCache({ spawned: false, starting: false }); queueTerminalInput(SESSION, 'echo'); assert.equal(terminalCache.get(SESSION).pendingInput, 'echo'); - assert.equal(ipcWrites.length, 0); + assert.equal(ipcCalls.length, 0); }); runTest('canSendTerminalInput is false when PTY is not spawned', () => { @@ -111,18 +139,18 @@ runTest('canSendTerminalInput is false when PTY is not spawned', () => { runTest('handleTerminalReady rejects stale generation', () => { terminalCache.clear(); - ipcWrites.length = 0; + ipcCalls.length = 0; seedCache({ starting: true, pendingInput: 'pwd', generation: 3 }); assert.equal(handleTerminalReady(SESSION, 2), false); assert.equal(terminalCache.get(SESSION).starting, true); - assert.equal(ipcWrites.length, 0); + assert.equal(ipcCalls.length, 0); }); runTest('queueTerminalInput no-ops when cache entry is missing', () => { terminalCache.clear(); - ipcWrites.length = 0; + ipcCalls.length = 0; queueTerminalInput(SESSION, 'echo hi'); - assert.equal(ipcWrites.length, 0); + assert.equal(ipcCalls.length, 0); }); console.log('Terminal input pipeline tests passed.'); \ No newline at end of file diff --git a/tests/terminalResizeSync.test.mjs b/tests/terminalResizeSync.test.mjs index 1d8e70a5..4a3974c7 100644 --- a/tests/terminalResizeSync.test.mjs +++ b/tests/terminalResizeSync.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict'; -import { syncTerminalResize } from '../.tmp-agent-tests/src/lib/terminal/terminalResizeSync.js'; +import { + clearTerminalResizeState, + flushTerminalResize, + syncTerminalResize, +} from '../.tmp-agent-tests/src/lib/terminal/terminalResizeSync.js'; import { terminalCache } from '../.tmp-agent-tests/src/lib/terminal/terminalCache.js'; const SESSION = 'resize-sync-test'; @@ -15,7 +19,23 @@ function runTest(name, fn) { } } -const term = { rows: 24, cols: 80 }; +function seedCache(overrides = {}) { + terminalCache.set(SESSION, { + term: { rows: 24, cols: 80 }, + fitAddon: {}, + searchAddon: {}, + generation: 1, + spawned: true, + starting: false, + listenerAttached: false, + pendingInput: '', + inputFlushTimer: null, + desiredResize: null, + lastResize: null, + ligaturesEnabled: false, + ...overrides, + }); +} const originalWindow = globalThis.window; globalThis.window = { @@ -29,70 +49,103 @@ globalThis.window = { runTest('syncTerminalResize skips IPC when cache entry is missing', () => { terminalCache.clear(); ipcResizes.length = 0; - syncTerminalResize(SESSION, term); + syncTerminalResize(SESSION, { rows: 24, cols: 80 }); assert.equal(ipcResizes.length, 0); }); -runTest('syncTerminalResize skips IPC while PTY is not spawned', () => { +runTest('syncTerminalResize retains desired size while PTY is not spawned', () => { terminalCache.clear(); ipcResizes.length = 0; - terminalCache.set(SESSION, { - term, - fitAddon: {}, - searchAddon: {}, - generation: 1, - spawned: false, - starting: false, - listenerAttached: false, - pendingInput: '', - inputFlushTimer: null, - lastResize: null, - ligaturesEnabled: false, - }); - syncTerminalResize(SESSION, term); + seedCache({ spawned: false, starting: false, term: { rows: 24, cols: 80 } }); + syncTerminalResize(SESSION, { rows: 40, cols: 120 }); assert.equal(ipcResizes.length, 0); + assert.deepEqual(terminalCache.get(SESSION).desiredResize, { rows: 40, cols: 120 }); }); -runTest('syncTerminalResize skips IPC while PTY is starting', () => { +runTest('syncTerminalResize retains desired size while PTY is starting', () => { terminalCache.clear(); ipcResizes.length = 0; - terminalCache.set(SESSION, { - term, - fitAddon: {}, - searchAddon: {}, - generation: 1, + seedCache({ spawned: true, starting: true, - listenerAttached: false, - pendingInput: '', - inputFlushTimer: null, - lastResize: null, - ligaturesEnabled: false, + term: { rows: 24, cols: 80 }, }); - syncTerminalResize(SESSION, term); + syncTerminalResize(SESSION, { rows: 50, cols: 160 }); assert.equal(ipcResizes.length, 0); + assert.deepEqual(terminalCache.get(SESSION).desiredResize, { rows: 50, cols: 160 }); + assert.equal(terminalCache.get(SESSION).lastResize, null); }); runTest('syncTerminalResize sends IPC when PTY is live', () => { terminalCache.clear(); ipcResizes.length = 0; - terminalCache.set(SESSION, { - term, - fitAddon: {}, - searchAddon: {}, - generation: 1, + seedCache({ spawned: true, starting: false, - listenerAttached: false, - pendingInput: '', - inputFlushTimer: null, - lastResize: null, - ligaturesEnabled: false, + term: { rows: 24, cols: 80 }, }); - syncTerminalResize(SESSION, term); + syncTerminalResize(SESSION, { rows: 24, cols: 80 }); assert.equal(ipcResizes.length, 1); assert.deepEqual(ipcResizes[0], { termId: SESSION, rows: 24, cols: 80 }); + assert.deepEqual(terminalCache.get(SESSION).lastResize, { rows: 24, cols: 80 }); + assert.deepEqual(terminalCache.get(SESSION).desiredResize, { rows: 24, cols: 80 }); +}); + +runTest('flushTerminalResize sends retained size after PTY becomes live', () => { + terminalCache.clear(); + ipcResizes.length = 0; + const term = { rows: 48, cols: 140 }; + seedCache({ + spawned: true, + starting: false, + term, + fitAddon: {}, + desiredResize: { rows: 48, cols: 140 }, + lastResize: null, + }); + assert.equal(flushTerminalResize(SESSION), true); + assert.equal(ipcResizes.length, 1); + assert.deepEqual(ipcResizes[0], { termId: SESSION, rows: 48, cols: 140 }); + assert.deepEqual(terminalCache.get(SESSION).lastResize, { rows: 48, cols: 140 }); +}); + +runTest('flushTerminalResize is a no-op while starting', () => { + terminalCache.clear(); + ipcResizes.length = 0; + seedCache({ + spawned: true, + starting: true, + term: { rows: 48, cols: 140 }, + desiredResize: { rows: 48, cols: 140 }, + }); + assert.equal(flushTerminalResize(SESSION), false); + assert.equal(ipcResizes.length, 0); +}); + +runTest('flushTerminalResize skips duplicate size', () => { + terminalCache.clear(); + ipcResizes.length = 0; + seedCache({ + spawned: true, + starting: false, + term: { rows: 30, cols: 100 }, + lastResize: { rows: 30, cols: 100 }, + desiredResize: { rows: 30, cols: 100 }, + }); + assert.equal(flushTerminalResize(SESSION), false); + assert.equal(ipcResizes.length, 0); +}); + +runTest('clearTerminalResizeState resets desired and last-sent', () => { + terminalCache.clear(); + seedCache({ + desiredResize: { rows: 40, cols: 120 }, + lastResize: { rows: 24, cols: 80 }, + }); + clearTerminalResizeState(SESSION); + assert.equal(terminalCache.get(SESSION).desiredResize, null); + assert.equal(terminalCache.get(SESSION).lastResize, null); }); globalThis.window = originalWindow; -console.log('Terminal resize sync tests passed.'); \ No newline at end of file +console.log('Terminal resize sync tests passed.'); From d40a4dca188d5215efa98154b78068d47c106273 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Thu, 27 Aug 2026 22:20:32 +0530 Subject: [PATCH 02/16] docs(changelog): note terminal size flush fix for #101 Record the Unreleased changelog entry for the desired-size PTY resize flush, referencing ff07642. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 555fdc7e..4b9efcfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Zync are documented in this file. The format is based on ## [Unreleased] +### Fixed +- **Remote PTY / tmux initial size (#101)**: Terminal geometry is retained while the PTY is starting and flushed on `terminal-ready`, so tmux and other remote sessions fill the viewport on first attach without requiring a manual window resize. ([ff07642]) + ## [2.25.8] - 2026-08-22 ### Added From 2608d05e2a35f7e60c791ecda45ed77f439603b3 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Thu, 27 Aug 2026 23:47:38 +0530 Subject: [PATCH 03/16] feat(status-bar): show SSH latency chip and add Status Bar settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measure SSH round-trip time with a gentle session-channel probe and replace the connected wifi icon with a compact latency chip. Add a dedicated Settings → Status Bar tab for the latency toggle, with fail-soft probing and stale-update guards. --- docs/SETTINGS_SYSTEM.md | 1 + src-tauri/src/commands.rs | 37 ++++++ src-tauri/src/connection_latency.rs | 45 +++++++ src-tauri/src/lib.rs | 2 + src/components/layout/StatusBar.tsx | 51 ++++++-- src/components/settings/SettingsModal.tsx | 19 ++- src/components/settings/tabs/StatusBarTab.tsx | 47 ++++++++ .../infrastructure/connectionIpc.ts | 5 + src/features/statusBar/StatusBarLatency.tsx | 30 +++++ src/features/statusBar/index.ts | 16 +++ src/features/statusBar/latency.ts | 68 +++++++++++ src/features/statusBar/settings.ts | 13 ++ src/features/statusBar/types.ts | 6 + .../statusBar/useConnectionLatency.ts | 114 ++++++++++++++++++ src/lib/tauri-ipc.ts | 7 +- src/store/settingsSlice.ts | 44 +++++++ tests/runAllAgentTests.mjs | 1 + tests/statusBarLatency.test.mjs | 83 +++++++++++++ tsconfig.agent-tests.json | 3 + 19 files changed, 575 insertions(+), 17 deletions(-) create mode 100644 src-tauri/src/connection_latency.rs create mode 100644 src/components/settings/tabs/StatusBarTab.tsx create mode 100644 src/features/statusBar/StatusBarLatency.tsx create mode 100644 src/features/statusBar/index.ts create mode 100644 src/features/statusBar/latency.ts create mode 100644 src/features/statusBar/settings.ts create mode 100644 src/features/statusBar/types.ts create mode 100644 src/features/statusBar/useConnectionLatency.ts create mode 100644 tests/statusBarLatency.test.mjs diff --git a/docs/SETTINGS_SYSTEM.md b/docs/SETTINGS_SYSTEM.md index 0ff2761e..50b6caf6 100644 --- a/docs/SETTINGS_SYSTEM.md +++ b/docs/SETTINGS_SYSTEM.md @@ -57,6 +57,7 @@ Zync provides an in-app editor tab (not external VS Code handoff) for global set - app-wide appearance preferences (theme, global font family/size, accent) - persisted feature/settings configuration in user `settings.json` - update/config toggles and system-level options +- status bar preferences (Settings → Status Bar; `statusBar.showConnectionLatency`) ### What is currently “local” in UI terms (but not local override file scope) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7db1b436..c016b558 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4848,6 +4848,43 @@ pub async fn ssh_exec( } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionLatencyPayload { + pub connection_id: String, + pub rtt_ms: u64, +} + +#[tauri::command] +pub async fn ssh_connection_latency( + id: String, + state: State<'_, AppState>, +) -> Result { + if id == "local" { + return Err("Local workspace has no SSH latency".to_string()); + } + + let session = { + let connections = state.connections.lock().await; + let conn = connections + .get(&id) + .ok_or_else(|| "Connection not found".to_string())?; + conn.session + .clone() + .ok_or_else(|| "Connection is not live".to_string())? + }; + + let rtt_ms = { + let handle = session.lock().await; + crate::connection_latency::measure_session_rtt_ms(&handle).await? + }; + + Ok(ConnectionLatencyPayload { + connection_id: id, + rtt_ms, + }) +} + #[tauri::command] pub async fn ssh_import_config( app: AppHandle, diff --git a/src-tauri/src/connection_latency.rs b/src-tauri/src/connection_latency.rs new file mode 100644 index 00000000..e65179c8 --- /dev/null +++ b/src-tauri/src/connection_latency.rs @@ -0,0 +1,45 @@ +use russh::client::Handle; +use std::time::{Duration, Instant}; +use tokio::time::timeout; + +use crate::ssh::Client; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(4); +const MAX_RTT_MS: u64 = 60_000; + +/// Caps a measured duration so UI / IPC never see unbounded values. +pub fn clamp_rtt_ms(elapsed_ms: u128) -> u64 { + elapsed_ms.min(u128::from(MAX_RTT_MS)) as u64 +} + +/// SSH-layer RTT: time until a session channel is confirmed, then close it. +/// Does not run a remote command (no shell history / login noise). +pub async fn measure_session_rtt_ms(session: &Handle) -> Result { + let started = Instant::now(); + let channel = timeout(PROBE_TIMEOUT, session.channel_open_session()) + .await + .map_err(|_| "SSH latency probe timed out".to_string())? + .map_err(|error| format!("SSH latency probe failed: {error}"))?; + let rtt_ms = clamp_rtt_ms(started.elapsed().as_millis()); + let _ = channel.close().await; + Ok(rtt_ms) +} + +#[cfg(test)] +mod tests { + use super::{clamp_rtt_ms, MAX_RTT_MS}; + + #[test] + fn clamp_rtt_ms_keeps_typical_values() { + assert_eq!(clamp_rtt_ms(0), 0); + assert_eq!(clamp_rtt_ms(42), 42); + assert_eq!(clamp_rtt_ms(1_200), 1_200); + } + + #[test] + fn clamp_rtt_ms_caps_unbounded_samples() { + assert_eq!(clamp_rtt_ms(u128::from(MAX_RTT_MS)), MAX_RTT_MS); + assert_eq!(clamp_rtt_ms(u128::from(MAX_RTT_MS) + 1), MAX_RTT_MS); + assert_eq!(clamp_rtt_ms(u128::MAX), MAX_RTT_MS); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bed370f4..d64e716d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod ai; mod atomic_io; mod commands; +mod connection_latency; mod fs; mod ghost; mod identity_migration; @@ -176,6 +177,7 @@ pub fn run() { commands::window_minimize, commands::window_close, commands::ssh_exec, + commands::ssh_connection_latency, commands::ssh_import_config, commands::ssh_import_config_from_file, commands::ssh_import_config_from_text, diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 6e67e457..08740b92 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -7,6 +7,11 @@ import { Tooltip } from '../ui/Tooltip'; import { StatusBarTransferIndicator } from '../file-manager/StatusBarTransferIndicator'; import { StatusBarUpdateIndicator } from '../../features/updater/StatusBarUpdateIndicator'; import { NotificationBell, useNotificationBellPlacement } from '../notifications/NotificationBell'; +import { + DEFAULT_STATUS_BAR_SETTINGS, + StatusBarLatency, + useConnectionLatency, +} from '../../features/statusBar'; const statusToggleBtnClass = 'h-6 w-6 shrink-0 rounded-md text-app-muted hover:text-app-text hover:bg-app-surface border border-transparent hover:border-app-border/40 transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-app-accent/60 focus-visible:ring-offset-0'; @@ -40,6 +45,14 @@ export function StatusBar() { const isLiveConnected = activeConnection?.status === 'connected'; const isConnecting = activeConnection?.status === 'connecting'; const { showInStatusLeft, showInStatusRight } = useNotificationBellPlacement(); + const showConnectionLatency = + useAppStore((state) => state.settings.statusBar?.showConnectionLatency) + ?? DEFAULT_STATUS_BAR_SETTINGS.showConnectionLatency; + const latencyMs = useConnectionLatency({ + connectionId: activeConnectionId, + enabled: showConnectionLatency, + isLive: Boolean(isLiveConnected && activeConnection), + }); return (
@@ -71,22 +84,34 @@ export function StatusBar() { )} -
+
{isLiveConnected && activeConnection ? ( - <> - - Connected to {activeConnection.name} - +
+ {showConnectionLatency && latencyMs !== null ? ( + + ) : ( + + )} + + + {activeConnection.name} + + +
) : isConnecting && activeConnection ? ( - <> - - Connecting to {activeConnection.name}… - + +
+ + {activeConnection.name} +
+
) : activeConnection ? ( - <> - - {activeConnection.name} · offline - + +
+ + {activeConnection.name} +
+
) : isLocalWorkspace ? ( <> diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 53dae2af..b0448e9e 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -6,7 +6,7 @@ import { ZPortal } from '../ui/ZPortal'; import { useAppStore } from '../../store/useAppStore'; // Updated Import import { usePlugins } from '../../context/PluginContext'; -import { X, Type, Monitor, FileText, Keyboard, Info, RefreshCw, FolderOpen, Settings as SettingsIcon, Package, Code, Sparkles, GripHorizontal } from 'lucide-react'; +import { X, Type, Monitor, FileText, Keyboard, Info, RefreshCw, FolderOpen, Settings as SettingsIcon, Package, Code, Sparkles, GripHorizontal, PanelBottom } from 'lucide-react'; import { ToastContainer } from '../ui/Toast'; import { buildEditorProviderOptions, CODEMIRROR_EDITOR_ID, formatEditorCapabilities } from '../editor/providers'; @@ -18,6 +18,7 @@ import { AiTab } from './tabs/AiTab'; import { ShortcutsTab } from './tabs/ShortcutsTab'; import { PluginsTab } from './tabs/PluginsTab'; import { AboutTab } from './tabs/AboutTab'; +import { StatusBarTab } from './tabs/StatusBarTab'; import { IconResolver } from './common/IconResolver'; import { TabButton } from './common/TabButton'; import { TiltLogo } from './common/TiltLogo'; @@ -37,7 +38,7 @@ interface SettingsModalProps { onClose: () => void; } -type Tab = 'general' | 'terminal' | 'appearance' | 'fileManager' | 'shortcuts' | 'plugins' | 'ai' | 'about'; +type Tab = 'general' | 'terminal' | 'appearance' | 'statusBar' | 'fileManager' | 'shortcuts' | 'plugins' | 'ai' | 'about'; const BUILTIN_ICON_THEME_COUNT = 2; // VSCode Icons + Lucide const FOCUSABLE_SELECTOR = [ 'a[href]', @@ -69,6 +70,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const updateAiSettings = useAppStore(state => state.updateAiSettings); const updateTerminalSettings = useAppStore(state => state.updateTerminalSettings); const updateFileManagerSettings = useAppStore(state => state.updateFileManagerSettings); + const updateStatusBarSettings = useAppStore(state => state.updateStatusBarSettings); const updateLocalTermSettings = useAppStore(state => state.updateLocalTermSettings); const updateKeybindings = useAppStore(state => state.updateKeybindings); const updateGhostSuggestionsSettings = useAppStore(state => state.updateGhostSuggestionsSettings); @@ -105,6 +107,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } }; + // Use the store action so merges happen against current state, not the render snapshot. const setGhostSuggestionsField = (patch: Partial) => { updateGhostSuggestionsSettings(patch).catch((error: unknown) => { @@ -367,7 +370,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { return; } e.preventDefault(); - const tabs: Tab[] = ['general', 'terminal', 'appearance', 'fileManager', 'shortcuts', 'plugins', 'ai', 'about']; + const tabs: Tab[] = ['general', 'terminal', 'appearance', 'statusBar', 'fileManager', 'shortcuts', 'plugins', 'ai', 'about']; const currentIndex = tabs.indexOf(activeTab); let nextIndex: number; @@ -564,6 +567,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { handleTabChange('general')} icon={} label="General" tabIndex={getTabIndex('general')} /> handleTabChange('terminal')} icon={} label="Terminal" tabIndex={getTabIndex('terminal')} /> handleTabChange('appearance')} icon={} label="Appearance" tabIndex={getTabIndex('appearance')} /> + handleTabChange('statusBar')} icon={} label="Status Bar" tabIndex={getTabIndex('statusBar')} /> handleTabChange('fileManager')} icon={} label="File Manager" tabIndex={getTabIndex('fileManager')} /> handleTabChange('shortcuts')} icon={} label="Shortcuts" tabIndex={getTabIndex('shortcuts')} /> handleTabChange('plugins')} icon={} label="Plugins" tabIndex={getTabIndex('plugins')} /> @@ -609,6 +613,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {

{activeTab === 'fileManager' ? 'File Manager' + : activeTab === 'statusBar' + ? 'Status Bar' : activeTab === 'ai' ? 'AI Assistant' : activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} @@ -663,6 +669,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { /> )} + {activeTab === 'statusBar' && ( + + )} + {activeTab === 'appearance' && ( ) => Promise; +} + +export function StatusBarTab({ settings, updateStatusBarSettings }: StatusBarTabProps) { + const [isUpdating, setIsUpdating] = useState(false); + const showToast = useAppStore((state) => state.showToast); + const showConnectionLatency = + settings.statusBar?.showConnectionLatency ?? DEFAULT_STATUS_BAR_SETTINGS.showConnectionLatency; + + const runUpdate = async (work: () => Promise) => { + setIsUpdating(true); + try { + await work(); + } catch (error) { + console.error('Failed to update status bar settings', error); + const message = error instanceof Error ? error.message : String(error); + showToast('error', `Failed to save status bar setting: ${message}`); + } finally { + setIsUpdating(false); + } + }; + + return ( +
+
+ { + void runUpdate(() => updateStatusBarSettings({ showConnectionLatency: value })); + }} + /> +
+
+ ); +} diff --git a/src/features/connections/infrastructure/connectionIpc.ts b/src/features/connections/infrastructure/connectionIpc.ts index cb27f842..80db1b01 100644 --- a/src/features/connections/infrastructure/connectionIpc.ts +++ b/src/features/connections/infrastructure/connectionIpc.ts @@ -121,6 +121,11 @@ export const connectIpc = async (config: ConnectionConfigPayload): Promise => window.ipcRenderer.invoke('ssh:disconnect', connectionId); +export const measureConnectionLatencyIpc = async ( + connectionId: string, +): Promise<{ connectionId: string; rttMs: number }> => + window.ipcRenderer.invoke('ssh:connectionLatency', connectionId); + export const cancelConnectIpc = async (connectionId: string, attemptId?: string): Promise => window.ipcRenderer.invoke('ssh:cancelConnect', { connectionId, attemptId }); diff --git a/src/features/statusBar/StatusBarLatency.tsx b/src/features/statusBar/StatusBarLatency.tsx new file mode 100644 index 00000000..5d75d377 --- /dev/null +++ b/src/features/statusBar/StatusBarLatency.tsx @@ -0,0 +1,30 @@ +import { cn } from '../../lib/utils'; +import { Tooltip } from '../../components/ui/Tooltip'; +import { formatConnectionLatency, formatConnectionLatencyParts, latencyTone } from './latency.js'; + +export function StatusBarLatency({ ms }: { ms: number | null }) { + if (ms === null) { + return null; + } + + const tone = latencyTone(ms); + const { value, unit } = formatConnectionLatencyParts(ms); + const label = formatConnectionLatency(ms); + + return ( + + + {value} + {unit} + + + ); +} diff --git a/src/features/statusBar/index.ts b/src/features/statusBar/index.ts new file mode 100644 index 00000000..9d63bdb0 --- /dev/null +++ b/src/features/statusBar/index.ts @@ -0,0 +1,16 @@ +export { + DEFAULT_STATUS_BAR_SETTINGS, + normalizeStatusBarSettings, +} from './settings.js'; +export { + LATENCY_PROBE_INTERVAL_MS, + formatConnectionLatency, + formatConnectionLatencyParts, + latencyTone, + parseLatencyRttMs, + shouldMeasureConnectionLatency, + smoothLatencySample, +} from './latency.js'; +export type { LatencyTone, StatusBarSettings } from './types.js'; +export { useConnectionLatency } from './useConnectionLatency.js'; +export { StatusBarLatency } from './StatusBarLatency.js'; diff --git a/src/features/statusBar/latency.ts b/src/features/statusBar/latency.ts new file mode 100644 index 00000000..6fa90072 --- /dev/null +++ b/src/features/statusBar/latency.ts @@ -0,0 +1,68 @@ +import type { LatencyTone } from './types.js'; + +const LOCAL_WORKSPACE_ID = 'local'; + +export const LATENCY_PROBE_INTERVAL_MS = 5_000; +const SMOOTH_PREVIOUS_WEIGHT = 0.65; +const SMOOTH_SAMPLE_WEIGHT = 0.35; +const GOOD_MS = 100; +const HIGH_MS = 250; + +export function shouldMeasureConnectionLatency(options: { + connectionId: string | null | undefined; + enabled: boolean; + isLive: boolean; +}): boolean { + const { connectionId, enabled, isLive } = options; + if (!enabled || !isLive) { + return false; + } + if (!connectionId || connectionId === LOCAL_WORKSPACE_ID) { + return false; + } + return true; +} + +export function parseLatencyRttMs(payload: unknown): number | null { + if (typeof payload === 'number' && Number.isFinite(payload) && payload >= 0) { + return Math.round(payload); + } + if (!payload || typeof payload !== 'object') { + return null; + } + const rttMs = (payload as { rttMs?: unknown }).rttMs; + if (typeof rttMs !== 'number' || !Number.isFinite(rttMs) || rttMs < 0) { + return null; + } + return Math.round(rttMs); +} + +/** Exponential moving average so the status bar does not flicker. */ +export function smoothLatencySample(previous: number | null, sample: number): number { + if (previous === null) { + return sample; + } + return Math.round(previous * SMOOTH_PREVIOUS_WEIGHT + sample * SMOOTH_SAMPLE_WEIGHT); +} + +export function latencyTone(ms: number): LatencyTone { + if (ms < GOOD_MS) { + return 'good'; + } + if (ms < HIGH_MS) { + return 'ok'; + } + return 'high'; +} + +export function formatConnectionLatencyParts(ms: number): { value: string; unit: 'ms' | 's' } { + if (ms >= 10_000) { + return { value: (ms / 1000).toFixed(1), unit: 's' }; + } + return { value: String(ms), unit: 'ms' }; +} + +export function formatConnectionLatency(ms: number): string { + const { value, unit } = formatConnectionLatencyParts(ms); + return `${value}${unit}`; +} diff --git a/src/features/statusBar/settings.ts b/src/features/statusBar/settings.ts new file mode 100644 index 00000000..f6a2b222 --- /dev/null +++ b/src/features/statusBar/settings.ts @@ -0,0 +1,13 @@ +import type { StatusBarSettings } from './types.js'; + +export const DEFAULT_STATUS_BAR_SETTINGS: StatusBarSettings = { + showConnectionLatency: true, +}; + +export function normalizeStatusBarSettings( + raw?: Partial | null, +): StatusBarSettings { + return { + showConnectionLatency: raw?.showConnectionLatency !== false, + }; +} diff --git a/src/features/statusBar/types.ts b/src/features/statusBar/types.ts new file mode 100644 index 00000000..826e9b25 --- /dev/null +++ b/src/features/statusBar/types.ts @@ -0,0 +1,6 @@ +export interface StatusBarSettings { + /** Show SSH round-trip latency next to the connected host. */ + showConnectionLatency: boolean; +} + +export type LatencyTone = 'good' | 'ok' | 'high'; diff --git a/src/features/statusBar/useConnectionLatency.ts b/src/features/statusBar/useConnectionLatency.ts new file mode 100644 index 00000000..d8de7a14 --- /dev/null +++ b/src/features/statusBar/useConnectionLatency.ts @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react'; +import { measureConnectionLatencyIpc } from '../connections/infrastructure/connectionIpc.js'; +import { + LATENCY_PROBE_INTERVAL_MS, + parseLatencyRttMs, + shouldMeasureConnectionLatency, + smoothLatencySample, +} from './latency.js'; + +export function useConnectionLatency(options: { + connectionId: string | null | undefined; + enabled: boolean; + isLive: boolean; +}): number | null { + const { connectionId, enabled, isLive } = options; + const [ms, setMs] = useState(null); + const smoothedRef = useRef(null); + + useEffect(() => { + smoothedRef.current = null; + setMs(null); + }, [connectionId]); + + useEffect(() => { + let cancelled = false; + let inFlight = false; + let timer: ReturnType | null = null; + + const clearTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const schedule = (delay: number) => { + clearTimer(); + timer = setTimeout(() => { + void probe(); + }, delay); + }; + + const probe = async () => { + if (cancelled || inFlight) { + return; + } + + if (!shouldMeasureConnectionLatency({ connectionId, enabled, isLive })) { + smoothedRef.current = null; + setMs(null); + return; + } + + if (typeof document !== 'undefined' && document.hidden) { + return; + } + + inFlight = true; + try { + const payload = await measureConnectionLatencyIpc(connectionId!); + if (cancelled) { + return; + } + const sample = parseLatencyRttMs(payload); + if (sample === null) { + return; + } + const next = smoothLatencySample(smoothedRef.current, sample); + smoothedRef.current = next; + setMs(next); + } catch { + // Fail soft: keep last good sample until disconnect / disable. + } finally { + inFlight = false; + if (!cancelled && shouldMeasureConnectionLatency({ connectionId, enabled, isLive })) { + schedule(LATENCY_PROBE_INTERVAL_MS); + } + } + }; + + if (!shouldMeasureConnectionLatency({ connectionId, enabled, isLive })) { + smoothedRef.current = null; + setMs(null); + return () => { + cancelled = true; + clearTimer(); + }; + } + + void probe(); + + const onVisibility = () => { + if (typeof document !== 'undefined' && document.hidden) { + return; + } + if (!inFlight) { + void probe(); + } + }; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibility); + } + + return () => { + cancelled = true; + clearTimer(); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibility); + } + }; + }, [connectionId, enabled, isLive]); + + return ms; +} diff --git a/src/lib/tauri-ipc.ts b/src/lib/tauri-ipc.ts index d73eba03..88b6f1d4 100644 --- a/src/lib/tauri-ipc.ts +++ b/src/lib/tauri-ipc.ts @@ -172,6 +172,7 @@ const ipcRenderer = { 'tunnel:start': 'tunnel_start', 'tunnel:stop': 'tunnel_stop', 'ssh:exec': 'ssh_exec', + 'ssh:connectionLatency': 'ssh_connection_latency', 'ssh:test': 'ssh_test_connection', 'ssh:extract-pem': 'ssh_extract_pem', @@ -323,7 +324,11 @@ const ipcRenderer = { // Manual argument mapping for mismatched commands if (tauriCommand === 'ssh_connect' || tauriCommand === 'ssh_test_connection') { payload = { config: args[0] }; - } else if (tauriCommand === 'ssh_disconnect' || tauriCommand === 'ssh_transport_lost') { + } else if ( + tauriCommand === 'ssh_disconnect' + || tauriCommand === 'ssh_transport_lost' + || tauriCommand === 'ssh_connection_latency' + ) { payload = { id: args[0] }; } else if (tauriCommand === 'ssh_cancel_connect') { if (args.length === 1 && hasConnectionId(args[0])) { diff --git a/src/store/settingsSlice.ts b/src/store/settingsSlice.ts index 402af1a2..0f33141a 100644 --- a/src/store/settingsSlice.ts +++ b/src/store/settingsSlice.ts @@ -22,6 +22,11 @@ import { normalizeNotificationSettings, type NotificationSettings, } from '../features/notifications'; +import { + DEFAULT_STATUS_BAR_SETTINGS, + normalizeStatusBarSettings, +} from '../features/statusBar/settings.js'; +import type { StatusBarSettings } from '../features/statusBar/types.js'; export interface AppSettings { theme: string; @@ -137,6 +142,7 @@ export interface AppSettings { showHostAddressesInLists: boolean; }; notifications: NotificationSettings; + statusBar: StatusBarSettings; } export const defaultSettings: AppSettings = { @@ -167,6 +173,7 @@ export const defaultSettings: AppSettings = { showHostAddressesInLists: DEFAULT_SHOW_HOST_ADDRESSES_IN_LISTS, }, notifications: { ...DEFAULT_NOTIFICATION_SETTINGS }, + statusBar: { ...DEFAULT_STATUS_BAR_SETTINGS }, terminal: { ...(() => { const typography = resolveDefaultTerminalTypography(); @@ -343,6 +350,7 @@ export type SettingsTabId = | 'general' | 'terminal' | 'appearance' + | 'statusBar' | 'fileManager' | 'shortcuts' | 'plugins' @@ -365,6 +373,7 @@ export interface SettingsSlice { updateTerminalSettings: (updates: Partial) => Promise; updateLocalTermSettings: (updates: Partial) => Promise; updateFileManagerSettings: (updates: Partial) => Promise; + updateStatusBarSettings: (updates: Partial) => Promise; updateGhostSuggestionsSettings: (updates: Partial) => Promise; updateKeybindings: (updates: Partial) => Promise; toggleExpandedFolder: (folderPath: string) => Promise; @@ -430,6 +439,7 @@ export const createSettingsSlice: StateCreator ai: { ...defaultSettings.ai, ...(loaded?.ai || {}) }, privacy: { ...defaultSettings.privacy, ...(loaded?.privacy || {}) }, notifications: normalizeNotificationSettings(loaded?.notifications), + statusBar: normalizeStatusBarSettings(loaded?.statusBar), expandedFolders: loaded?.expandedFolders || [] }; set({ settings: merged, isLoadingSettings: false }); @@ -631,6 +641,40 @@ export const createSettingsSlice: StateCreator } }, + updateStatusBarSettings: async (updates) => { + const previous = get().settings; + const previousBar = normalizeStatusBarSettings(previous.statusBar); + const updatedBar = { ...previousBar, ...updates }; + const updated = { + ...previous, + statusBar: updatedBar, + }; + set({ settings: updated }); + const changedKeys = Object.keys(updates) as Array; + try { + await persistSettings({ statusBar: updates }); + } catch (error) { + console.error('Failed to save status bar settings:', error); + const current = get().settings; + if (current.statusBar !== updatedBar) { + throw error; + } + const rollbackPatch = Object.fromEntries( + changedKeys.map((key) => [key, previousBar[key]]), + ) as Partial; + set({ + settings: { + ...current, + statusBar: { + ...normalizeStatusBarSettings(current.statusBar), + ...rollbackPatch, + }, + }, + }); + throw error; + } + }, + updateFileManagerSettings: async (updates) => { const previous = get().settings; const updated = { diff --git a/tests/runAllAgentTests.mjs b/tests/runAllAgentTests.mjs index 46973cfb..93b06aa6 100644 --- a/tests/runAllAgentTests.mjs +++ b/tests/runAllAgentTests.mjs @@ -82,6 +82,7 @@ const tests = [ 'tests/terminalResizeSync.test.mjs', 'tests/terminalReconnectReset.test.mjs', 'tests/sidebarSubmit.test.mjs', + 'tests/statusBarLatency.test.mjs', 'tests/tunnelAutoStartService.test.mjs', 'tests/tunnelReconnectService.test.mjs', 'tests/syncPassphrase.test.mjs', diff --git a/tests/statusBarLatency.test.mjs b/tests/statusBarLatency.test.mjs new file mode 100644 index 00000000..ea03267a --- /dev/null +++ b/tests/statusBarLatency.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { + formatConnectionLatency, + formatConnectionLatencyParts, + latencyTone, + parseLatencyRttMs, + shouldMeasureConnectionLatency, + smoothLatencySample, +} from '../.tmp-agent-tests/src/features/statusBar/latency.js'; +import { normalizeStatusBarSettings } from '../.tmp-agent-tests/src/features/statusBar/settings.js'; + +function runTest(name, fn) { + try { + fn(); + console.log(` ok ${name}`); + } catch (error) { + console.error(` fail ${name}`); + throw error; + } +} + +runTest('normalizeStatusBarSettings defaults to showing latency', () => { + assert.deepEqual(normalizeStatusBarSettings(undefined), { showConnectionLatency: true }); + assert.deepEqual(normalizeStatusBarSettings({}), { showConnectionLatency: true }); +}); + +runTest('normalizeStatusBarSettings respects explicit false', () => { + assert.deepEqual(normalizeStatusBarSettings({ showConnectionLatency: false }), { + showConnectionLatency: false, + }); +}); + +runTest('shouldMeasureConnectionLatency skips local and offline', () => { + assert.equal(shouldMeasureConnectionLatency({ + connectionId: 'host-1', + enabled: true, + isLive: true, + }), true); + assert.equal(shouldMeasureConnectionLatency({ + connectionId: 'local', + enabled: true, + isLive: true, + }), false); + assert.equal(shouldMeasureConnectionLatency({ + connectionId: 'host-1', + enabled: false, + isLive: true, + }), false); + assert.equal(shouldMeasureConnectionLatency({ + connectionId: 'host-1', + enabled: true, + isLive: false, + }), false); +}); + +runTest('parseLatencyRttMs reads camelCase payload and rejects junk', () => { + assert.equal(parseLatencyRttMs({ connectionId: 'h', rttMs: 42.6 }), 43); + assert.equal(parseLatencyRttMs(12), 12); + assert.equal(parseLatencyRttMs({ rttMs: -1 }), null); + assert.equal(parseLatencyRttMs({ rttMs: 'nope' }), null); + assert.equal(parseLatencyRttMs(null), null); +}); + +runTest('smoothLatencySample uses first sample then blends', () => { + assert.equal(smoothLatencySample(null, 40), 40); + assert.equal(smoothLatencySample(100, 0), 65); +}); + +runTest('latencyTone buckets good / ok / high', () => { + assert.equal(latencyTone(20), 'good'); + assert.equal(latencyTone(120), 'ok'); + assert.equal(latencyTone(400), 'high'); +}); + +runTest('formatConnectionLatency uses compact units', () => { + assert.equal(formatConnectionLatency(42), '42ms'); + assert.deepEqual(formatConnectionLatencyParts(42), { value: '42', unit: 'ms' }); + assert.equal(formatConnectionLatency(9999), '9999ms'); + assert.equal(formatConnectionLatency(10_000), '10.0s'); + assert.deepEqual(formatConnectionLatencyParts(10_000), { value: '10.0', unit: 's' }); +}); + +console.log('Status bar latency tests passed.'); diff --git a/tsconfig.agent-tests.json b/tsconfig.agent-tests.json index e558a0c4..b4204e98 100644 --- a/tsconfig.agent-tests.json +++ b/tsconfig.agent-tests.json @@ -62,6 +62,9 @@ "src/features/connections/domain/types.ts", "src/features/connections/domain/validation.ts", "src/features/notifications/types.ts", + "src/features/statusBar/types.ts", + "src/features/statusBar/settings.ts", + "src/features/statusBar/latency.ts", "src/features/notifications/policy.ts", "src/features/notifications/historyOps.ts", "src/features/notifications/storage.ts", From 64b4de0424b8fefcc615231348bac1e4a43566a4 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Thu, 27 Aug 2026 23:47:48 +0530 Subject: [PATCH 04/16] docs(changelog): note status bar SSH latency feature Record the Unreleased changelog entry for the latency chip and Status Bar settings tab, referencing 2608d05. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9efcfa..6a6a7ff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Zync are documented in this file. The format is based on ## [Unreleased] +### Added +- **Status bar SSH latency**: Live round-trip chip replaces the connected wifi icon (`21ms`), with Settings → Status Bar to toggle it. Probes the active SSH session only; fails soft when unknown. ([2608d05]) + ### Fixed - **Remote PTY / tmux initial size (#101)**: Terminal geometry is retained while the PTY is starting and flushed on `terminal-ready`, so tmux and other remote sessions fill the viewport on first attach without requiring a manual window resize. ([ff07642]) From 12a8aa77ae38f5b0ef1365da756574596942ccc7 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Fri, 28 Aug 2026 00:41:47 +0530 Subject: [PATCH 05/16] feat(ui): branded connect stage with smoother loading flow Replace the generic connect spinner with a loaders kit and a single connect stage panel. Dash rides the host tile, connecting/error share the same frame, and success fades the overlay into the workspace. --- src/components/layout/MainLayout.tsx | 75 ++++++++----------- src/components/loaders/ConnectLoader.tsx | 65 ++++++++++++++++ src/components/loaders/ConnectStagePanel.tsx | 57 ++++++++++++++ src/components/loaders/InlineSpinner.tsx | 38 ++++++++++ src/components/loaders/PanelLoader.tsx | 10 +++ src/components/loaders/index.ts | 6 ++ .../loaders/useConnectionStageOverlay.ts | 36 +++++++++ src/index.css | 33 ++++++++ 8 files changed, 275 insertions(+), 45 deletions(-) create mode 100644 src/components/loaders/ConnectLoader.tsx create mode 100644 src/components/loaders/ConnectStagePanel.tsx create mode 100644 src/components/loaders/InlineSpinner.tsx create mode 100644 src/components/loaders/PanelLoader.tsx create mode 100644 src/components/loaders/index.ts create mode 100644 src/components/loaders/useConnectionStageOverlay.ts diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index ba5989ed..328be4a0 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -16,6 +16,7 @@ import { listen } from '@tauri-apps/api/event'; import { Modal } from '../ui/Modal'; import { Button } from '../ui/Button'; import { ShieldAlert, Loader2 } from 'lucide-react'; +import { ConnectStagePanel, PanelLoader, useConnectionStageOverlay } from '../loaders'; import ReleaseNotesTab from '../tabs/ReleaseNotesTab'; import { SnippetSidebar } from '../snippets/SnippetSidebar'; import { SetupWizard } from '../onboarding/SetupWizard'; @@ -73,12 +74,7 @@ const SyncBackupWorkspacePanel = lazy(() => import('../sync/SyncBackupWorkspacePanel').then(module => ({ default: module.default })) ); -// Loading Component -const TabLoading = () => ( -
-
-
-); +const TabLoading = () => ; /** * Fallback splash only if boot splash is gone early. @@ -405,7 +401,11 @@ const TabContent = memo(function TabContent({ tab, isActive }: { const isConnecting = connection?.status === 'connecting'; const isError = connection?.status === 'error'; - const forceOpaqueShell = isConnecting || isError; + const { stage, visible: stageVisible, showWorkspace } = useConnectionStageOverlay( + Boolean(isConnecting), + Boolean(isError), + ); + const forceOpaqueShell = isConnecting || isError || Boolean(stage); /** * Handles selection from the combined tab bar. @@ -513,44 +513,7 @@ const TabContent = memo(function TabContent({ tab, isActive }: { !isActive && "hidden", isActive && !forceOpaqueShell && "animate-in fade-in duration-150 ease-out fill-mode-forwards" )}> - {isConnecting ? ( -
-
-
Connecting to server...
- -
- ) : isError ? ( -
-
-
Connection Failed
-
- Could not establish a connection to {connection?.host}. -
- {connection?.lastError && ( -
- {connection.lastError} -
- )} - {connection?.authRef && ( -
- Vault credential: {connection.authRef.credentialId?.slice(0, 8) ?? connection.authRef.itemId?.slice(0, 8) ?? ''} -
- )} - -
- ) : ( + {showWorkspace && ( <> {/* Unified Tab Bar — not shown for the standalone global snippets tab */} {tab.connectionId !== GLOBAL_SNIPPETS_CONNECTION_ID && ( @@ -652,6 +615,28 @@ const TabContent = memo(function TabContent({ tab, isActive }: {
)} + {stage && ( +
+ { + if (connection) void cancelConnect(connection.id); + }} + onRetry={() => { + if (connection) void connect(connection.id); + }} + /> +
+ )}

); }); diff --git a/src/components/loaders/ConnectLoader.tsx b/src/components/loaders/ConnectLoader.tsx new file mode 100644 index 00000000..1016725d --- /dev/null +++ b/src/components/loaders/ConnectLoader.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; +import { cn } from '../../lib/utils'; + +export function ConnectLoader({ + status, + icon, + size = 88, +}: { + status: 'connecting' | 'error'; + icon: ReactNode; + size?: number; +}) { + const paused = status === 'error'; + + return ( +
+ + + + + +
+ {icon} +
+
+ ); +} diff --git a/src/components/loaders/ConnectStagePanel.tsx b/src/components/loaders/ConnectStagePanel.tsx new file mode 100644 index 00000000..07226573 --- /dev/null +++ b/src/components/loaders/ConnectStagePanel.tsx @@ -0,0 +1,57 @@ +import { OSIcon } from '../icons/OSIcon'; +import { Button } from '../ui/Button'; +import { ConnectLoader } from './ConnectLoader'; + +export function ConnectStagePanel({ + status, + name, + host, + icon, + lastError, + onCancel, + onRetry, +}: { + status: 'connecting' | 'error'; + name: string; + host?: string; + icon?: string; + lastError?: string; + onCancel?: () => void; + onRetry?: () => void; +}) { + const isError = status === 'error'; + const label = name.trim() || host || 'host'; + + return ( +
+ } + /> +
+

{label}

+

+ {isError ? 'Connection failed' : 'Connecting…'} +

+
+ {isError && lastError && ( +
+ {lastError} +
+ )} + {isError && onRetry ? ( + + ) : !isError && onCancel ? ( + + ) : null} +
+ ); +} diff --git a/src/components/loaders/InlineSpinner.tsx b/src/components/loaders/InlineSpinner.tsx new file mode 100644 index 00000000..ae83785d --- /dev/null +++ b/src/components/loaders/InlineSpinner.tsx @@ -0,0 +1,38 @@ +import { cn } from '../../lib/utils'; + +export function InlineSpinner({ + size = 16, + className, + label = 'Loading', +}: { + size?: number; + className?: string; + label?: string; +}) { + return ( + + + + + ); +} diff --git a/src/components/loaders/PanelLoader.tsx b/src/components/loaders/PanelLoader.tsx new file mode 100644 index 00000000..c8a25a71 --- /dev/null +++ b/src/components/loaders/PanelLoader.tsx @@ -0,0 +1,10 @@ +import { cn } from '../../lib/utils'; +import { InlineSpinner } from './InlineSpinner'; + +export function PanelLoader({ className }: { className?: string }) { + return ( +
+ +
+ ); +} diff --git a/src/components/loaders/index.ts b/src/components/loaders/index.ts new file mode 100644 index 00000000..35c817aa --- /dev/null +++ b/src/components/loaders/index.ts @@ -0,0 +1,6 @@ +export { ConnectLoader } from './ConnectLoader'; +export { ConnectStagePanel } from './ConnectStagePanel'; +export { InlineSpinner } from './InlineSpinner'; +export { PanelLoader } from './PanelLoader'; +export { useConnectionStageOverlay } from './useConnectionStageOverlay'; +export type { ConnectionStage } from './useConnectionStageOverlay'; diff --git a/src/components/loaders/useConnectionStageOverlay.ts b/src/components/loaders/useConnectionStageOverlay.ts new file mode 100644 index 00000000..01b7ec65 --- /dev/null +++ b/src/components/loaders/useConnectionStageOverlay.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +const FADE_MS = 220; + +export type ConnectionStage = 'connecting' | 'error'; + +/** Lags overlay unmount so connecting → live can cross-fade instead of hard-cutting. */ +export function useConnectionStageOverlay(isConnecting: boolean, isError: boolean) { + const [stage, setStage] = useState( + isConnecting ? 'connecting' : isError ? 'error' : null, + ); + const [visible, setVisible] = useState(Boolean(isConnecting || isError)); + + useEffect(() => { + if (isConnecting) { + setStage('connecting'); + setVisible(true); + return; + } + if (isError) { + setStage('error'); + setVisible(true); + return; + } + + setVisible(false); + const timer = window.setTimeout(() => setStage(null), FADE_MS); + return () => window.clearTimeout(timer); + }, [isConnecting, isError]); + + return { + stage, + visible, + showWorkspace: !isConnecting && !isError, + }; +} diff --git a/src/index.css b/src/index.css index 5ee84562..f94a13c8 100644 --- a/src/index.css +++ b/src/index.css @@ -238,7 +238,40 @@ -moz-osx-font-smoothing: grayscale; } +@keyframes zync-loader-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes zync-loader-dash { + to { + stroke-dashoffset: -276; + } +} + +.zync-inline-spinner { + animation: zync-loader-spin 0.85s linear infinite; + transform-origin: 50% 50%; +} + +.zync-connect-orbit-dash { + stroke-dasharray: 36 240; + animation: zync-loader-dash 1.65s linear infinite; +} + +.zync-connect-orbit-dash.is-paused { + animation: none; + stroke-dasharray: none; + stroke-dashoffset: 0; +} + @media (prefers-reduced-motion: reduce) { + .zync-inline-spinner, + .zync-connect-orbit-dash { + animation: none !important; + } + .zync-splash-icon, .zync-splash-meta, .zync-splash-ring-spin { From f8380dbc124b420d23724d6b2b5fbd33b28606c0 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Fri, 28 Aug 2026 00:41:56 +0530 Subject: [PATCH 06/16] docs(changelog): note branded connect stage loader Record the Unreleased changelog entry for the connect loading UX, referencing 12a8aa7. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a6a7ff5..2cc16a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Zync are documented in this file. The format is based on ### Added - **Status bar SSH latency**: Live round-trip chip replaces the connected wifi icon (`21ms`), with Settings → Status Bar to toggle it. Probes the active SSH session only; fails soft when unknown. ([2608d05]) +- **Branded connect stage**: Host connect uses a loaders kit (`ConnectLoader` / `ConnectStagePanel`) with a dash on the host tile, quiet Connecting… copy, and a fade into the workspace (or the same-frame error + Retry). ([12a8aa7]) ### Fixed - **Remote PTY / tmux initial size (#101)**: Terminal geometry is retained while the PTY is starting and flushed on `terminal-ready`, so tmux and other remote sessions fill the viewport on first attach without requiring a manual window resize. ([ff07642]) From 9903eb857470ce14263f558681da82064624afe7 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Fri, 28 Aug 2026 13:37:41 +0530 Subject: [PATCH 07/16] feat(survey): wire install/release survey and Settings feedback Add the survey API client, welcome/update check-in modal, Settings Feedback tab (including public GitHub issue prefills), Select portal fixes for modal dropdowns, and local survey preference persistence. --- .env.example | 3 + src/components/layout/MainLayout.tsx | 100 +++++++ src/components/settings/SettingsModal.tsx | 14 +- src/components/settings/tabs/FeedbackTab.tsx | 179 ++++++++++++ src/components/survey/SurveyPromptModal.tsx | 277 +++++++++++++++++++ src/components/ui/Select.tsx | 16 +- src/features/survey/client.ts | 62 +++++ src/features/survey/config.ts | 8 + src/features/survey/eligibility.ts | 24 ++ src/features/survey/githubIssue.ts | 58 ++++ src/features/survey/index.ts | 31 +++ src/features/survey/options.ts | 47 ++++ src/features/survey/platform.ts | 24 ++ src/features/survey/prefill.ts | 14 + src/features/survey/settings.ts | 23 ++ src/features/survey/types.ts | 65 +++++ src/lib/debugFlags.ts | 24 ++ src/store/settingsSlice.ts | 47 ++++ tests/runAllAgentTests.mjs | 1 + tests/surveyEligibility.test.mjs | 54 ++++ tsconfig.agent-tests.json | 3 + 21 files changed, 1066 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 src/components/settings/tabs/FeedbackTab.tsx create mode 100644 src/components/survey/SurveyPromptModal.tsx create mode 100644 src/features/survey/client.ts create mode 100644 src/features/survey/config.ts create mode 100644 src/features/survey/eligibility.ts create mode 100644 src/features/survey/githubIssue.ts create mode 100644 src/features/survey/index.ts create mode 100644 src/features/survey/options.ts create mode 100644 src/features/survey/platform.ts create mode 100644 src/features/survey/prefill.ts create mode 100644 src/features/survey/settings.ts create mode 100644 src/features/survey/types.ts create mode 100644 tests/surveyEligibility.test.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4f1a308c --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Optional: zync-survey API base URL (no trailing slash). +# Dev default in code is http://127.0.0.1:8090 when unset. +# VITE_SURVEY_API_URL=https://survey.example.com diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 328be4a0..ac41ade2 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -17,6 +17,13 @@ import { Modal } from '../ui/Modal'; import { Button } from '../ui/Button'; import { ShieldAlert, Loader2 } from 'lucide-react'; import { ConnectStagePanel, PanelLoader, useConnectionStageOverlay } from '../loaders'; +import { SurveyPromptModal } from '../survey/SurveyPromptModal'; +import { + normalizeSurveySettings, + resolveSurveyPromptKind, + type SurveyPromptKind, +} from '../../features/survey'; +import { getDebugSurveyPromptKind, isDebugSurveyPromptEnabled } from '../../lib/debugFlags'; import ReleaseNotesTab from '../tabs/ReleaseNotesTab'; import { SnippetSidebar } from '../snippets/SnippetSidebar'; import { SetupWizard } from '../onboarding/SetupWizard'; @@ -726,7 +733,10 @@ export function MainLayout({ children }: { children: ReactNode }) { const settings = useAppStore(state => state.settings); const sidebarCollapsed = settings.sidebarCollapsed; const updateSettings = useAppStore(state => state.updateSettings); + const updateSurveySettings = useAppStore(state => state.updateSurveySettings); const setSidebarCollapsedLocal = useAppStore(state => state.setSidebarCollapsedLocal); + const [surveyPrompt, setSurveyPrompt] = useState<{ kind: SurveyPromptKind; version: string } | null>(null); + const surveyChecked = useRef(false); // Shutdown Management const [isShutdownModalOpen, setIsShutdownModalOpen] = useState(false); @@ -848,6 +858,88 @@ export function MainLayout({ children }: { children: ReactNode }) { checkVersionAndShowNotes(); }, [isLoadingSettings, openReleaseNotesTab, updateSettings]); + // Profile survey: install once, or release check-in after updating from a prior version. + useEffect(() => { + if (isLoadingSettings || !sessionLoaded || surveyChecked.current) return; + surveyChecked.current = true; + + // Capture before other boot effects rewrite lastSeenVersion. + const state = useAppStore.getState(); + const survey = normalizeSurveySettings(state.settings.survey); + const previousSeenVersion = state.settings.lastSeenVersion || ''; + + const maybeShowSurvey = async () => { + try { + const currentVersion = await window.ipcRenderer?.invoke('app:getVersion'); + if (!currentVersion || typeof currentVersion !== 'string') return; + + const debugKind = isDebugSurveyPromptEnabled() + ? (getDebugSurveyPromptKind() ?? 'install') + : null; + const kind = debugKind ?? resolveSurveyPromptKind(survey, currentVersion, previousSeenVersion); + if (!kind) return; + + if (kind === 'release' && !debugKind) { + // Prefer showing after What's New is closed (max ~8s). + const started = Date.now(); + await new Promise((resolve) => { + const tick = () => { + const activeId = useAppStore.getState().activeTabId; + const active = useAppStore.getState().tabs.find((tab) => tab.id === activeId); + const notesOpen = active?.type === 'release-notes'; + if (!notesOpen || Date.now() - started > 8000) { + resolve(); + return; + } + window.setTimeout(tick, 350); + }; + window.setTimeout(tick, 600); + }); + } else { + await new Promise((resolve) => window.setTimeout(resolve, 900)); + } + + setSurveyPrompt({ kind, version: currentVersion }); + } catch (err) { + console.error('Failed to resolve survey prompt', err); + } + }; + + void maybeShowSurvey(); + }, [isLoadingSettings, sessionLoaded]); + + const handleSurveyCompleted = useCallback(async ( + result: 'submitted' | 'skipped', + prefs?: { lastRole?: string; lastWorkContext?: string; lastDiscoverySource?: string }, + ) => { + const prompt = surveyPrompt; + setSurveyPrompt(null); + if (!prompt) return; + try { + const prefPatch = result === 'submitted' + ? { + lastRole: prefs?.lastRole ?? '', + lastWorkContext: prefs?.lastWorkContext ?? '', + lastDiscoverySource: prefs?.lastDiscoverySource ?? '', + } + : {}; + if (prompt.kind === 'install') { + await updateSurveySettings({ + installCompleted: true, + releaseSeenVersion: prompt.version, + ...prefPatch, + }); + } else { + await updateSurveySettings({ + releaseSeenVersion: prompt.version, + ...prefPatch, + }); + } + } catch (err) { + console.error(`Failed to persist survey ${result} state`, err); + } + }, [surveyPrompt, updateSurveySettings]); + // Theme Application Effect const theme = useAppStore(state => state.settings.theme); const accentColor = useAppStore(state => state.settings.accentColor); @@ -1081,6 +1173,14 @@ export function MainLayout({ children }: { children: ReactNode }) { /> {/* Portal Root for Modals/Overlays to ensure they stay within rounded corners */} ); } diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index b0448e9e..4dc1a0d7 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -6,7 +6,7 @@ import { ZPortal } from '../ui/ZPortal'; import { useAppStore } from '../../store/useAppStore'; // Updated Import import { usePlugins } from '../../context/PluginContext'; -import { X, Type, Monitor, FileText, Keyboard, Info, RefreshCw, FolderOpen, Settings as SettingsIcon, Package, Code, Sparkles, GripHorizontal, PanelBottom } from 'lucide-react'; +import { X, Type, Monitor, FileText, Keyboard, Info, RefreshCw, FolderOpen, Settings as SettingsIcon, Package, Code, Sparkles, GripHorizontal, PanelBottom, MessageSquare } from 'lucide-react'; import { ToastContainer } from '../ui/Toast'; import { buildEditorProviderOptions, CODEMIRROR_EDITOR_ID, formatEditorCapabilities } from '../editor/providers'; @@ -19,6 +19,7 @@ import { ShortcutsTab } from './tabs/ShortcutsTab'; import { PluginsTab } from './tabs/PluginsTab'; import { AboutTab } from './tabs/AboutTab'; import { StatusBarTab } from './tabs/StatusBarTab'; +import { FeedbackTab } from './tabs/FeedbackTab'; import { IconResolver } from './common/IconResolver'; import { TabButton } from './common/TabButton'; import { TiltLogo } from './common/TiltLogo'; @@ -38,7 +39,7 @@ interface SettingsModalProps { onClose: () => void; } -type Tab = 'general' | 'terminal' | 'appearance' | 'statusBar' | 'fileManager' | 'shortcuts' | 'plugins' | 'ai' | 'about'; +type Tab = 'general' | 'terminal' | 'appearance' | 'statusBar' | 'fileManager' | 'shortcuts' | 'plugins' | 'ai' | 'feedback' | 'about'; const BUILTIN_ICON_THEME_COUNT = 2; // VSCode Icons + Lucide const FOCUSABLE_SELECTOR = [ 'a[href]', @@ -370,7 +371,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { return; } e.preventDefault(); - const tabs: Tab[] = ['general', 'terminal', 'appearance', 'statusBar', 'fileManager', 'shortcuts', 'plugins', 'ai', 'about']; + const tabs: Tab[] = ['general', 'terminal', 'appearance', 'statusBar', 'fileManager', 'shortcuts', 'plugins', 'ai', 'feedback', 'about']; const currentIndex = tabs.indexOf(activeTab); let nextIndex: number; @@ -572,6 +573,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { handleTabChange('shortcuts')} icon={} label="Shortcuts" tabIndex={getTabIndex('shortcuts')} /> handleTabChange('plugins')} icon={} label="Plugins" tabIndex={getTabIndex('plugins')} /> handleTabChange('ai')} icon={} label="AI" tabIndex={getTabIndex('ai')} /> + handleTabChange('feedback')} icon={} label="Feedback" tabIndex={getTabIndex('feedback')} /> { @@ -617,6 +619,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { ? 'Status Bar' : activeTab === 'ai' ? 'AI Assistant' + : activeTab === 'feedback' + ? 'Feedback' : activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} )} + {activeTab === 'feedback' && ( + + )} + {activeTab === 'about' && ( state.showToast); + const [category, setCategory] = useState('improvement'); + const [message, setMessage] = useState(''); + const [contactEmail, setContactEmail] = useState(''); + const [allowContact, setAllowContact] = useState(false); + const [reproSteps, setReproSteps] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const openGitHubIssue = async () => { + const trimmed = message.trim(); + if (trimmed.length < 10) { + showToast('error', 'Please write at least a short message (10+ characters).'); + return; + } + try { + const appVersion = await resolveAppVersion(); + const url = buildGitHubFeedbackIssueUrl({ + category, + message: trimmed, + reproSteps: category === 'bug' ? reproSteps : undefined, + appVersion: appVersion || undefined, + platform: resolveSurveyPlatform(), + }); + await window.ipcRenderer.invoke('shell:open', url); + } catch (err) { + showToast('error', err instanceof Error ? err.message : 'Could not open GitHub'); + } + }; + + const handleSubmit = async () => { + const trimmed = message.trim(); + if (trimmed.length < 10) { + showToast('error', 'Please write at least a short message (10+ characters).'); + return; + } + + setSubmitting(true); + try { + const appVersion = await resolveAppVersion(); + await submitFeedback({ + schemaVersion: 1, + category, + message: trimmed, + appVersion: appVersion || 'unknown', + platform: resolveSurveyPlatform(), + arch: resolveSurveyArch(), + contactEmail: allowContact && contactEmail.trim() ? contactEmail.trim() : undefined, + allowContact: Boolean(allowContact && contactEmail.trim()), + submittedAt: new Date().toISOString(), + submittedFrom: 'app', + bugContext: + category === 'bug' && reproSteps.trim() + ? { reproSteps: reproSteps.trim().slice(0, 2000) } + : undefined, + }); + setMessage(''); + setReproSteps(''); + setContactEmail(''); + setAllowContact(false); + showToast('success', 'Thanks — feedback sent.'); + } catch (err) { + const raw = err instanceof Error ? err.message : String(err); + const friendly = raw.includes("Couldn't reach the server") + ? "Couldn't reach the server. Check your connection and try again." + : raw; + showToast('error', friendly); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

+ Send bugs, ideas, or praise anytime. +

+ +
+ +