Spaces:
Runtime error
Runtime error
nyk commited on
feat: hybrid mode — show gateway + local sessions simultaneously (#392)
Browse filesAlways merge both gateway and local (Claude Code, Codex, Hermes) sessions
in the sessions API instead of gating on `include_local`. The conversation
list now renders all sources unconditionally, using each session's `source`
field for display logic rather than the global `dashboardMode`.
- Add `localSessionsAvailable` store flag, set from capabilities `claudeHome`
- Remove `include_local` query param and early-return in sessions API
- Remove source-based filter and `dashboardMode` branching in conversation list
- Show both gateway and local active/recent groups when data exists
- src/app/[[...panel]]/page.tsx +5 -2
- src/app/api/sessions/route.ts +3 -11
- src/components/chat/conversation-list.tsx +18 -28
- src/store/index.ts +4 -0
src/app/[[...panel]]/page.tsx
CHANGED
|
@@ -87,7 +87,7 @@ export default function Home() {
|
|
| 87 |
const tb = useTranslations('boot')
|
| 88 |
const tp = useTranslations('page')
|
| 89 |
const tc = useTranslations('common')
|
| 90 |
-
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setCapabilitiesChecked, setSubscription, setDefaultOrgName, setUpdateAvailable, setOpenclawUpdate, showOnboarding, setShowOnboarding, liveFeedOpen, toggleLiveFeed, showProjectManagerModal, setShowProjectManagerModal, fetchProjects, setChatPanelOpen, bootComplete, setBootComplete, setAgents, setSessions, setProjects, setInterfaceMode, setMemoryGraphAgents, setSkillsData } = useMissionControl()
|
| 91 |
|
| 92 |
// Sync URL → Zustand activeTab
|
| 93 |
const pathname = usePathname()
|
|
@@ -286,6 +286,9 @@ export default function Home() {
|
|
| 286 |
setDashboardMode('full')
|
| 287 |
setGatewayAvailable(true)
|
| 288 |
}
|
|
|
|
|
|
|
|
|
|
| 289 |
setCapabilitiesChecked(true)
|
| 290 |
markStep('capabilities')
|
| 291 |
|
|
@@ -362,7 +365,7 @@ export default function Home() {
|
|
| 362 |
]).catch(() => { /* panels will lazy-load as fallback */ })
|
| 363 |
|
| 364 |
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot once on mount, not on every pathname change
|
| 365 |
-
}, [connect, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setCapabilitiesChecked, setSubscription, setUpdateAvailable, setShowOnboarding, setAgents, setSessions, setProjects, setInterfaceMode, setMemoryGraphAgents, setSkillsData])
|
| 366 |
|
| 367 |
if (!isClient || !bootComplete) {
|
| 368 |
return <Loader variant="page" steps={isClient ? initSteps : undefined} />
|
|
|
|
| 87 |
const tb = useTranslations('boot')
|
| 88 |
const tp = useTranslations('page')
|
| 89 |
const tc = useTranslations('common')
|
| 90 |
+
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setLocalSessionsAvailable, setCapabilitiesChecked, setSubscription, setDefaultOrgName, setUpdateAvailable, setOpenclawUpdate, showOnboarding, setShowOnboarding, liveFeedOpen, toggleLiveFeed, showProjectManagerModal, setShowProjectManagerModal, fetchProjects, setChatPanelOpen, bootComplete, setBootComplete, setAgents, setSessions, setProjects, setInterfaceMode, setMemoryGraphAgents, setSkillsData } = useMissionControl()
|
| 91 |
|
| 92 |
// Sync URL → Zustand activeTab
|
| 93 |
const pathname = usePathname()
|
|
|
|
| 286 |
setDashboardMode('full')
|
| 287 |
setGatewayAvailable(true)
|
| 288 |
}
|
| 289 |
+
if (data?.claudeHome) {
|
| 290 |
+
setLocalSessionsAvailable(true)
|
| 291 |
+
}
|
| 292 |
setCapabilitiesChecked(true)
|
| 293 |
markStep('capabilities')
|
| 294 |
|
|
|
|
| 365 |
]).catch(() => { /* panels will lazy-load as fallback */ })
|
| 366 |
|
| 367 |
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot once on mount, not on every pathname change
|
| 368 |
+
}, [connect, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setLocalSessionsAvailable, setCapabilitiesChecked, setSubscription, setUpdateAvailable, setShowOnboarding, setAgents, setSessions, setProjects, setInterfaceMode, setMemoryGraphAgents, setSkillsData])
|
| 369 |
|
| 370 |
if (!isClient || !bootComplete) {
|
| 371 |
return <Loader variant="page" steps={isClient ? initSteps : undefined} />
|
src/app/api/sessions/route.ts
CHANGED
|
@@ -16,26 +16,18 @@ export async function GET(request: NextRequest) {
|
|
| 16 |
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 17 |
|
| 18 |
try {
|
| 19 |
-
const { searchParams } = new URL(request.url)
|
| 20 |
-
const includeLocal = searchParams.get('include_local') === '1'
|
| 21 |
const gatewaySessions = getAllGatewaySessions()
|
| 22 |
const mappedGatewaySessions = mapGatewaySessions(gatewaySessions)
|
| 23 |
|
| 24 |
-
//
|
| 25 |
-
// return only gateway-backed sessions unless include_local=1 is requested.
|
| 26 |
-
if (mappedGatewaySessions.length > 0 && !includeLocal) {
|
| 27 |
-
return NextResponse.json({ sessions: mappedGatewaySessions })
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
// Local Claude + Codex sessions from disk/SQLite
|
| 31 |
await syncClaudeSessions()
|
| 32 |
const claudeSessions = getLocalClaudeSessions()
|
| 33 |
const codexSessions = getLocalCodexSessions()
|
| 34 |
const hermesSessions = getLocalHermesSessions()
|
| 35 |
const localMerged = mergeLocalSessions(claudeSessions, codexSessions, hermesSessions)
|
| 36 |
|
| 37 |
-
if (mappedGatewaySessions.length === 0) {
|
| 38 |
-
return NextResponse.json({ sessions:
|
| 39 |
}
|
| 40 |
|
| 41 |
const merged = dedupeAndSortSessions([...mappedGatewaySessions, ...localMerged])
|
|
|
|
| 16 |
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 17 |
|
| 18 |
try {
|
|
|
|
|
|
|
| 19 |
const gatewaySessions = getAllGatewaySessions()
|
| 20 |
const mappedGatewaySessions = mapGatewaySessions(gatewaySessions)
|
| 21 |
|
| 22 |
+
// Always include local sessions alongside gateway sessions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
await syncClaudeSessions()
|
| 24 |
const claudeSessions = getLocalClaudeSessions()
|
| 25 |
const codexSessions = getLocalCodexSessions()
|
| 26 |
const hermesSessions = getLocalHermesSessions()
|
| 27 |
const localMerged = mergeLocalSessions(claudeSessions, codexSessions, hermesSessions)
|
| 28 |
|
| 29 |
+
if (mappedGatewaySessions.length === 0 && localMerged.length === 0) {
|
| 30 |
+
return NextResponse.json({ sessions: [] })
|
| 31 |
}
|
| 32 |
|
| 33 |
const merged = dedupeAndSortSessions([...mappedGatewaySessions, ...localMerged])
|
src/components/chat/conversation-list.tsx
CHANGED
|
@@ -133,10 +133,8 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 133 |
activeConversation,
|
| 134 |
setActiveConversation,
|
| 135 |
markConversationRead,
|
| 136 |
-
dashboardMode,
|
| 137 |
} = useMissionControl()
|
| 138 |
const [search, setSearch] = useState('')
|
| 139 |
-
const isGatewayMode = dashboardMode !== 'local'
|
| 140 |
|
| 141 |
// Context menu state
|
| 142 |
const [ctxMenu, setCtxMenu] = useState<{ convId: string; x: number; y: number } | null>(null)
|
|
@@ -247,9 +245,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 247 |
|
| 248 |
const loadConversations = useCallback(async () => {
|
| 249 |
try {
|
| 250 |
-
const sessionsUrl =
|
| 251 |
-
? '/api/sessions?include_local=1'
|
| 252 |
-
: '/api/sessions'
|
| 253 |
const requests: Promise<Response>[] = [
|
| 254 |
fetch(sessionsUrl),
|
| 255 |
fetch('/api/chat/session-prefs'),
|
|
@@ -260,12 +256,6 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 260 |
const prefs = prefsRes.ok ? readSessionPrefs(await prefsRes.json().catch(() => null)) : {}
|
| 261 |
|
| 262 |
const providerSessions = sessionsData
|
| 263 |
-
.filter((s) => {
|
| 264 |
-
if (dashboardMode === 'local') {
|
| 265 |
-
return s?.source === 'local' && (s?.kind === 'claude-code' || s?.kind === 'codex-cli' || s?.kind === 'hermes')
|
| 266 |
-
}
|
| 267 |
-
return s?.source === 'gateway'
|
| 268 |
-
})
|
| 269 |
.map((s, idx: number) => {
|
| 270 |
const lastActivityMs = Number(s.lastActivity || s.startTime || 0)
|
| 271 |
const updatedAt = lastActivityMs > 1_000_000_000_000
|
|
@@ -283,7 +273,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 283 |
: 'Gateway'
|
| 284 |
const prefKey = `${sessionKind}:${s.id}`
|
| 285 |
const pref = prefs[prefKey] || {}
|
| 286 |
-
const defaultName =
|
| 287 |
? `${kindLabel} • ${s.key || s.id}`
|
| 288 |
: `${s.agent || 'Gateway'} • ${s.key || s.id}`
|
| 289 |
const sessionName = pref.name || defaultName
|
|
@@ -329,7 +319,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 329 |
} catch (err) {
|
| 330 |
log.error('Failed to load conversations:', err)
|
| 331 |
}
|
| 332 |
-
}, [
|
| 333 |
|
| 334 |
useSmartPoll(loadConversations, 30000, { pauseWhenSseConnected: true })
|
| 335 |
|
|
@@ -448,7 +438,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 448 |
{/* Header */}
|
| 449 |
<div className="p-3 border-b border-border flex-shrink-0">
|
| 450 |
<div className="mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
| 451 |
-
|
| 452 |
</div>
|
| 453 |
<div className="relative">
|
| 454 |
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50">
|
|
@@ -473,33 +463,25 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 473 |
</div>
|
| 474 |
) : (
|
| 475 |
<>
|
| 476 |
-
{
|
| 477 |
<div>
|
| 478 |
<div className="px-3 pt-2 py-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-green-400/70">
|
| 479 |
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
| 480 |
Active
|
| 481 |
</div>
|
| 482 |
-
{
|
| 483 |
-
</div>
|
| 484 |
-
)}
|
| 485 |
-
{dashboardMode === 'local' && inactiveLocalRows.length > 0 && (
|
| 486 |
-
<div>
|
| 487 |
-
<div className="px-3 pt-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/40">
|
| 488 |
-
Recent
|
| 489 |
-
</div>
|
| 490 |
-
{inactiveLocalRows.map(renderConversationItem)}
|
| 491 |
</div>
|
| 492 |
)}
|
| 493 |
-
{
|
| 494 |
<div>
|
| 495 |
<div className="px-3 pt-2 py-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-green-400/70">
|
| 496 |
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
| 497 |
-
Active
|
| 498 |
</div>
|
| 499 |
-
{
|
| 500 |
</div>
|
| 501 |
)}
|
| 502 |
-
{
|
| 503 |
<div>
|
| 504 |
<div className="px-3 pt-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/40">
|
| 505 |
Recent
|
|
@@ -507,6 +489,14 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
|
|
| 507 |
{inactiveGatewayRows.map(renderConversationItem)}
|
| 508 |
</div>
|
| 509 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
</>
|
| 511 |
)}
|
| 512 |
</div>
|
|
|
|
| 133 |
activeConversation,
|
| 134 |
setActiveConversation,
|
| 135 |
markConversationRead,
|
|
|
|
| 136 |
} = useMissionControl()
|
| 137 |
const [search, setSearch] = useState('')
|
|
|
|
| 138 |
|
| 139 |
// Context menu state
|
| 140 |
const [ctxMenu, setCtxMenu] = useState<{ convId: string; x: number; y: number } | null>(null)
|
|
|
|
| 245 |
|
| 246 |
const loadConversations = useCallback(async () => {
|
| 247 |
try {
|
| 248 |
+
const sessionsUrl = '/api/sessions'
|
|
|
|
|
|
|
| 249 |
const requests: Promise<Response>[] = [
|
| 250 |
fetch(sessionsUrl),
|
| 251 |
fetch('/api/chat/session-prefs'),
|
|
|
|
| 256 |
const prefs = prefsRes.ok ? readSessionPrefs(await prefsRes.json().catch(() => null)) : {}
|
| 257 |
|
| 258 |
const providerSessions = sessionsData
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
.map((s, idx: number) => {
|
| 260 |
const lastActivityMs = Number(s.lastActivity || s.startTime || 0)
|
| 261 |
const updatedAt = lastActivityMs > 1_000_000_000_000
|
|
|
|
| 273 |
: 'Gateway'
|
| 274 |
const prefKey = `${sessionKind}:${s.id}`
|
| 275 |
const pref = prefs[prefKey] || {}
|
| 276 |
+
const defaultName = s.source === 'local'
|
| 277 |
? `${kindLabel} • ${s.key || s.id}`
|
| 278 |
: `${s.agent || 'Gateway'} • ${s.key || s.id}`
|
| 279 |
const sessionName = pref.name || defaultName
|
|
|
|
| 319 |
} catch (err) {
|
| 320 |
log.error('Failed to load conversations:', err)
|
| 321 |
}
|
| 322 |
+
}, [setConversations])
|
| 323 |
|
| 324 |
useSmartPoll(loadConversations, 30000, { pauseWhenSseConnected: true })
|
| 325 |
|
|
|
|
| 438 |
{/* Header */}
|
| 439 |
<div className="p-3 border-b border-border flex-shrink-0">
|
| 440 |
<div className="mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
| 441 |
+
Sessions
|
| 442 |
</div>
|
| 443 |
<div className="relative">
|
| 444 |
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50">
|
|
|
|
| 463 |
</div>
|
| 464 |
) : (
|
| 465 |
<>
|
| 466 |
+
{activeGatewayRows.length > 0 && (
|
| 467 |
<div>
|
| 468 |
<div className="px-3 pt-2 py-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-green-400/70">
|
| 469 |
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
| 470 |
Active
|
| 471 |
</div>
|
| 472 |
+
{activeGatewayRows.map(renderConversationItem)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
</div>
|
| 474 |
)}
|
| 475 |
+
{activeLocalRows.length > 0 && (
|
| 476 |
<div>
|
| 477 |
<div className="px-3 pt-2 py-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-green-400/70">
|
| 478 |
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
| 479 |
+
Active Local
|
| 480 |
</div>
|
| 481 |
+
{activeLocalRows.map(renderConversationItem)}
|
| 482 |
</div>
|
| 483 |
)}
|
| 484 |
+
{inactiveGatewayRows.length > 0 && (
|
| 485 |
<div>
|
| 486 |
<div className="px-3 pt-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/40">
|
| 487 |
Recent
|
|
|
|
| 489 |
{inactiveGatewayRows.map(renderConversationItem)}
|
| 490 |
</div>
|
| 491 |
)}
|
| 492 |
+
{inactiveLocalRows.length > 0 && (
|
| 493 |
+
<div>
|
| 494 |
+
<div className="px-3 pt-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/40">
|
| 495 |
+
Recent Local
|
| 496 |
+
</div>
|
| 497 |
+
{inactiveLocalRows.map(renderConversationItem)}
|
| 498 |
+
</div>
|
| 499 |
+
)}
|
| 500 |
</>
|
| 501 |
)}
|
| 502 |
</div>
|
src/store/index.ts
CHANGED
|
@@ -371,6 +371,7 @@ interface MissionControlStore {
|
|
| 371 |
// Dashboard Mode (local vs full gateway)
|
| 372 |
dashboardMode: 'full' | 'local'
|
| 373 |
gatewayAvailable: boolean
|
|
|
|
| 374 |
bannerDismissed: boolean
|
| 375 |
capabilitiesChecked: boolean
|
| 376 |
bootComplete: boolean
|
|
@@ -378,6 +379,7 @@ interface MissionControlStore {
|
|
| 378 |
defaultOrgName: string
|
| 379 |
setDashboardMode: (mode: 'full' | 'local') => void
|
| 380 |
setGatewayAvailable: (available: boolean) => void
|
|
|
|
| 381 |
dismissBanner: () => void
|
| 382 |
setCapabilitiesChecked: (checked: boolean) => void
|
| 383 |
setBootComplete: () => void
|
|
@@ -598,6 +600,7 @@ export const useMissionControl = create<MissionControlStore>()(
|
|
| 598 |
// Dashboard Mode
|
| 599 |
dashboardMode: 'local' as const,
|
| 600 |
gatewayAvailable: false,
|
|
|
|
| 601 |
bannerDismissed: false,
|
| 602 |
capabilitiesChecked: false,
|
| 603 |
bootComplete: false,
|
|
@@ -605,6 +608,7 @@ export const useMissionControl = create<MissionControlStore>()(
|
|
| 605 |
defaultOrgName: 'Default',
|
| 606 |
setDashboardMode: (mode) => set({ dashboardMode: mode }),
|
| 607 |
setGatewayAvailable: (available) => set({ gatewayAvailable: available }),
|
|
|
|
| 608 |
dismissBanner: () => set({ bannerDismissed: true }),
|
| 609 |
setCapabilitiesChecked: (checked) => set({ capabilitiesChecked: checked }),
|
| 610 |
setBootComplete: () => set({ bootComplete: true }),
|
|
|
|
| 371 |
// Dashboard Mode (local vs full gateway)
|
| 372 |
dashboardMode: 'full' | 'local'
|
| 373 |
gatewayAvailable: boolean
|
| 374 |
+
localSessionsAvailable: boolean
|
| 375 |
bannerDismissed: boolean
|
| 376 |
capabilitiesChecked: boolean
|
| 377 |
bootComplete: boolean
|
|
|
|
| 379 |
defaultOrgName: string
|
| 380 |
setDashboardMode: (mode: 'full' | 'local') => void
|
| 381 |
setGatewayAvailable: (available: boolean) => void
|
| 382 |
+
setLocalSessionsAvailable: (available: boolean) => void
|
| 383 |
dismissBanner: () => void
|
| 384 |
setCapabilitiesChecked: (checked: boolean) => void
|
| 385 |
setBootComplete: () => void
|
|
|
|
| 600 |
// Dashboard Mode
|
| 601 |
dashboardMode: 'local' as const,
|
| 602 |
gatewayAvailable: false,
|
| 603 |
+
localSessionsAvailable: false,
|
| 604 |
bannerDismissed: false,
|
| 605 |
capabilitiesChecked: false,
|
| 606 |
bootComplete: false,
|
|
|
|
| 608 |
defaultOrgName: 'Default',
|
| 609 |
setDashboardMode: (mode) => set({ dashboardMode: mode }),
|
| 610 |
setGatewayAvailable: (available) => set({ gatewayAvailable: available }),
|
| 611 |
+
setLocalSessionsAvailable: (available) => set({ localSessionsAvailable: available }),
|
| 612 |
dismissBanner: () => set({ bannerDismissed: true }),
|
| 613 |
setCapabilitiesChecked: (checked) => set({ capabilitiesChecked: checked }),
|
| 614 |
setBootComplete: () => set({ bootComplete: true }),
|