From 274e9a2e5c7ccdee6c0b4ef770dd0079a2ec1b59 Mon Sep 17 00:00:00 2001 From: John-tip <125675940+John-tip@users.noreply.github.com> Date: Thu, 7 May 2026 18:11:03 -0600 Subject: [PATCH] fix(chat): correct local session accounting and titles (#350) * Hide normal chat sessions from agent sidebar * Fix context meter for portable chat sessions * Persist local session renames * Show local session titles in sidebar * Harden Workspace asset serving and expose Kanban nav --------- Co-authored-by: clawbot --- server-entry.js | 21 +++++++ src/hooks/use-agent-view.ts | 25 +++++--- src/routes/api/context-usage.ts | 18 +++++- src/routes/api/session-status.ts | 61 +++++++++++++------ src/routes/api/sessions.ts | 34 ++++++++++- src/screens/chat/components/chat-sidebar.tsx | 2 +- .../chat/components/sidebar/session-item.tsx | 3 + src/server/context-usage.ts | 40 ++++++++++-- 8 files changed, 169 insertions(+), 35 deletions(-) diff --git a/server-entry.js b/server-entry.js index 94e98625..95a21f9e 100644 --- a/server-entry.js +++ b/server-entry.js @@ -114,6 +114,27 @@ async function tryServeStatic(req, res) { // Prevent directory traversal if (pathname.includes('..')) return false + // Asset requests should never fall through to the SSR handler. If a browser + // asks for a stale hashed JS/CSS chunk after a deploy or branch switch, + // returning the HTML shell with 200 text/html makes the SPA fail as a black + // screen. Return a real 404 instead so clients reload/recover correctly and + // health checks can detect the broken asset reference. + if (pathname.startsWith('/assets/')) { + const filePath = join(CLIENT_DIR, pathname) + if (!filePath.startsWith(CLIENT_DIR)) return false + try { + const fileStat = await stat(filePath) + if (!fileStat.isFile()) throw new Error('not a file') + } catch { + res.writeHead(404, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + }) + res.end('Asset not found') + return true + } + } + const filePath = join(CLIENT_DIR, pathname) // Make sure the resolved path is within CLIENT_DIR diff --git a/src/hooks/use-agent-view.ts b/src/hooks/use-agent-view.ts index 256fc5ce..7ea3504a 100644 --- a/src/hooks/use-agent-view.ts +++ b/src/hooks/use-agent-view.ts @@ -213,19 +213,24 @@ function readSessionName(session: GatewaySession): string { function isAgentSession(session: GatewaySession): boolean { const key = readSessionKey(session).toLowerCase() - - // Always exclude main sessions - if (key === 'main' || key.includes(':main')) return false const friendlyId = readString(session.friendlyId).toLowerCase() - if (friendlyId === 'main') return false - - // Always exclude cron jobs - if (key.includes('cron')) return false const kind = readString(session.kind).toLowerCase() - if (kind === 'cron') return false + const label = readString(session.label).toLowerCase() + const title = readString(session.title).toLowerCase() - // Everything else is an agent session — inclusive by default (#37) - return true + // The right-side agent panel must only show actual delegated/worker runs. + // Normal Workspace chat sessions are also returned by /api/sessions with + // kind="chat" and transient api-* keys; treating them as agents creates + // phantom personas like "Nova — Security Specialist" during normal sends. + if (key === 'main' || key.includes(':main')) return false + if (friendlyId === 'main') return false + if (kind === 'cron' || key.includes('cron')) return false + // Normal chat sessions can have agent-like generated titles and can be + // transiently active while streaming, but they are not delegated workers. + // Never show them in the agent sidebar. + if (kind === 'chat') return false + + return ['agent', 'worker', 'delegate', 'swarm', 'subagent'].includes(kind) } function readTaskText(session: GatewaySession): string { diff --git a/src/routes/api/context-usage.ts b/src/routes/api/context-usage.ts index 658ca84f..466ee107 100644 --- a/src/routes/api/context-usage.ts +++ b/src/routes/api/context-usage.ts @@ -12,7 +12,23 @@ export const Route = createFileRoute('/api/context-usage')({ } const url = new URL(request.url) - const sessionId = url.searchParams.get('sessionId')?.trim() || '' + const sessionId = + url.searchParams.get('sessionId')?.trim() || + url.searchParams.get('sessionKey')?.trim() || + '' + + if (sessionId === 'new' || sessionId === 'main') { + return json({ + ok: true, + contextPercent: 0, + maxTokens: 0, + usedTokens: 0, + model: '', + staticTokens: 0, + conversationTokens: 0, + }) + } + const snapshot = await readContextUsage(sessionId) return json(snapshot) }, diff --git a/src/routes/api/session-status.ts b/src/routes/api/session-status.ts index 161de9e2..7308d058 100644 --- a/src/routes/api/session-status.ts +++ b/src/routes/api/session-status.ts @@ -8,6 +8,7 @@ import { listSessions, } from '../../server/claude-api' import { isSyntheticSessionKey } from '../../server/session-utils' +import { getLocalSession } from '../../server/local-session-store' import { isAuthenticated } from '@/server/auth-middleware' import { readContextUsage } from '@/server/context-usage' @@ -53,30 +54,54 @@ export const Route = createFileRoute('/api/session-status')({ inputTokens: 0, outputTokens: 0, totalTokens: 0, + contextPercent: 0, + maxTokens: 0, + usedTokens: 0, sessions: [], }, }) } if (isSyntheticSessionKey(sessionKey)) { - const sessions = await listSessions(1, 0) - if (sessions.length === 0) { - return json({ - ok: true, - payload: { - status: 'idle', - sessionKey: 'new', - sessionLabel: '', - model: '', - modelProvider: '', - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - sessions: [], - }, - }) - } - sessionKey = sessions[0].id + return json({ + ok: true, + payload: { + status: 'idle', + sessionKey, + sessionLabel: '', + model: '', + modelProvider: '', + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + contextPercent: 0, + maxTokens: 0, + usedTokens: 0, + sessions: [], + }, + }) + } + + const localSession = getLocalSession(sessionKey) + if (localSession) { + const contextUsage = await readContextUsage(sessionKey) + return json({ + ok: true, + payload: { + status: 'idle', + sessionKey, + sessionLabel: localSession.title ?? '', + model: localSession.model ?? contextUsage.model, + modelProvider: 'local', + inputTokens: contextUsage.usedTokens, + outputTokens: 0, + totalTokens: contextUsage.usedTokens, + contextPercent: contextUsage.contextPercent, + maxTokens: contextUsage.maxTokens, + usedTokens: contextUsage.usedTokens, + sessions: [], + }, + }) } const session = await getSession(sessionKey) diff --git a/src/routes/api/sessions.ts b/src/routes/api/sessions.ts index 32c2e8a6..b0ee0e8e 100644 --- a/src/routes/api/sessions.ts +++ b/src/routes/api/sessions.ts @@ -14,7 +14,12 @@ import { updateSession, } from '../../server/claude-api' import { createCapabilityUnavailablePayload } from '@/lib/feature-gates' -import { deleteLocalSession, getLocalSession, listLocalSessions } from '../../server/local-session-store' +import { + deleteLocalSession, + getLocalSession, + listLocalSessions, + updateLocalSessionTitle, +} from '../../server/local-session-store' export const Route = createFileRoute('/api/sessions')({ server: { @@ -46,7 +51,10 @@ export const Route = createFileRoute('/api/sessions')({ gatewaySessions.push({ key: ls.id, id: ls.id, + friendlyId: ls.id, title: ls.title || 'Local Chat', + label: ls.title || 'Local Chat', + derivedTitle: ls.title || 'Local Chat', startedAt: ls.createdAt, updatedAt: ls.updatedAt, message_count: ls.messageCount, @@ -193,6 +201,30 @@ export const Route = createFileRoute('/api/sessions')({ ) } + const localSession = getLocalSession(sessionKey) + if (localSession) { + if (label) updateLocalSessionTitle(sessionKey, label) + return json({ + ok: true, + sessionKey, + friendlyId: rawFriendlyId || sessionKey, + entry: { + key: sessionKey, + id: sessionKey, + title: label || sessionKey, + label: label || sessionKey, + derivedTitle: label || sessionKey, + startedAt: localSession.createdAt, + updatedAt: Date.now(), + message_count: localSession.messageCount, + model: localSession.model, + source: 'local', + }, + updated: true, + source: 'local', + }) + } + if (capabilities.dashboard.available && !capabilities.enhancedChat) { return json({ ok: true, diff --git a/src/screens/chat/components/chat-sidebar.tsx b/src/screens/chat/components/chat-sidebar.tsx index 9c8c1915..69424ab2 100644 --- a/src/screens/chat/components/chat-sidebar.tsx +++ b/src/screens/chat/components/chat-sidebar.tsx @@ -821,7 +821,7 @@ function ChatSidebarComponent({ kind: 'link', to: '/tasks', icon: CheckListIcon, - label: t('nav.tasks'), + label: 'Kanban', active: isTasksActive, }, { diff --git a/src/screens/chat/components/sidebar/session-item.tsx b/src/screens/chat/components/sidebar/session-item.tsx index a18eb7b5..3ad107e2 100644 --- a/src/screens/chat/components/sidebar/session-item.tsx +++ b/src/screens/chat/components/sidebar/session-item.tsx @@ -83,6 +83,9 @@ function getSessionDisplayTitle( const derivedTitle = normalizeTitleValue(session.derivedTitle) if (derivedTitle) return derivedTitle + const title = normalizeTitleValue(session.title) + if (title) return title + if (isGenerating) return 'Naming…' const shortId = getSessionShortId(session) return shortId ? `Session ${shortId}` : 'Session' diff --git a/src/server/context-usage.ts b/src/server/context-usage.ts index 27908647..110d00f7 100644 --- a/src/server/context-usage.ts +++ b/src/server/context-usage.ts @@ -5,6 +5,7 @@ import { ensureGatewayProbed, getCapabilities, } from '@/server/gateway-capabilities' +import { getLocalMessages, getLocalSession } from '@/server/local-session-store' export type ContextUsageSnapshot = { ok: true @@ -74,16 +75,42 @@ export async function readContextUsage( ): Promise { try { let sessionData: Record | null = null + const explicitSessionId = sessionId.trim() const capabilities = await ensureGatewayProbed() - if (sessionId) { + if (explicitSessionId) { + const localSession = getLocalSession(explicitSessionId) + if (localSession) { + const messages = getLocalMessages(explicitSessionId) + const totalChars = messages.reduce( + (sum, msg) => sum + (msg.content || '').length, + 0, + ) + const usedTokens = Math.ceil(totalChars / CHARS_PER_TOKEN) + const model = localSession.model || 'gpt-5.5' + const maxTokens = getContextWindow(model) + const contextPercent = + maxTokens > 0 ? Math.round((usedTokens / maxTokens) * 1000) / 10 : 0 + return { + ok: true, + contextPercent, + maxTokens, + usedTokens, + model, + staticTokens: 0, + conversationTokens: usedTokens, + } + } + } + + if (explicitSessionId) { try { const res = capabilities.dashboard.available - ? await dashboardFetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + ? await dashboardFetch(`/api/sessions/${encodeURIComponent(explicitSessionId)}`, { signal: AbortSignal.timeout(3000), }) : await fetch( - `${CLAUDE_API}/api/sessions/${encodeURIComponent(sessionId)}`, + `${CLAUDE_API}/api/sessions/${encodeURIComponent(explicitSessionId)}`, { headers: authHeaders(), signal: AbortSignal.timeout(3000), @@ -100,6 +127,11 @@ export async function readContextUsage( } } + // If the caller asked for a specific session and neither the local store nor + // the gateway has it, return empty. Falling back to the latest session makes + // new/portable chats inherit unrelated large context usage in the UI. + if (explicitSessionId && !sessionData) return emptySnapshot() + if (!sessionData) { try { const listRes = capabilities.dashboard.available @@ -141,7 +173,7 @@ export async function readContextUsage( usedTokens = Math.ceil((cacheReadTokens / assistantTurns) * 1.2) } else if (messageCount > 0) { try { - const targetSessionId = sessionId || String(sessionData.id || '') + const targetSessionId = explicitSessionId || String(sessionData.id || '') if (targetSessionId) { const capabilitiesNow = getCapabilities() const msgRes = capabilitiesNow.dashboard.available