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

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
This commit is contained in:
nyk 2026-03-16 12:05:41 +07:00 committed by GitHub
parent 301ee9cdd8
commit d87307a4f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 35 additions and 46 deletions

View File

@ -87,7 +87,7 @@ export default function Home() {
const tb = useTranslations('boot')
const tp = useTranslations('page')
const tc = useTranslations('common')
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()
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()
// Sync URL → Zustand activeTab
const pathname = usePathname()
@ -286,6 +286,9 @@ export default function Home() {
setDashboardMode('full')
setGatewayAvailable(true)
}
if (data?.claudeHome) {
setLocalSessionsAvailable(true)
}
setCapabilitiesChecked(true)
markStep('capabilities')
@ -362,7 +365,7 @@ export default function Home() {
]).catch(() => { /* panels will lazy-load as fallback */ })
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot once on mount, not on every pathname change
}, [connect, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setCapabilitiesChecked, setSubscription, setUpdateAvailable, setShowOnboarding, setAgents, setSessions, setProjects, setInterfaceMode, setMemoryGraphAgents, setSkillsData])
}, [connect, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setLocalSessionsAvailable, setCapabilitiesChecked, setSubscription, setUpdateAvailable, setShowOnboarding, setAgents, setSessions, setProjects, setInterfaceMode, setMemoryGraphAgents, setSkillsData])
if (!isClient || !bootComplete) {
return <Loader variant="page" steps={isClient ? initSteps : undefined} />

View File

@ -16,26 +16,18 @@ export async function GET(request: NextRequest) {
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams } = new URL(request.url)
const includeLocal = searchParams.get('include_local') === '1'
const gatewaySessions = getAllGatewaySessions()
const mappedGatewaySessions = mapGatewaySessions(gatewaySessions)
// Preserve existing behavior by default: when gateway sessions are present,
// return only gateway-backed sessions unless include_local=1 is requested.
if (mappedGatewaySessions.length > 0 && !includeLocal) {
return NextResponse.json({ sessions: mappedGatewaySessions })
}
// Local Claude + Codex sessions from disk/SQLite
// Always include local sessions alongside gateway sessions
await syncClaudeSessions()
const claudeSessions = getLocalClaudeSessions()
const codexSessions = getLocalCodexSessions()
const hermesSessions = getLocalHermesSessions()
const localMerged = mergeLocalSessions(claudeSessions, codexSessions, hermesSessions)
if (mappedGatewaySessions.length === 0) {
return NextResponse.json({ sessions: localMerged })
if (mappedGatewaySessions.length === 0 && localMerged.length === 0) {
return NextResponse.json({ sessions: [] })
}
const merged = dedupeAndSortSessions([...mappedGatewaySessions, ...localMerged])

View File

@ -133,10 +133,8 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
activeConversation,
setActiveConversation,
markConversationRead,
dashboardMode,
} = useMissionControl()
const [search, setSearch] = useState('')
const isGatewayMode = dashboardMode !== 'local'
// Context menu state
const [ctxMenu, setCtxMenu] = useState<{ convId: string; x: number; y: number } | null>(null)
@ -247,9 +245,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
const loadConversations = useCallback(async () => {
try {
const sessionsUrl = dashboardMode === 'local'
? '/api/sessions?include_local=1'
: '/api/sessions'
const sessionsUrl = '/api/sessions'
const requests: Promise<Response>[] = [
fetch(sessionsUrl),
fetch('/api/chat/session-prefs'),
@ -260,12 +256,6 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
const prefs = prefsRes.ok ? readSessionPrefs(await prefsRes.json().catch(() => null)) : {}
const providerSessions = sessionsData
.filter((s) => {
if (dashboardMode === 'local') {
return s?.source === 'local' && (s?.kind === 'claude-code' || s?.kind === 'codex-cli' || s?.kind === 'hermes')
}
return s?.source === 'gateway'
})
.map((s, idx: number) => {
const lastActivityMs = Number(s.lastActivity || s.startTime || 0)
const updatedAt = lastActivityMs > 1_000_000_000_000
@ -283,7 +273,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
: 'Gateway'
const prefKey = `${sessionKind}:${s.id}`
const pref = prefs[prefKey] || {}
const defaultName = dashboardMode === 'local'
const defaultName = s.source === 'local'
? `${kindLabel}${s.key || s.id}`
: `${s.agent || 'Gateway'}${s.key || s.id}`
const sessionName = pref.name || defaultName
@ -329,7 +319,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
} catch (err) {
log.error('Failed to load conversations:', err)
}
}, [dashboardMode, setConversations])
}, [setConversations])
useSmartPoll(loadConversations, 30000, { pauseWhenSseConnected: true })
@ -448,7 +438,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
{/* Header */}
<div className="p-3 border-b border-border flex-shrink-0">
<div className="mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{isGatewayMode ? 'Gateway Sessions' : 'Sessions'}
Sessions
</div>
<div className="relative">
<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,24 +463,7 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
</div>
) : (
<>
{dashboardMode === 'local' && activeLocalRows.length > 0 && (
<div>
<div className="px-3 pt-2 py-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-green-400/70">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
Active
</div>
{activeLocalRows.map(renderConversationItem)}
</div>
)}
{dashboardMode === 'local' && inactiveLocalRows.length > 0 && (
<div>
<div className="px-3 pt-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/40">
Recent
</div>
{inactiveLocalRows.map(renderConversationItem)}
</div>
)}
{isGatewayMode && activeGatewayRows.length > 0 && (
{activeGatewayRows.length > 0 && (
<div>
<div className="px-3 pt-2 py-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-green-400/70">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
@ -499,7 +472,16 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
{activeGatewayRows.map(renderConversationItem)}
</div>
)}
{isGatewayMode && inactiveGatewayRows.length > 0 && (
{activeLocalRows.length > 0 && (
<div>
<div className="px-3 pt-2 py-1 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-green-400/70">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
Active Local
</div>
{activeLocalRows.map(renderConversationItem)}
</div>
)}
{inactiveGatewayRows.length > 0 && (
<div>
<div className="px-3 pt-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/40">
Recent
@ -507,6 +489,14 @@ export function ConversationList({ onNewConversation: _onNewConversation }: Conv
{inactiveGatewayRows.map(renderConversationItem)}
</div>
)}
{inactiveLocalRows.length > 0 && (
<div>
<div className="px-3 pt-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground/40">
Recent Local
</div>
{inactiveLocalRows.map(renderConversationItem)}
</div>
)}
</>
)}
</div>

View File

@ -371,6 +371,7 @@ interface MissionControlStore {
// Dashboard Mode (local vs full gateway)
dashboardMode: 'full' | 'local'
gatewayAvailable: boolean
localSessionsAvailable: boolean
bannerDismissed: boolean
capabilitiesChecked: boolean
bootComplete: boolean
@ -378,6 +379,7 @@ interface MissionControlStore {
defaultOrgName: string
setDashboardMode: (mode: 'full' | 'local') => void
setGatewayAvailable: (available: boolean) => void
setLocalSessionsAvailable: (available: boolean) => void
dismissBanner: () => void
setCapabilitiesChecked: (checked: boolean) => void
setBootComplete: () => void
@ -598,6 +600,7 @@ export const useMissionControl = create<MissionControlStore>()(
// Dashboard Mode
dashboardMode: 'local' as const,
gatewayAvailable: false,
localSessionsAvailable: false,
bannerDismissed: false,
capabilitiesChecked: false,
bootComplete: false,
@ -605,6 +608,7 @@ export const useMissionControl = create<MissionControlStore>()(
defaultOrgName: 'Default',
setDashboardMode: (mode) => set({ dashboardMode: mode }),
setGatewayAvailable: (available) => set({ gatewayAvailable: available }),
setLocalSessionsAvailable: (available) => set({ localSessionsAvailable: available }),
dismissBanner: () => set({ bannerDismissed: true }),
setCapabilitiesChecked: (checked) => set({ capabilitiesChecked: checked }),
setBootComplete: () => set({ bootComplete: true }),