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:
parent
301ee9cdd8
commit
d87307a4f8
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Reference in New Issue