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
|
// 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user