tabs and panes: several conversations, several machines, one window

The iPad layout in the browser. A tab holds one to three panes; each pane is a whole chat —
its own server chips, its own list, its own conversation, its own socket.

The blocker was that chat:selected-session is ONE channel for the screen, so two detail
panels would have shown the same conversation. A pane now provides its own selection through
context and usePaneSelection prefers it; outside a pane the context is absent and the channel
behaves exactly as before, so the dashboard chat panel and the mobile layout are untouched.
Context rather than props because SessionList and ChatDetailPanel sit at different depths and
neither should know whether it is inside a pane.

A pane shows its LIST until something is open and the CHAT afterwards, with one way back.
Mobile can afford both at once inside a pane; three of those in a browser column would leave
nothing for the conversation itself.

The layout lives in one unscoped localStorage entry, deliberately not per server — a tab
holding one conversation from the laptop and one from alpha belongs to neither. Pane keys are
re-minted on restore, because keys from a previous page whose counter restarted at zero make
React reuse the wrong subtree and a conversation appears in the wrong column.

What this gives up, and it is the only thing: /chat/<id> still deep-links but can only open
in the first pane. With three conversations on screen there is no single one for the address
bar to name.

WorkspaceView and the fixed three-panel layout are gone from this screen; the panels
themselves are unchanged and still registered for the dashboard.

Typecheck, 602 tests and the SPA bundle all pass. Nobody has clicked it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 21:17:02 +01:00
co-authored by Claude Opus 5
parent ec4f06a313
commit a19895c216
8 changed files with 344 additions and 41 deletions
@@ -1,15 +1,12 @@
import { useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import type { LayoutNode, SelectedSession } from 'officerdev';
import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
import type { SelectedSession } from 'officerdev';
import { ChatTabs, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
import { toast } from '@/components/ui/sonner';
import { useIsMobile } from 'hooks/useIsMobile';
import { useClient } from 'hooks/useClient';
import { serverClient } from 'hooks/useServerClient';
import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { defaultLayout, hasAppType } from './defaultLayout';
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
const CHAT_TAIL = 20;
@@ -28,26 +25,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
selectedRef.current = selected;
// A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no
// longer resolves; `appTypes` lands anything unknown on the detail panel.
const workspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
const isMobile = useIsMobile();
const navigate = useNavigate();
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
// Adopt a structural change to this screen's layout.
//
// `useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened
// /chat keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs
// which app a panel runs, never the tree, so adding the Live panel above the list would have been
// invisible to every existing user and visible only on a fresh account.
//
// Replacing outright is safe *here* specifically because the screen is `locked`: its structure is
// dictated by code and the only thing a user can have contributed is the column sizes, which is a
// cheap thing to lose once. Terminates because the replacement contains the panel it tests for.
useEffect(() => {
if (!workspace.isLoaded) return;
if (hasAppType(workspace.value, 'chat-live')) return;
workspace.setValue(defaultLayout);
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
// Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests
// the address bar verbatim — so one left over from before the path-based groups sits there forever,
@@ -120,20 +98,15 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isNew, groupCwd]);
// The tabbed, multi-pane chat replaces the fixed three-panel workspace. The panels themselves are
// unchanged and still registered for the dashboard; what changes is that a PANE owns its conversation
// rather than the whole screen sharing one, which is what lets two machines be live side by side.
//
// `useDashboardState`/`WorkspaceView` are no longer used here. The layout that matters now is the tab
// blob in localStorage, because a tab spanning two servers cannot be stored per server.
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['chat-session-list', 'chat-live', 'chat-detail'], fallback: 'chat-detail' }}
mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => {
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
// on /chat/<id> it isn't (deliberately — see chat-routes.ts), so fall back to the open
// session's own directory, which the resolve above put on the selection.
if (!id) navigate(chatListPath(groupCwd ?? selectedRef.current?.cwd ?? null), { replace: true });
}}
/>
<ChatTabs />
</div>
);
};