From a19895c216902d8f97e16a031821b90b0ac74e4f Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 21:17:01 +0100 Subject: [PATCH] tabs and panes: several conversations, several machines, one window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 --- .../Screens/Dashboard/ChatHistory/index.tsx | 45 +--- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 4 +- .../src/apps/ChatHistory/ChatPane.tsx | 57 +++++ .../src/apps/ChatHistory/ChatTabs.tsx | 213 ++++++++++++++++++ .../src/apps/ChatHistory/PaneSelection.tsx | 56 +++++ .../src/apps/ChatHistory/SessionList.tsx | 5 +- .../officerdev/src/apps/ChatHistory/index.ts | 3 + src/workspaces/officerdev/src/index.ts | 2 +- 8 files changed, 344 insertions(+), 41 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 6cbc06c7..943d082e 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -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('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 (
- { - // Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL; - // on /chat/ 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 }); - }} - /> +
); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 704901bb..20d349f0 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router'; import { Unplug } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { EditableTitle } from '@/components/EditableTitle'; -import { useSelectedChatSession } from '../../channels'; +import { usePaneSelection } from './PaneSelection'; import { usePublishPageTitle } from '../../page-title'; import { useAuth } from 'hooks/useAuth'; import { errorText } from 'helpers/error-text'; @@ -226,7 +226,7 @@ function NewChat(props: NewChatProps) { } export const ChatDetailPanel = () => { - const [selected] = useSelectedChatSession(); + const [selected] = usePaneSelection(); // Name the page after the conversation, whenever the URL names a real one. Gated on the route param // rather than on `selected`, so `/chat` and `/chat/new` keep the plain "Chat" — the panel holds a diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx new file mode 100644 index 00000000..31959c3e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx @@ -0,0 +1,57 @@ +import { ArrowLeft } from 'lucide-react'; +import { connectionLabel } from 'hooks/connections'; +import { SessionList } from './SessionList'; +import { ChatDetailPanel } from './ChatDetailPanel'; +import type { SelectedSession } from './ChatDetailPanel'; +import { PaneSelectionProvider } from './PaneSelection'; + +/** + * One self-contained conversation column: its own server, its own list, its own chat. + * + * Modelled on the mobile app's pane, where "a pane is just a whole ChatScreen" — an empty one IS the + * conversation list, and filling it is tapping a row. That is what makes two panes independent without + * inventing a second concept: everything a conversation needs is already inside one. + * + * The web version differs in one way, deliberately. Mobile has room for a list and a chat side by side + * inside a pane; two or three of those in a browser column would leave nothing for the conversation. So + * a pane shows its LIST until something is open and the CHAT afterwards, with one way back. The tab bar + * above holds the panes; this holds one conversation. + */ +type ChatPaneProps = { + target: SelectedSession | null; + onTargetChange: (next: SelectedSession | null) => void; + /** Shown when more than one pane is open, so it is obvious which machine a column is on. */ + showServerBadge?: boolean; +}; + +export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPaneProps) => { + const open = !!target; + + return ( + +
+ {open && ( +
+ + {showServerBadge && ( + // Which machine this column is talking to. Only worth the space when there is more than + // one pane — with a single column the chips in the list already say it. + + {connectionLabel(target?.serverId)} + + )} +
+ )} + +
{open ? : }
+
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx new file mode 100644 index 00000000..8db421fb --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef, useState } from 'react'; +import { Columns2, Plus, X } from 'lucide-react'; +import { connectionLabel } from 'hooks/connections'; +import { ChatPane } from './ChatPane'; +import type { SelectedSession } from './ChatDetailPanel'; + +/** + * Tabs of side-by-side conversations, each pane free to sit on a different Officer. + * + * This is the iPad layout brought to the browser: one window, one `/chat`, several live conversations + * on several machines at once. The mobile app proved the shape — what it adds over the old single-panel + * screen is that a pane owns its conversation (see `PaneSelection`) instead of the whole screen sharing + * one. + * + * ## What is stored where, which is the part that matters + * + * The layout — which tabs exist, which panes they hold, and what each pane has open — is kept in ONE + * unscoped `localStorage` entry, deliberately not per server. A tab holding one conversation from the + * laptop and one from alpha belongs to neither, so scoping it to either would be wrong. The mobile app + * makes the same call and says so. + * + * A pane's `target` carries its own `serverId`, so a restored tab reopens the right conversation on the + * right machine rather than looking it up on whichever server happens to be nearest. + * + * ## The URL + * + * `/chat/` still deep-links, and still opens in the FIRST pane. It cannot mean more than that: with + * three conversations on screen there is no single "the" conversation for the address bar to name, which + * is the one place this design gives something up. Everything else about the route conventions holds. + */ + +type Pane = { key: string; target: SelectedSession | null }; +type Tab = { key: string; title?: string; panes: Pane[] }; + +const STORE_KEY = 'officer.chat.tabs.v1'; +const MAX_PANES = 3; + +let seq = 0; +const nextKey = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${seq++}`; + +function load(): Tab[] { + try { + const raw = localStorage.getItem(STORE_KEY); + const parsed = raw ? (JSON.parse(raw) as Tab[]) : null; + if (!Array.isArray(parsed) || !parsed.length) throw new Error('empty'); + // Keys were minted by a previous page whose counter restarted at zero. Re-mint them, or React can + // reuse the wrong subtree and a conversation appears in the wrong column — the mobile app hit + // exactly this and guards it the same way. + return parsed.map((tab) => ({ + ...tab, + key: nextKey('tab'), + panes: (tab.panes ?? []).slice(0, MAX_PANES).map((pane) => ({ ...pane, key: nextKey('pane') })), + })); + } catch { + return [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }]; + } +} + +export const ChatTabs = () => { + const [tabs, setTabs] = useState(load); + const [activeKey, setActiveKey] = useState(() => ''); + const restored = useRef(false); + + // First render picks the first tab; afterwards the user owns it. + useEffect(() => { + if (restored.current) return; + restored.current = true; + setActiveKey(tabs[0]?.key ?? ''); + }, [tabs]); + + useEffect(() => { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(tabs)); + } catch { + /* private mode or quota — the layout still works for this page's lifetime */ + } + }, [tabs]); + + const active = tabs.find((tab) => tab.key === activeKey) ?? tabs[0]; + + const update = (tabKey: string, fn: (tab: Tab) => Tab) => + setTabs((prev) => prev.map((tab) => (tab.key === tabKey ? fn(tab) : tab))); + + const addTab = () => { + const tab: Tab = { key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }; + setTabs((prev) => [...prev, tab]); + setActiveKey(tab.key); + }; + + const closeTab = (tabKey: string) => { + setTabs((prev) => { + const next = prev.filter((tab) => tab.key !== tabKey); + // Never leave nothing: an empty tab bar has no way back to a conversation. + const safe = next.length ? next : [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }]; + if (tabKey === activeKey) setActiveKey(safe[0]!.key); + return safe; + }); + }; + + const splitPane = () => + active && + update(active.key, (tab) => + tab.panes.length >= MAX_PANES ? tab : { ...tab, panes: [...tab.panes, { key: nextKey('pane'), target: null }] }, + ); + + const closePane = (paneKey: string) => + active && + update(active.key, (tab) => + tab.panes.length <= 1 ? tab : { ...tab, panes: tab.panes.filter((pane) => pane.key !== paneKey) }, + ); + + const setPaneTarget = (paneKey: string, target: SelectedSession | null) => + active && + update(active.key, (tab) => ({ + ...tab, + panes: tab.panes.map((pane) => (pane.key === paneKey ? { ...pane, target } : pane)), + })); + + if (!active) return null; + + return ( +
+ {/* Always visible: it is the only way to open a second tab or split a pane, so hiding it in the + single-conversation case would hide the feature from anyone who has not already used it. */} + { +
+ {tabs.map((tab) => { + // A tab is named after what is in it: the first pane's conversation, else the machine. + const first = tab.panes[0]?.target; + const label = + tab.title || + first?.title || + (tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat')); + return ( + + ); + })} + + + + +
+ } + +
+ {active.panes.map((pane, index) => ( +
0 ? 'border-l border-border' : ''}`} + style={{ width: `${100 / active.panes.length}%` }} + > + {active.panes.length > 1 && ( + + )} + setPaneTarget(pane.key, next)} + showServerBadge={active.panes.length > 1} + /> +
+ ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx new file mode 100644 index 00000000..bebbd2e5 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx @@ -0,0 +1,56 @@ +import { createContext, useContext, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useSelectedChatSession } from '../../channels'; +import type { SelectedSession } from './ChatDetailPanel'; + +/** + * Which conversation THIS pane has open. + * + * `chat:selected-session` is one channel for the whole screen, which was right while there was exactly + * one conversation on it. Two panes side by side make it wrong: both would read the same value and show + * the same chat, which is the opposite of the point. + * + * So a pane provides its own state here, and `usePaneSelection` prefers it. Outside a pane the context + * is absent and the channel is used exactly as before — every existing caller (the mobile layout, the + * dashboard's own chat panel) is untouched, which is what makes this safe to drop in. + * + * Deliberately a context rather than props: `SessionList` and `ChatDetailPanel` sit at different depths + * and neither should have to know whether it is inside a pane. + */ +type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null) => void]; + +const PaneSelectionContext = createContext(null); + +export function usePaneSelection(): PaneSelectionValue { + const scoped = useContext(PaneSelectionContext); + const channel = useSelectedChatSession(); + // Hooks must run unconditionally, so the channel is always read; the scoped value simply wins. + return scoped ?? ([channel[0], channel[1]] as PaneSelectionValue); +} + +/** + * Give the subtree its own selection. + * + * `value`/`onChange` make it controllable, so the tab shell can persist a pane's open conversation + * across a reload — the mobile app keeps the target on the pane for the same reason, and it is what + * makes a restored tab still point at the right chat on the right machine. + */ +export const PaneSelectionProvider = ({ + children, + value, + onChange, +}: { + children: ReactNode; + value?: SelectedSession | null; + onChange?: (next: SelectedSession | null) => void; +}) => { + const [internal, setInternal] = useState(null); + const controlled = value !== undefined && !!onChange; + + const pair = useMemo( + () => (controlled ? [value ?? null, onChange!] : [internal, setInternal]), + [controlled, value, onChange, internal], + ); + + return {children}; +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 0c1eb3f9..75157bf2 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router'; import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data'; -import { useSelectedChatSession } from '../../channels'; +import { usePaneSelection } from './PaneSelection'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { ServerChips } from './ServerChips'; @@ -19,7 +19,8 @@ export const SessionList = () => { // them there rather than from the selection channel means the highlight and the group are correct on // a deep link and on back/forward, before any panel has published. const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); - const [selected, setSelected] = useSelectedChatSession(); + // Scoped to this pane when inside one, the shared channel otherwise — see PaneSelection. + const [selected, setSelected] = usePaneSelection(); // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts index 893f8375..949666a3 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts +++ b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts @@ -7,6 +7,9 @@ import { ChatDetailPanel } from './ChatDetailPanel'; export { SessionList }; export { LiveSessions }; export { ChatDetailPanel }; +export { ChatTabs } from './ChatTabs'; +export { ChatPane } from './ChatPane'; +export { PaneSelectionProvider, usePaneSelection } from './PaneSelection'; export type { SelectedSession } from './ChatDetailPanel'; export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes'; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index c3b207a2..a66deb9e 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -29,7 +29,7 @@ export { } from './apps/Chat'; export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat'; export * from './apps/Chat/types'; -export { SessionList, ChatDetailPanel } from './apps/ChatHistory'; +export { SessionList, ChatDetailPanel, ChatTabs } from './apps/ChatHistory'; export type { SelectedSession } from './apps/ChatHistory'; // The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way. export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';