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';