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 <clawbot@clawbots-Mac-mini.local>
This commit is contained in:
John-tip
2026-05-07 18:11:03 -06:00
committed by GitHub
parent 19eadb66c8
commit 274e9a2e5c
8 changed files with 169 additions and 35 deletions

View File

@@ -114,6 +114,27 @@ async function tryServeStatic(req, res) {
// Prevent directory traversal // Prevent directory traversal
if (pathname.includes('..')) return false 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) const filePath = join(CLIENT_DIR, pathname)
// Make sure the resolved path is within CLIENT_DIR // Make sure the resolved path is within CLIENT_DIR

View File

@@ -213,19 +213,24 @@ function readSessionName(session: GatewaySession): string {
function isAgentSession(session: GatewaySession): boolean { function isAgentSession(session: GatewaySession): boolean {
const key = readSessionKey(session).toLowerCase() const key = readSessionKey(session).toLowerCase()
// Always exclude main sessions
if (key === 'main' || key.includes(':main')) return false
const friendlyId = readString(session.friendlyId).toLowerCase() 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() 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) // The right-side agent panel must only show actual delegated/worker runs.
return true // 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 { function readTaskText(session: GatewaySession): string {

View File

@@ -12,7 +12,23 @@ export const Route = createFileRoute('/api/context-usage')({
} }
const url = new URL(request.url) 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) const snapshot = await readContextUsage(sessionId)
return json(snapshot) return json(snapshot)
}, },

View File

@@ -8,6 +8,7 @@ import {
listSessions, listSessions,
} from '../../server/claude-api' } from '../../server/claude-api'
import { isSyntheticSessionKey } from '../../server/session-utils' import { isSyntheticSessionKey } from '../../server/session-utils'
import { getLocalSession } from '../../server/local-session-store'
import { isAuthenticated } from '@/server/auth-middleware' import { isAuthenticated } from '@/server/auth-middleware'
import { readContextUsage } from '@/server/context-usage' import { readContextUsage } from '@/server/context-usage'
@@ -53,30 +54,54 @@ export const Route = createFileRoute('/api/session-status')({
inputTokens: 0, inputTokens: 0,
outputTokens: 0, outputTokens: 0,
totalTokens: 0, totalTokens: 0,
contextPercent: 0,
maxTokens: 0,
usedTokens: 0,
sessions: [], sessions: [],
}, },
}) })
} }
if (isSyntheticSessionKey(sessionKey)) { if (isSyntheticSessionKey(sessionKey)) {
const sessions = await listSessions(1, 0)
if (sessions.length === 0) {
return json({ return json({
ok: true, ok: true,
payload: { payload: {
status: 'idle', status: 'idle',
sessionKey: 'new', sessionKey,
sessionLabel: '', sessionLabel: '',
model: '', model: '',
modelProvider: '', modelProvider: '',
inputTokens: 0, inputTokens: 0,
outputTokens: 0, outputTokens: 0,
totalTokens: 0, totalTokens: 0,
contextPercent: 0,
maxTokens: 0,
usedTokens: 0,
sessions: [], sessions: [],
}, },
}) })
} }
sessionKey = sessions[0].id
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) const session = await getSession(sessionKey)

View File

@@ -14,7 +14,12 @@ import {
updateSession, updateSession,
} from '../../server/claude-api' } from '../../server/claude-api'
import { createCapabilityUnavailablePayload } from '@/lib/feature-gates' 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')({ export const Route = createFileRoute('/api/sessions')({
server: { server: {
@@ -46,7 +51,10 @@ export const Route = createFileRoute('/api/sessions')({
gatewaySessions.push({ gatewaySessions.push({
key: ls.id, key: ls.id,
id: ls.id, id: ls.id,
friendlyId: ls.id,
title: ls.title || 'Local Chat', title: ls.title || 'Local Chat',
label: ls.title || 'Local Chat',
derivedTitle: ls.title || 'Local Chat',
startedAt: ls.createdAt, startedAt: ls.createdAt,
updatedAt: ls.updatedAt, updatedAt: ls.updatedAt,
message_count: ls.messageCount, 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) { if (capabilities.dashboard.available && !capabilities.enhancedChat) {
return json({ return json({
ok: true, ok: true,

View File

@@ -821,7 +821,7 @@ function ChatSidebarComponent({
kind: 'link', kind: 'link',
to: '/tasks', to: '/tasks',
icon: CheckListIcon, icon: CheckListIcon,
label: t('nav.tasks'), label: 'Kanban',
active: isTasksActive, active: isTasksActive,
}, },
{ {

View File

@@ -83,6 +83,9 @@ function getSessionDisplayTitle(
const derivedTitle = normalizeTitleValue(session.derivedTitle) const derivedTitle = normalizeTitleValue(session.derivedTitle)
if (derivedTitle) return derivedTitle if (derivedTitle) return derivedTitle
const title = normalizeTitleValue(session.title)
if (title) return title
if (isGenerating) return 'Naming…' if (isGenerating) return 'Naming…'
const shortId = getSessionShortId(session) const shortId = getSessionShortId(session)
return shortId ? `Session ${shortId}` : 'Session' return shortId ? `Session ${shortId}` : 'Session'

View File

@@ -5,6 +5,7 @@ import {
ensureGatewayProbed, ensureGatewayProbed,
getCapabilities, getCapabilities,
} from '@/server/gateway-capabilities' } from '@/server/gateway-capabilities'
import { getLocalMessages, getLocalSession } from '@/server/local-session-store'
export type ContextUsageSnapshot = { export type ContextUsageSnapshot = {
ok: true ok: true
@@ -74,16 +75,42 @@ export async function readContextUsage(
): Promise<ContextUsageSnapshot> { ): Promise<ContextUsageSnapshot> {
try { try {
let sessionData: Record<string, unknown> | null = null let sessionData: Record<string, unknown> | null = null
const explicitSessionId = sessionId.trim()
const capabilities = await ensureGatewayProbed() 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 { try {
const res = capabilities.dashboard.available const res = capabilities.dashboard.available
? await dashboardFetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { ? await dashboardFetch(`/api/sessions/${encodeURIComponent(explicitSessionId)}`, {
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
}) })
: await fetch( : await fetch(
`${CLAUDE_API}/api/sessions/${encodeURIComponent(sessionId)}`, `${CLAUDE_API}/api/sessions/${encodeURIComponent(explicitSessionId)}`,
{ {
headers: authHeaders(), headers: authHeaders(),
signal: AbortSignal.timeout(3000), 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) { if (!sessionData) {
try { try {
const listRes = capabilities.dashboard.available const listRes = capabilities.dashboard.available
@@ -141,7 +173,7 @@ export async function readContextUsage(
usedTokens = Math.ceil((cacheReadTokens / assistantTurns) * 1.2) usedTokens = Math.ceil((cacheReadTokens / assistantTurns) * 1.2)
} else if (messageCount > 0) { } else if (messageCount > 0) {
try { try {
const targetSessionId = sessionId || String(sessionData.id || '') const targetSessionId = explicitSessionId || String(sessionData.id || '')
if (targetSessionId) { if (targetSessionId) {
const capabilitiesNow = getCapabilities() const capabilitiesNow = getCapabilities()
const msgRes = capabilitiesNow.dashboard.available const msgRes = capabilitiesNow.dashboard.available