nyk commited on
Commit
85e41f4
·
unverified ·
1 Parent(s): 11d4af9

feat: hybrid mode — show gateway + local sessions simultaneously (#392)

Browse files

Always 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 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
- // Preserve existing behavior by default: when gateway sessions are present,
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: localMerged })
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 = dashboardMode === 'local'
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 = dashboardMode === 'local'
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
- }, [dashboardMode, setConversations])
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
- {isGatewayMode ? 'Gateway Sessions' : 'Sessions'}
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
- {dashboardMode === 'local' && activeLocalRows.length > 0 && (
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
- {activeLocalRows.map(renderConversationItem)}
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
- {isGatewayMode && activeGatewayRows.length > 0 && (
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
- {activeGatewayRows.map(renderConversationItem)}
500
  </div>
501
  )}
502
- {isGatewayMode && inactiveGatewayRows.length > 0 && (
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 }),