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:
@@ -1,15 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import type { LayoutNode, SelectedSession } from 'officerdev';
|
import type { SelectedSession } from 'officerdev';
|
||||||
import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
|
import { ChatTabs, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
|
||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { serverClient } from 'hooks/useServerClient';
|
import { serverClient } from 'hooks/useServerClient';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
|
||||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
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.
|
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
||||||
const CHAT_TAIL = 20;
|
const CHAT_TAIL = 20;
|
||||||
@@ -28,26 +25,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
selectedRef.current = selected;
|
selectedRef.current = selected;
|
||||||
// A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no
|
// 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.
|
// longer resolves; `appTypes` lands anything unknown on the detail panel.
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const navigate = useNavigate();
|
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
|
// 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,
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sessionId, isNew, groupCwd]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView
|
<ChatTabs />
|
||||||
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 });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router';
|
|||||||
import { Unplug } from 'lucide-react';
|
import { Unplug } from 'lucide-react';
|
||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { EditableTitle } from '@/components/EditableTitle';
|
import { EditableTitle } from '@/components/EditableTitle';
|
||||||
import { useSelectedChatSession } from '../../channels';
|
import { usePaneSelection } from './PaneSelection';
|
||||||
import { usePublishPageTitle } from '../../page-title';
|
import { usePublishPageTitle } from '../../page-title';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
@@ -226,7 +226,7 @@ function NewChat(props: NewChatProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChatDetailPanel = () => {
|
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
|
// 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
|
// rather than on `selected`, so `/chat` and `/chat/new` keep the plain "Chat" — the panel holds a
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<PaneSelectionProvider value={target} onChange={onTargetChange}>
|
||||||
|
<div className="flex h-full min-w-0 flex-col">
|
||||||
|
{open && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-2 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTargetChange(null)}
|
||||||
|
title="Back to the conversation list"
|
||||||
|
className="flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3 w-3" /> List
|
||||||
|
</button>
|
||||||
|
{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.
|
||||||
|
<span className="truncate rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{connectionLabel(target?.serverId)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">{open ? <ChatDetailPanel /> : <SessionList />}</div>
|
||||||
|
</div>
|
||||||
|
</PaneSelectionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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/<id>` 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<Tab[]>(load);
|
||||||
|
const [activeKey, setActiveKey] = useState<string>(() => '');
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
{/* 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. */}
|
||||||
|
{
|
||||||
|
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-background/60 px-2 py-1">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveKey(tab.key)}
|
||||||
|
className={`group flex max-w-[14rem] shrink-0 items-center gap-1 rounded-t px-2 py-1 text-xs transition-colors ${
|
||||||
|
tab.key === active.key
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{tabs.length > 1 && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeTab(tab.key);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-60 hover:!opacity-100"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addTab}
|
||||||
|
title="New tab"
|
||||||
|
className="shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={splitPane}
|
||||||
|
disabled={active.panes.length >= MAX_PANES}
|
||||||
|
title={
|
||||||
|
active.panes.length >= MAX_PANES ? `At most ${MAX_PANES} panes` : 'Split — add a pane beside this one'
|
||||||
|
}
|
||||||
|
className="ml-auto shrink-0 cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Columns2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{active.panes.map((pane, index) => (
|
||||||
|
<div
|
||||||
|
key={pane.key}
|
||||||
|
className={`relative min-w-0 flex-1 ${index > 0 ? 'border-l border-border' : ''}`}
|
||||||
|
style={{ width: `${100 / active.panes.length}%` }}
|
||||||
|
>
|
||||||
|
{active.panes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closePane(pane.key)}
|
||||||
|
title="Close this pane"
|
||||||
|
className="absolute right-1 top-1 z-10 cursor-pointer rounded p-0.5 text-muted-foreground opacity-40 transition-opacity hover:bg-muted hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ChatPane
|
||||||
|
target={pane.target}
|
||||||
|
onTargetChange={(next) => setPaneTarget(pane.key, next)}
|
||||||
|
showServerBadge={active.panes.length > 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<PaneSelectionValue | null>(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<SelectedSession | null>(null);
|
||||||
|
const controlled = value !== undefined && !!onChange;
|
||||||
|
|
||||||
|
const pair = useMemo<PaneSelectionValue>(
|
||||||
|
() => (controlled ? [value ?? null, onChange!] : [internal, setInternal]),
|
||||||
|
[controlled, value, onChange, internal],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <PaneSelectionContext.Provider value={pair}>{children}</PaneSelectionContext.Provider>;
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router';
|
|||||||
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react';
|
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react';
|
||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data';
|
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 { errorText } from 'helpers/error-text';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import { ServerChips } from './ServerChips';
|
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
|
// 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.
|
// a deep link and on back/forward, before any panel has published.
|
||||||
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
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/<id> shows
|
// A group path when we're on one; otherwise the open session's own directory, so /chat/<id> shows
|
||||||
// that session among its neighbours instead of snapping the list back to the default group. Null =
|
// that session among its neighbours instead of snapping the list back to the default group. Null =
|
||||||
// the default general_chat_sessions dir.
|
// the default general_chat_sessions dir.
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { ChatDetailPanel } from './ChatDetailPanel';
|
|||||||
export { SessionList };
|
export { SessionList };
|
||||||
export { LiveSessions };
|
export { LiveSessions };
|
||||||
export { ChatDetailPanel };
|
export { ChatDetailPanel };
|
||||||
|
export { ChatTabs } from './ChatTabs';
|
||||||
|
export { ChatPane } from './ChatPane';
|
||||||
|
export { PaneSelectionProvider, usePaneSelection } from './PaneSelection';
|
||||||
export type { SelectedSession } from './ChatDetailPanel';
|
export type { SelectedSession } from './ChatDetailPanel';
|
||||||
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes';
|
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes';
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export {
|
|||||||
} from './apps/Chat';
|
} from './apps/Chat';
|
||||||
export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
|
export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
|
||||||
export * from './apps/Chat/types';
|
export * from './apps/Chat/types';
|
||||||
export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
export { SessionList, ChatDetailPanel, ChatTabs } from './apps/ChatHistory';
|
||||||
export type { SelectedSession } 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.
|
// 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';
|
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
|
||||||
|
|||||||
Reference in New Issue
Block a user