From e4b5c1c6b911b0b3884fef76b04d52558ef64f60 Mon Sep 17 00:00:00 2001 From: George Benjamin-Schonberger Date: Wed, 13 May 2026 19:59:09 +0300 Subject: [PATCH 1/6] [fix:global] cors fix --- backend/src/routes/settings.ts | 2 +- backend/src/server.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 4beffd3..a183a2f 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify' import { db } from '../db/schema' const DEFAULTS: Record = { - bypass_permissions: 'true', + bypass_permissions: 'false', session_mode: 'terminal', } diff --git a/backend/src/server.ts b/backend/src/server.ts index 91e68af..09deda2 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,13 +18,16 @@ import { settingsRoutes } from './routes/settings' const fastify = Fastify({ logger: true }) async function start() { + const PORT = Number(process.env.PORT ?? 9998) + const HOST = process.env.HOST ?? '0.0.0.0' const devOrigin = process.env.FRONTEND_ORIGIN await fastify.register(cors, { methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], origin: (origin, cb) => { if (!origin) return cb(null, true) // same-origin / curl / no-CORS try { - if (new URL(origin).port === '9999') return cb(null, true) + const { port } = new URL(origin) + if (port === '9999' || port === String(PORT)) return cb(null, true) } catch { /* ignore malformed */ } if (devOrigin && origin === devOrigin) return cb(null, true) cb(new Error('Not allowed by CORS'), false) @@ -57,9 +60,6 @@ async function start() { }) } - const PORT = Number(process.env.PORT ?? 9998) - const HOST = process.env.HOST ?? '0.0.0.0' - try { await fastify.listen({ port: PORT, host: HOST }) } catch (err) { From ff790501fa42b94ee521658d94508316ee90d57a Mon Sep 17 00:00:00 2001 From: George Benjamin-Schonberger Date: Wed, 13 May 2026 20:19:29 +0300 Subject: [PATCH 2/6] [fix:global] fixes --- .env.example | 3 --- CLAUDE.md | 1 - Makefile | 8 +------- README.md | 1 - backend/src/routes/account.ts | 5 ++--- backend/src/routes/settings.test.ts | 2 +- backend/src/server.ts | 5 +++-- backend/src/services/accountCache.ts | 17 +++++++++++++++++ backend/src/services/claudeAccount.ts | 7 +++++-- frontend/src/hooks/useDashboard.ts | 11 ++++++++--- run.sh | 4 ---- scripts/test-service-install.sh | 2 -- 12 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 backend/src/services/accountCache.ts diff --git a/.env.example b/.env.example index 181b273..04c769f 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,3 @@ CLAUDE_BIN=claude # Backend server port (default: 9998) PORT=9998 - -# Allowed CORS origin for the Vite dev server (dev only; not needed in production) -FRONTEND_ORIGIN=http://localhost:9999 diff --git a/CLAUDE.md b/CLAUDE.md index 4993eb0..e4ee894 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,6 @@ cp .env.example backend/.env | `CLAUDE_BIN` | `claude` | Path to the `claude` binary | | `PORT` | `9998` | Backend listen port | | `HOST` | `0.0.0.0` | Backend listen address | -| `FRONTEND_ORIGIN` | `http://localhost:9999` | CORS allowed origin (dev only) | ## Dev ports diff --git a/Makefile b/Makefile index ee2522d..f09ee18 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,10 @@ .PHONY: dev dev-backend dev-frontend build lint test install clean run service-install service-uninstall service-test -# Fedora / RHEL hosts lack the Google Trust Services intermediate CA that -# Anthropic's API uses. Set NODE_EXTRA_CA_CERTS only when the bundle exists -# so the fix is a no-op on macOS, Ubuntu, Alpine, etc. -CA_BUNDLE := /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem -CA_ENV := $(if $(wildcard $(CA_BUNDLE)),NODE_EXTRA_CA_CERTS=$(CA_BUNDLE),) - dev: @make -j2 dev-backend dev-frontend dev-backend: - cd backend && $(CA_ENV) npm run dev + cd backend && npm run dev dev-frontend: cd frontend && npm run dev diff --git a/README.md b/README.md index 0b067ed..cc8f78a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,6 @@ cp .env.example backend/.env | `CLAUDE_BIN` | `claude` | Path to the `claude` binary | | `PORT` | `9998` | Backend listen port | | `HOST` | `0.0.0.0` | Backend listen address | -| `FRONTEND_ORIGIN` | `http://localhost:9999` | Allowed CORS origin (dev only) | ## Development diff --git a/backend/src/routes/account.ts b/backend/src/routes/account.ts index b23ae4f..079a070 100644 --- a/backend/src/routes/account.ts +++ b/backend/src/routes/account.ts @@ -1,9 +1,8 @@ import type { FastifyInstance } from 'fastify' -import { getAccountInfo } from '../services/claudeAccount' +import { getCachedAccount } from '../services/accountCache' export async function accountRoutes(fastify: FastifyInstance) { fastify.get('/api/account', async (_req, reply) => { - const info = await getAccountInfo() - return reply.send(info) + return reply.send(getCachedAccount()) }) } diff --git a/backend/src/routes/settings.test.ts b/backend/src/routes/settings.test.ts index d52fcdd..310c183 100644 --- a/backend/src/routes/settings.test.ts +++ b/backend/src/routes/settings.test.ts @@ -32,7 +32,7 @@ describe('settings routes', () => { expect(res.statusCode).toBe(200) const body = res.json() expect(body).toMatchObject({ - bypass_permissions: 'true', + bypass_permissions: 'false', session_mode: 'terminal', }) }) diff --git a/backend/src/server.ts b/backend/src/server.ts index 09deda2..4373f10 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,6 +10,7 @@ import { sessionRoutes } from './routes/sessions' import { sessionWsRoutes } from './ws/session' import { terminalWsRoutes } from './ws/terminal' import { settingsRoutes } from './routes/settings' +import { initAccountCache } from './services/accountCache' // TODO: add bearer token auth — add @fastify/bearer-auth plugin here // and set token via DASHBOARD_TOKEN env var @@ -20,7 +21,6 @@ const fastify = Fastify({ logger: true }) async function start() { const PORT = Number(process.env.PORT ?? 9998) const HOST = process.env.HOST ?? '0.0.0.0' - const devOrigin = process.env.FRONTEND_ORIGIN await fastify.register(cors, { methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], origin: (origin, cb) => { @@ -29,7 +29,6 @@ async function start() { const { port } = new URL(origin) if (port === '9999' || port === String(PORT)) return cb(null, true) } catch { /* ignore malformed */ } - if (devOrigin && origin === devOrigin) return cb(null, true) cb(new Error('Not allowed by CORS'), false) }, }) @@ -60,6 +59,8 @@ async function start() { }) } + await initAccountCache() + try { await fastify.listen({ port: PORT, host: HOST }) } catch (err) { diff --git a/backend/src/services/accountCache.ts b/backend/src/services/accountCache.ts new file mode 100644 index 0000000..d394b75 --- /dev/null +++ b/backend/src/services/accountCache.ts @@ -0,0 +1,17 @@ +import { getAccountInfo } from './claudeAccount' +import type { AccountInfo } from './claudeAccount' + +const REFRESH_MS = 60_000 + +let cached: AccountInfo | null = null + +export async function initAccountCache(): Promise { + cached = await getAccountInfo() + setInterval(async () => { + cached = await getAccountInfo() + }, REFRESH_MS).unref() +} + +export function getCachedAccount(): AccountInfo | null { + return cached +} diff --git a/backend/src/services/claudeAccount.ts b/backend/src/services/claudeAccount.ts index 6afbdee..90f9c14 100644 --- a/backend/src/services/claudeAccount.ts +++ b/backend/src/services/claudeAccount.ts @@ -27,7 +27,11 @@ export async function getAccountInfo(): Promise { authStatus: 'unknown', } - const versionResult = await execFileNoThrow(claudeBin(), ['--version']) + const [versionResult, authResult] = await Promise.all([ + execFileNoThrow(claudeBin(), ['--version']), + execFileNoThrow(claudeBin(), ['config', 'get', 'oauthToken']), + ]) + if (versionResult.status !== 0) return base base.claudeInstalled = true @@ -42,7 +46,6 @@ export async function getAccountInfo(): Promise { // settings.json absent or malformed — model stays null } - const authResult = await execFileNoThrow(claudeBin(), ['config', 'get', 'oauthToken']) base.authStatus = authResult.status === 0 && authResult.stdout.trim().length > 0 ? 'authenticated' : 'unauthenticated' diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index 0d098a3..6f919f5 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -38,15 +38,14 @@ export function useDashboard(month?: string): DashboardData & { refresh: () => v setLoading(true) setError(null) + // Fast group: account (cached), sessions, settings — unblocks the UI immediately. Promise.all([ fetch('/api/account').then((r) => r.json() as Promise), - fetch(`/api/usage?month=${target}`).then((r) => r.json() as Promise), fetch('/api/sessions').then((r) => r.json() as Promise), fetch('/api/settings').then((r) => r.json() as Promise>), ]) - .then(([acc, usg, sess, settings]) => { + .then(([acc, sess, settings]) => { setAccount(acc) - setUsage(usg) setSessions(Array.isArray(sess) ? sess : []) setDefaultSessionMode(settings.session_mode === 'terminal' ? 'terminal' : 'chat') setLoading(false) @@ -55,6 +54,12 @@ export function useDashboard(month?: string): DashboardData & { refresh: () => v setError(e.message) setLoading(false) }) + + // Slow group: usage (file I/O + optional network) fills in independently. + fetch(`/api/usage?month=${target}`) + .then((r) => r.json() as Promise) + .then(setUsage) + .catch(() => {}) }, [target]) useEffect(() => { diff --git a/run.sh b/run.sh index 51d4d76..02a392c 100755 --- a/run.sh +++ b/run.sh @@ -3,10 +3,6 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Fedora / RHEL: include system CA bundle so Anthropic API calls succeed -CA_BUNDLE="/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" -[[ -f "$CA_BUNDLE" ]] && export NODE_EXTRA_CA_CERTS="$CA_BUNDLE" - # Bootstrap .env on first run if [[ ! -f "$ROOT_DIR/backend/.env" ]]; then echo "backend/.env not found — copying from .env.example" diff --git a/scripts/test-service-install.sh b/scripts/test-service-install.sh index 2915c23..1deacab 100755 --- a/scripts/test-service-install.sh +++ b/scripts/test-service-install.sh @@ -58,7 +58,6 @@ cat > "$TMPDIR/.env" <<'DOT_ENV' ANTHROPIC_API_KEY=test-key-abc CLAUDE_BIN=/home/user/.local/bin/claude PORT=8888 -FRONTEND_ORIGIN=http://localhost:9999 DOT_ENV read_env_var() { @@ -82,7 +81,6 @@ ENV=$(cat "$TMPDIR/test.env") assert_contains "ANTHROPIC_API_KEY written" "ANTHROPIC_API_KEY=test-key-abc" "$ENV" assert_contains "CLAUDE_BIN written" "CLAUDE_BIN=/home/user/.local/bin/claude" "$ENV" assert_contains "PORT written" "PORT=8888" "$ENV" -assert_not_contains "FRONTEND_ORIGIN excluded" "FRONTEND_ORIGIN" "$ENV" echo "" echo "=== Env file defaults (no .env) ===" From 3a16357fa624dd97a146a1f26b634bb63e23e5e4 Mon Sep 17 00:00:00 2001 From: George Benjamin-Schonberger Date: Wed, 13 May 2026 20:25:03 +0300 Subject: [PATCH 3/6] [fix:global] fixes --- backend/src/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index 4373f10..734de65 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -59,14 +59,14 @@ async function start() { }) } - await initAccountCache() - try { await fastify.listen({ port: PORT, host: HOST }) } catch (err) { fastify.log.error(err) process.exit(1) } + + initAccountCache().catch((err) => fastify.log.error(err)) } start() From 77d9870582c2335bf98988ac54c4be1fad21b1b8 Mon Sep 17 00:00:00 2001 From: George Benjamin-Schonberger Date: Wed, 13 May 2026 23:33:22 +0300 Subject: [PATCH 4/6] [fix:global] terminal history fix --- frontend/src/components/TerminalSession.tsx | 41 +++++++++++++++++---- frontend/src/hooks/useTerminalSession.ts | 10 ++++- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/TerminalSession.tsx b/frontend/src/components/TerminalSession.tsx index 1ded0e8..69cb017 100644 --- a/frontend/src/components/TerminalSession.tsx +++ b/frontend/src/components/TerminalSession.tsx @@ -12,7 +12,16 @@ export default function TerminalSession() { const fitRef = useRef(null) const lastSessionIdRef = useRef(null) + // Output arriving while history is being replayed is buffered here and flushed + // after the replay write completes, preserving correct ordering. + const pendingOutputRef = useRef([]) + const replayingRef = useRef(false) + const onOutput = useCallback((data: string) => { + if (replayingRef.current) { + pendingOutputRef.current.push(data) + return + } termRef.current?.write(data) }, []) @@ -30,12 +39,28 @@ export default function TerminalSession() { }) }, []) - // Replay scrollback from the backend on reconnect (covers page refresh). - // Clears first so history isn't duplicated on top of live content from CSS-toggle sessions. + // Replay scrollback from the backend on reconnect. + // write() is async (goes through xterm's write queue via setTimeout). reset() is + // synchronous — it resets parser state but does NOT flush or clear the pending write + // queue. Any onOutput writes already queued would therefore be processed BEFORE the + // history write, producing partial/garbled display. + // Fix: write('', callback) to drain the queue first, then reset + write history. + // Output that arrives from the new connection during this async wait is buffered and + // flushed after the history write completes. const onHistory = useCallback((data: string) => { - if (!termRef.current) return - termRef.current.clear() - termRef.current.write(data) + const term = termRef.current + if (!term) return + replayingRef.current = true + pendingOutputRef.current = [] + term.write('', () => { + term.reset() + term.write(data, () => { + replayingRef.current = false + const pending = pendingOutputRef.current + pendingOutputRef.current = [] + for (const chunk of pending) term.write(chunk) + }) + }) }, []) const { send } = useTerminalSession(onOutput, onConnect, onHistory) @@ -69,12 +94,12 @@ export default function TerminalSession() { return () => term.dispose() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // Clear xterm when switching to a different session so stale output isn't shown. - // Navigating to the sessions list and back to the SAME session skips the clear. + // Reset xterm when switching to a different session so stale VT state isn't carried over. + // Navigating to the sessions list and back to the SAME session skips the reset. useEffect(() => { if (!state.sessionId) return if (lastSessionIdRef.current !== null && lastSessionIdRef.current !== state.sessionId) { - termRef.current?.clear() + termRef.current?.reset() } lastSessionIdRef.current = state.sessionId }, [state.sessionId]) diff --git a/frontend/src/hooks/useTerminalSession.ts b/frontend/src/hooks/useTerminalSession.ts index cd674e4..ce445f7 100644 --- a/frontend/src/hooks/useTerminalSession.ts +++ b/frontend/src/hooks/useTerminalSession.ts @@ -55,7 +55,7 @@ export function useTerminalSession(onOutput: (data: string) => void, onConnect?: if (attemptsRef.current < MAX_RECONNECT_ATTEMPTS) { const delay = BASE_DELAY_MS * 2 ** attemptsRef.current attemptsRef.current++ - setTimeout(connect, delay) + setTimeout(() => { if (!closed) connect() }, delay) } else { dispatch({ type: 'WS_STATE', timestamp: Date.now(), state: 'disconnected' }) setTimeout(() => { @@ -69,8 +69,14 @@ export function useTerminalSession(onOutput: (data: string) => void, onConnect?: return () => { closed = true attemptsRef.current = 0 - wsRef.current?.close() + const ws = wsRef.current wsRef.current = null + if (ws) { + ws.onmessage = null + ws.onerror = null + ws.onclose = null + ws.close() + } } }, [state.sessionId, state.mode]) // eslint-disable-line react-hooks/exhaustive-deps From 6b8deda958f9e48cfa2f382566d321bd4379e1e1 Mon Sep 17 00:00:00 2001 From: George Benjamin-Schonberger Date: Wed, 13 May 2026 23:35:39 +0300 Subject: [PATCH 5/6] [fix:backend] fix windows support (#4) --- backend/src/utils/resolveBin.test.ts | 70 ++++++++++++++++++++++++++++ backend/src/utils/resolveBin.ts | 39 ++++++++++++++++ backend/src/ws/session.ts | 3 +- backend/src/ws/terminal.ts | 3 +- 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 backend/src/utils/resolveBin.test.ts create mode 100644 backend/src/utils/resolveBin.ts diff --git a/backend/src/utils/resolveBin.test.ts b/backend/src/utils/resolveBin.test.ts new file mode 100644 index 0000000..57bcf05 --- /dev/null +++ b/backend/src/utils/resolveBin.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +const existsSyncMock = vi.hoisted(() => vi.fn<(p: string) => boolean>()) + +vi.mock('fs', async (importOriginal) => { + const original = await importOriginal() + return { ...original, existsSync: existsSyncMock, default: { ...original, existsSync: existsSyncMock } } +}) + +const { resolveBin } = await import('./resolveBin') + +const originalPlatform = process.platform +const originalPath = process.env.PATH +const originalPathExt = process.env.PATHEXT + +function setPlatform(p: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: p, configurable: true }) +} + +describe('resolveBin', () => { + beforeEach(() => { + existsSyncMock.mockReset() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + process.env.PATH = originalPath + process.env.PATHEXT = originalPathExt + }) + + it('returns input unchanged on POSIX', () => { + setPlatform('linux') + expect(resolveBin('claude')).toBe('claude') + expect(resolveBin('/usr/local/bin/claude')).toBe('/usr/local/bin/claude') + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('on Windows, resolves a bare name to \\.CMD when only .cmd exists', () => { + setPlatform('win32') + process.env.PATH = 'C:\\bin;C:\\other' + process.env.PATHEXT = '.COM;.EXE;.CMD' + existsSyncMock.mockImplementation((p) => p === 'C:\\bin\\claude.CMD') + + expect(resolveBin('claude')).toBe('C:\\bin\\claude.CMD') + }) + + it('on Windows, falls back to input when nothing matches on PATH', () => { + setPlatform('win32') + process.env.PATH = 'C:\\bin' + process.env.PATHEXT = '.EXE;.CMD' + existsSyncMock.mockReturnValue(false) + + expect(resolveBin('claude')).toBe('claude') + }) + + it('on Windows, an absolute path with an existing extension is returned as-is', () => { + setPlatform('win32') + existsSyncMock.mockImplementation((p) => p === 'C:\\tools\\claude.cmd') + + expect(resolveBin('C:\\tools\\claude.cmd')).toBe('C:\\tools\\claude.cmd') + }) + + it('on Windows, an absolute path without extension gets PATHEXT appended', () => { + setPlatform('win32') + process.env.PATHEXT = '.EXE;.CMD' + existsSyncMock.mockImplementation((p) => p === 'C:\\tools\\claude.CMD') + + expect(resolveBin('C:\\tools\\claude')).toBe('C:\\tools\\claude.CMD') + }) +}) diff --git a/backend/src/utils/resolveBin.ts b/backend/src/utils/resolveBin.ts new file mode 100644 index 0000000..249ef16 --- /dev/null +++ b/backend/src/utils/resolveBin.ts @@ -0,0 +1,39 @@ +import * as fs from 'fs' +import * as path from 'path' + +// node-pty on Windows uses CreateProcessW, which does not honor PATHEXT. +// Spawning a bare name like "claude" fails with "File not found: claude" +// even though `claude.cmd` is on PATH. Resolve PATH + PATHEXT explicitly here. +// On POSIX this is a no-op — node-pty's own PATH search works fine there. +export function resolveBin(bin: string): string { + if (process.platform !== 'win32') return bin + + const exts = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') + .split(';') + .map((e) => e.trim()) + .filter(Boolean) + + if (path.isAbsolute(bin)) { + if (path.extname(bin) && fs.existsSync(bin)) return bin + for (const ext of exts) { + const candidate = bin + ext + if (fs.existsSync(candidate)) return candidate + } + return bin + } + + const dirs = (process.env.PATH ?? '').split(';').filter(Boolean) + const hasExt = !!path.extname(bin) + for (const dir of dirs) { + if (hasExt) { + const candidate = path.join(dir, bin) + if (fs.existsSync(candidate)) return candidate + } else { + for (const ext of exts) { + const candidate = path.join(dir, bin + ext) + if (fs.existsSync(candidate)) return candidate + } + } + } + return bin +} diff --git a/backend/src/ws/session.ts b/backend/src/ws/session.ts index 159026c..574e917 100644 --- a/backend/src/ws/session.ts +++ b/backend/src/ws/session.ts @@ -4,6 +4,7 @@ import * as path from 'path' import type { WebSocket } from 'ws' import type { FastifyInstance } from 'fastify' import { db } from '../db/schema' +import { resolveBin } from '../utils/resolveBin' const IDLE_TIMEOUT_MS = 30 * 60 * 1000 @@ -128,7 +129,7 @@ class ActiveSession { ).run(this.id, 'user', text, Date.now()) } - const claudeBin = process.env.CLAUDE_BIN ?? 'claude' + const claudeBin = resolveBin(process.env.CLAUDE_BIN?.trim() || 'claude') const bypassPermissions = getBypassPermissions() // --print + -p: non-interactive print mode with the prompt passed as a CLI diff --git a/backend/src/ws/terminal.ts b/backend/src/ws/terminal.ts index d118468..a68139a 100644 --- a/backend/src/ws/terminal.ts +++ b/backend/src/ws/terminal.ts @@ -4,6 +4,7 @@ import * as path from 'path' import type { WebSocket } from 'ws' import type { FastifyInstance } from 'fastify' import { db } from '../db/schema' +import { resolveBin } from '../utils/resolveBin' const IDLE_TIMEOUT_MS = 30 * 60 * 1000 @@ -52,7 +53,7 @@ class ActiveTerminalSession { } private spawnPty(cols: number, rows: number) { - const claudeBin = process.env.CLAUDE_BIN ?? 'claude' + const claudeBin = resolveBin(process.env.CLAUDE_BIN?.trim() || 'claude') const bypassPermissions = getBypassPermissions() const args: string[] = [] if (bypassPermissions) args.push('--dangerously-skip-permissions') From 35a54ffc11de9be75577e8eb54b43b75a8390e37 Mon Sep 17 00:00:00 2001 From: George Benjamin-Schonberger Date: Thu, 14 May 2026 00:23:00 +0300 Subject: [PATCH 6/6] [fix:global] more fixes --- backend/src/routes/sessions.ts | 17 +++++ backend/src/utils/resolveBin.ts | 10 +-- frontend/src/App.tsx | 9 ++- frontend/src/components/SessionHeader.tsx | 8 ++- frontend/src/components/SessionList.tsx | 24 +++---- frontend/src/views/DashboardView.tsx | 20 +++--- frontend/src/views/SessionRoute.tsx | 79 +++++++++++++++++++++++ 7 files changed, 134 insertions(+), 33 deletions(-) create mode 100644 frontend/src/views/SessionRoute.tsx diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index b43e57d..bfddf35 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -27,6 +27,23 @@ export async function sessionRoutes(fastify: FastifyInstance) { } }) + fastify.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { + const { id } = req.params + const row = db + .prepare( + `SELECT s.*, + CASE WHEN s.ended_at IS NULL THEN 1 ELSE 0 END as is_active, + COUNT(m.id) as message_count + FROM sessions s + LEFT JOIN messages m ON m.session_id = s.id + WHERE s.id = ? + GROUP BY s.id`, + ) + .get(id) + if (!row) return reply.status(404).send({ error: 'Session not found' }) + return reply.send(row) + }) + fastify.get('/api/sessions', async (_req, reply) => { const rows = db .prepare( diff --git a/backend/src/utils/resolveBin.ts b/backend/src/utils/resolveBin.ts index 249ef16..c61b6bf 100644 --- a/backend/src/utils/resolveBin.ts +++ b/backend/src/utils/resolveBin.ts @@ -13,8 +13,8 @@ export function resolveBin(bin: string): string { .map((e) => e.trim()) .filter(Boolean) - if (path.isAbsolute(bin)) { - if (path.extname(bin) && fs.existsSync(bin)) return bin + if (path.win32.isAbsolute(bin)) { + if (path.win32.extname(bin) && fs.existsSync(bin)) return bin for (const ext of exts) { const candidate = bin + ext if (fs.existsSync(candidate)) return candidate @@ -23,14 +23,14 @@ export function resolveBin(bin: string): string { } const dirs = (process.env.PATH ?? '').split(';').filter(Boolean) - const hasExt = !!path.extname(bin) + const hasExt = !!path.win32.extname(bin) for (const dir of dirs) { if (hasExt) { - const candidate = path.join(dir, bin) + const candidate = path.win32.join(dir, bin) if (fs.existsSync(candidate)) return candidate } else { for (const ext of exts) { - const candidate = path.join(dir, bin + ext) + const candidate = path.win32.join(dir, bin + ext) if (fs.existsSync(candidate)) return candidate } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 262f6c3..d544e3d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,19 @@ -import { HashRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import DashboardView from './views/DashboardView' +import SessionRoute from './views/SessionRoute' import SettingsView from './views/SettingsView' import { SessionProvider } from './context/SessionContext' export default function App() { return ( - +
} /> + } /> + } /> } /> } /> } /> @@ -19,6 +22,6 @@ export default function App() {
-
+ ) } diff --git a/frontend/src/components/SessionHeader.tsx b/frontend/src/components/SessionHeader.tsx index c03ad1e..6737a1d 100644 --- a/frontend/src/components/SessionHeader.tsx +++ b/frontend/src/components/SessionHeader.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Plus, List, Square, Pencil } from 'lucide-react' +import { Link } from 'react-router-dom' import { useSession } from '../context/SessionContext' import { formatModelName, formatTokens, formatDuration } from '../utils/format' @@ -164,13 +165,14 @@ export default function SessionHeader({
- + +
{/* Session rows */} @@ -51,13 +51,14 @@ export default function SessionList({ {sessions.length === 0 ? (

No sessions yet

- +
) : ( sessions.map((session) => ( @@ -92,13 +93,14 @@ export default function SessionList({ {/* Right: actions */}
- +