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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
sessionKey,
|
||||
sessionLabel: '',
|
||||
model: '',
|
||||
modelProvider: '',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextPercent: 0,
|
||||
maxTokens: 0,
|
||||
usedTokens: 0,
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -821,7 +821,7 @@ function ChatSidebarComponent({
|
||||
kind: 'link',
|
||||
to: '/tasks',
|
||||
icon: CheckListIcon,
|
||||
label: t('nav.tasks'),
|
||||
label: 'Kanban',
|
||||
active: isTasksActive,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<ContextUsageSnapshot> {
|
||||
try {
|
||||
let sessionData: Record<string, unknown> | 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
|
||||
|
||||
Reference in New Issue
Block a user