revert the chat tabs and panes work, back to one conversation
Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from "tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the per-server directory picker, the render-loop fix, pane transcript resolution, the send queue, the two socket fixes from the other session, the pane-socket notes, and my own socket-set change from tonight. He is rebuilding from here. Deliberately KEPT:dc6b623, "talk to two officers at once from one browser". That was a separate ask that predates the tabs one, and the multi-server client, the server chips and the connections store stand on their own without panes. Reverting it too is one more command if that was the intent. Collateral, worth naming:cb7ab55carried an unrelated MusicPlayerHost change alongside its socket instrumentation, so that came out with it. Reverts, not a reset — every one of these is pushed and a second session is live in this repo. Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing identically before this and unrelated to chat. What is NOT explained by this revert: the browser symptoms tonight. The server was verified good throughout — two real turns streamed back through the public URL on both models, and the full 2,281-message history came through nginx intact. Whatever the client fault is, it is still unfound, and the pre-tabs code is where it now has to be looked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,74 +0,0 @@
|
|||||||
# Multi-pane chat: where it stands, and the one open bug
|
|
||||||
|
|
||||||
Written 2026-08-10 at the end of a long session, so the next one starts from evidence instead of
|
|
||||||
re-deriving it.
|
|
||||||
|
|
||||||
## The open bug
|
|
||||||
|
|
||||||
**A pane pointed at a REMOTE server shows `Disconnected` and never gets a reply.** The pane opens, lists
|
|
||||||
and reads that server's conversations fine (HTTP with the API key works). Sending produces nothing.
|
|
||||||
|
|
||||||
Console, repeatedly:
|
|
||||||
|
|
||||||
```
|
|
||||||
WebSocket connection to 'wss://officer.pastilhas.dev/api/chat/ws?token=ofk_…' failed:
|
|
||||||
WebSocket is closed before the connection is established.
|
|
||||||
```
|
|
||||||
|
|
||||||
The same message also appears for the LOCAL pane's `ws://localhost:9010/...`, yet the local pane works
|
|
||||||
and shows connected. So the message alone is not the bug — something closes the remote one and it never
|
|
||||||
comes back.
|
|
||||||
|
|
||||||
## Ruled out, each by direct test — do not re-test these
|
|
||||||
|
|
||||||
- **The server.** `wss://officer.pastilhas.dev/api/chat/ws?token=<the same ofk_ key>` opens on the first
|
|
||||||
try from outside the browser. Tested twice: with no `Origin` header, and with
|
|
||||||
`Origin: http://localhost:9010` (what the browser sends). Both `OPEN`.
|
|
||||||
- **The key.** Same key, works over HTTP for `/chat/sessions`, and opens the socket above.
|
|
||||||
- **Websocket auth not understanding API keys.** `upgradeWs` in `server.tsx` uses `resolveAuthToken`, the
|
|
||||||
same resolver as the HTTP doors, and comments say so explicitly. `chat` is an `execution` capability
|
|
||||||
and the owner passes `isWsProviderAllowed`.
|
|
||||||
- **Officer being stale on alpha.** Pulled and restarted; the failure persists.
|
|
||||||
- **Send being dropped.** Fixed in `243bd04` — `useChatWebSocket.send` used to `return` silently when the
|
|
||||||
socket was not `OPEN`. It now queues and flushes on open. That fix is real and worth keeping, but it
|
|
||||||
did not resolve this: a socket that never opens never flushes.
|
|
||||||
|
|
||||||
## Where to look next
|
|
||||||
|
|
||||||
`src/workspaces/hooks/src/useChatWebSocket.ts`, and specifically what happens with SEVERAL instances
|
|
||||||
mounted at once — one per pane.
|
|
||||||
|
|
||||||
The handlers look individually correct: `close` returns early when `socketRef.current !== socket`, the
|
|
||||||
effect depends on `[url]` alone, and `isCleaningUpRef` is reset on mount. What has NOT been established
|
|
||||||
is how those interact across three simultaneous instances plus React's dev StrictMode double-invoke,
|
|
||||||
which creates, closes and recreates every socket on mount.
|
|
||||||
|
|
||||||
Concrete things to try, cheapest first:
|
|
||||||
|
|
||||||
1. **Instrument before theorising.** Log `url`, `readyState` and instance identity on every create,
|
|
||||||
open, close and retry. The console message says a close arrived during CONNECTING; it does not say
|
|
||||||
who called it. That is the whole question and it is one log line away.
|
|
||||||
2. **Check whether the effect re-runs.** `url` is a string, so it should be stable — but
|
|
||||||
`chatSocketUrl(serverId, localStorage.getItem('BEARER_TOKEN'))` is recomputed every render, and if
|
|
||||||
anything makes `serverId` flip (say, a pane re-resolving its target) the URL changes and the socket
|
|
||||||
is torn down mid-handshake, forever.
|
|
||||||
3. **Suspect the transcript resolver in `ChatPane`.** It calls `onTargetChange` on success, which
|
|
||||||
rewrites the tab state, which produces a new `target` object for every pane in that tab. If that ever
|
|
||||||
loops, every pane's socket is rebuilt on every pass and none survives the handshake.
|
|
||||||
4. **Try one pane pointed at the remote server, alone.** If it connects, the bug is about multiplicity
|
|
||||||
rather than about the remote server, which would be the single most useful fact to have.
|
|
||||||
|
|
||||||
## What works, so it is not re-litigated
|
|
||||||
|
|
||||||
- Adding a second server by URL + `ofk_` key, verified against `/api/auth/me` before storing.
|
|
||||||
- Per-pane server chips; the list, the directory picker and the transcript all follow the pane's server.
|
|
||||||
- Tabs, splitting to three panes, closing, renaming a tab, and the tab name winning the page title.
|
|
||||||
- Reading a remote conversation end to end.
|
|
||||||
|
|
||||||
## Setup facts worth not rediscovering
|
|
||||||
|
|
||||||
- Andre's Mac key lives in agent memory. Alpha needs its own; the one in the console above is alpha's.
|
|
||||||
- `bun dev` on the Mac serves a STALE bundle whenever a new file is added to a workspace package — the
|
|
||||||
symptom is `X is not a function` for an export that plainly exists. Restart it; do not debug it.
|
|
||||||
- The Mac is `https://macbook.pastilhas.dev` through NPM on alpha (`100.64.0.1` → Mac `100.64.0.9:9010`),
|
|
||||||
and the cert is valid under strict TLS.
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import type { SelectedSession } from 'officerdev';
|
import type { LayoutNode, SelectedSession } from 'officerdev';
|
||||||
import { ChatTabs, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
|
import { WorkspaceView, 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;
|
||||||
@@ -25,7 +28,26 @@ 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,
|
||||||
@@ -98,15 +120,20 @@ 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">
|
||||||
<ChatTabs />
|
<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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import type { PageTitleOverride } from 'officerdev';
|
import type { PageTitleOverride } from 'officerdev';
|
||||||
import { usePageTitleOverride, useChatTabName } from 'officerdev';
|
import { usePageTitleOverride } from 'officerdev';
|
||||||
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
||||||
|
|
||||||
type TitleRule = { match: (p: string) => boolean; title: string };
|
type TitleRule = { match: (p: string) => boolean; title: string };
|
||||||
@@ -160,7 +160,6 @@ claimTabIdentity();
|
|||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const override = usePageTitleOverride();
|
const override = usePageTitleOverride();
|
||||||
const chatTabName = useChatTabName();
|
|
||||||
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
|
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
|
||||||
|
|
||||||
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
|
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
|
||||||
@@ -186,10 +185,7 @@ export function usePageTitle() {
|
|||||||
|
|
||||||
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
||||||
|
|
||||||
// A chat tab's own name is the most specific thing anyone has said about this page — more specific
|
return [label ?? override?.title ?? titleForPath(pathname), rename] as const;
|
||||||
// than the conversation inside it (there may be three) and more deliberate than a browser-tab name
|
|
||||||
// typed earlier on a different screen. So it wins outright.
|
|
||||||
return [chatTabName ?? label ?? override?.title ?? titleForPath(pathname), rename] as const;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class SessionManager {
|
|||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
piProcess: null,
|
piProcess: null,
|
||||||
sockets: new Set(),
|
ws: null,
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
idleTimer: null,
|
idleTimer: null,
|
||||||
streamBuffer: '',
|
streamBuffer: '',
|
||||||
@@ -145,7 +145,7 @@ class SessionManager {
|
|||||||
attachWs(sessionId: string, ws: any): void {
|
attachWs(sessionId: string, ws: any): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.sockets.add(ws);
|
session.ws = ws;
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
|
|
||||||
if (session.idleTimer) {
|
if (session.idleTimer) {
|
||||||
@@ -155,24 +155,14 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
detachWs(sessionId: string): void {
|
||||||
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
|
|
||||||
* session's only socket field, so a stale client's close event silenced whichever client had attached
|
|
||||||
* after it. A close is only the end of the conversation when nothing else is still watching.
|
|
||||||
*/
|
|
||||||
detachWs(sessionId: string, ws: any): void {
|
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.sockets.delete(ws);
|
session.ws = null;
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
|
|
||||||
hasSockets(sessionId: string): boolean {
|
|
||||||
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|||||||
@@ -299,13 +299,7 @@ export type UserSession = {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
model: string;
|
model: string;
|
||||||
piProcess: any | null;
|
piProcess: any | null;
|
||||||
/**
|
ws: any | null;
|
||||||
* Every socket watching this conversation, not the most recent one. Two panes in one window, or a
|
|
||||||
* laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a
|
|
||||||
* single `ws` field meant the newest attach silently stole the turn from everyone else, while any one
|
|
||||||
* of them closing set it to null and killed delivery for the rest.
|
|
||||||
*/
|
|
||||||
sockets: Set<any>;
|
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
idleTimer: Timer | null;
|
idleTimer: Timer | null;
|
||||||
streamBuffer: string;
|
streamBuffer: string;
|
||||||
|
|||||||
@@ -158,10 +158,8 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
|||||||
|
|
||||||
const sessionId = wsToSessionMap.get(ws);
|
const sessionId = wsToSessionMap.get(ws);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
sessionManager.detachWs(sessionId, ws);
|
sessionManager.detachWs(sessionId);
|
||||||
// Only once nothing is watching. Another pane or another device still attached means the
|
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||||
// conversation is live, and arming the idle GC here would collect it out from under them.
|
|
||||||
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +244,7 @@ function createMessageHandler(sessionId: string, model: string) {
|
|||||||
const session = sessionManager.getSession(sessionId);
|
const session = sessionManager.getSession(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
foldIntoSession(session, msg, model);
|
foldIntoSession(session, msg, model);
|
||||||
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
|
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +651,7 @@ async function handleResumeCursor(
|
|||||||
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
|
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
|
||||||
// away" into a turn that was running perfectly well.
|
// away" into a turn that was running perfectly well.
|
||||||
if (msg.generating && decision.kind !== 'assume') {
|
if (msg.generating && decision.kind !== 'assume') {
|
||||||
await endTurnIfAgentIsGone([ws], sessionId, decision.model);
|
await endTurnIfAgentIsGone(ws, sessionId, decision.model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,7 +821,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
|||||||
* so its sessions are left alone rather than guessed at.
|
* so its sessions are left alone rather than guessed at.
|
||||||
*/
|
*/
|
||||||
async function endTurnIfAgentIsGone(
|
async function endTurnIfAgentIsGone(
|
||||||
targets: Iterable<ServerWebSocket<WSData> | null>,
|
ws: ServerWebSocket<WSData> | null,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
model: string,
|
model: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -836,11 +834,11 @@ async function endTurnIfAgentIsGone(
|
|||||||
const event: ServerMessage = { type: 'cut-off' };
|
const event: ServerMessage = { type: 'cut-off' };
|
||||||
try {
|
try {
|
||||||
const seq = await appendChatEvent(sessionId, event);
|
const seq = await appendChatEvent(sessionId, event);
|
||||||
for (const target of targets) sendToClient(target, event, seq);
|
sendToClient(ws, event, seq);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
|
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
|
||||||
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
||||||
for (const target of targets) sendToClient(target, event);
|
sendToClient(ws, event);
|
||||||
}
|
}
|
||||||
logger.info('Ended a turn whose agent had gone', { sessionId });
|
logger.info('Ended a turn whose agent had gone', { sessionId });
|
||||||
}
|
}
|
||||||
@@ -852,11 +850,7 @@ async function endTurnIfAgentIsGone(
|
|||||||
sidecar.onClaudeSidecarStarted(() => {
|
sidecar.onClaudeSidecarStarted(() => {
|
||||||
for (const session of sessionManager.getAllSessions()) {
|
for (const session of sessionManager.getAllSessions()) {
|
||||||
if (!session.isGenerating) continue;
|
if (!session.isGenerating) continue;
|
||||||
void endTurnIfAgentIsGone(
|
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
|
||||||
session.sockets as Set<ServerWebSocket<WSData>>,
|
|
||||||
session.sessionId,
|
|
||||||
session.model,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,22 +20,6 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
|||||||
const onOpenRef = useRef(onOpen);
|
const onOpenRef = useRef(onOpen);
|
||||||
onOpenRef.current = onOpen;
|
onOpenRef.current = onOpen;
|
||||||
|
|
||||||
/**
|
|
||||||
* Messages typed before the socket was ready.
|
|
||||||
*
|
|
||||||
* `send` used to drop them: `readyState !== OPEN` returned, silently, with no error and no retry — so
|
|
||||||
* pressing enter did nothing and the turn never happened. That window is not rare. React's dev
|
|
||||||
* StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a
|
|
||||||
* reconnect after a drop reopens it again; with several chat panes on screen there are several sockets
|
|
||||||
* doing this at once. One of them is always briefly not OPEN.
|
|
||||||
*
|
|
||||||
* Queued and flushed on open, in order. The mobile chat app does exactly this and for exactly this
|
|
||||||
* reason — the composer is allowed to fire before the transport is ready.
|
|
||||||
*/
|
|
||||||
const pendingRef = useRef<string[]>([]);
|
|
||||||
/** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */
|
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (isCleaningUpRef.current) return;
|
if (isCleaningUpRef.current) return;
|
||||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
|
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
|
||||||
@@ -43,30 +27,15 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
|||||||
const socket = new WebSocket(url);
|
const socket = new WebSocket(url);
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
// Temporary, and deliberately loud. A pane connected and then sat silent, and reasoning from this
|
|
||||||
// hook's source three times running did not explain it — the console says a socket closed, never who
|
|
||||||
// closed it or whether the message went out. `window.__officerWs = false` turns it off.
|
|
||||||
const host = new URL(url).host;
|
|
||||||
const log = (what: string, extra?: unknown) =>
|
|
||||||
(window as any).__officerWs !== false && console.log(`[ws ${host}] ${what}`, extra ?? '');
|
|
||||||
log('creating');
|
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
if (socketRef.current !== socket) return;
|
if (socketRef.current !== socket) return;
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
retryRef.current = 0;
|
retryRef.current = 0;
|
||||||
log('OPEN');
|
|
||||||
// BEFORE onOpen, deliberately: onOpen sends the resume/attach handshake, and anything the user
|
|
||||||
// typed while connecting belongs after that, not in front of it.
|
|
||||||
const queued = pendingRef.current;
|
|
||||||
pendingRef.current = [];
|
|
||||||
onOpenRef.current?.();
|
onOpenRef.current?.();
|
||||||
for (const message of queued) socket.send(message);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('message', (ev) => {
|
socket.addEventListener('message', (ev) => {
|
||||||
try {
|
try {
|
||||||
log('recv', String(ev.data).slice(0, 120));
|
|
||||||
const data = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
|
const data = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
|
||||||
onMessageRef.current(data);
|
onMessageRef.current(data);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -74,13 +43,7 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('close', (ev) => {
|
socket.addEventListener('close', () => {
|
||||||
log('close', {
|
|
||||||
code: ev.code,
|
|
||||||
reason: ev.reason,
|
|
||||||
stale: socketRef.current !== socket,
|
|
||||||
tearingDown: isCleaningUpRef.current,
|
|
||||||
});
|
|
||||||
if (isCleaningUpRef.current) return;
|
if (isCleaningUpRef.current) return;
|
||||||
if (socketRef.current !== socket) return;
|
if (socketRef.current !== socket) return;
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
@@ -96,60 +59,25 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A pending teardown from a remount that is about to be undone — see below.
|
|
||||||
if (closeTimerRef.current !== null) {
|
|
||||||
clearTimeout(closeTimerRef.current);
|
|
||||||
closeTimerRef.current = null;
|
|
||||||
}
|
|
||||||
isCleaningUpRef.current = false;
|
isCleaningUpRef.current = false;
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
/**
|
|
||||||
* Close LATER, not now.
|
|
||||||
*
|
|
||||||
* Closing here directly is correct for a real unmount and disastrous for a remount, and this hook
|
|
||||||
* cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect
|
|
||||||
* (mount → unmount → mount), and a pane whose subtree is re-created — a tab re-render, a resolved
|
|
||||||
* transcript, a parent key change — does the same. Each time, the socket was closed while still
|
|
||||||
* CONNECTING, the browser logged "closed before the connection is established", and the replacement
|
|
||||||
* was closed in turn. A pane could churn forever and never hold a connection: exactly what a fresh
|
|
||||||
* remote pane did.
|
|
||||||
*
|
|
||||||
* Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and
|
|
||||||
* cancels this timer, so the live socket is kept and the handshake completes. A real unmount has
|
|
||||||
* nobody to cancel it and the socket closes a frame later, which costs nothing.
|
|
||||||
*/
|
|
||||||
isCleaningUpRef.current = true;
|
isCleaningUpRef.current = true;
|
||||||
if (retryTimeoutRef.current !== null) {
|
if (retryTimeoutRef.current !== null) {
|
||||||
clearTimeout(retryTimeoutRef.current);
|
clearTimeout(retryTimeoutRef.current);
|
||||||
retryTimeoutRef.current = null;
|
retryTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
const socket = socketRef.current;
|
if (socketRef.current) {
|
||||||
closeTimerRef.current = window.setTimeout(() => {
|
socketRef.current.close();
|
||||||
closeTimerRef.current = null;
|
socketRef.current = null;
|
||||||
if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it
|
}
|
||||||
if (socket) socket.close();
|
|
||||||
if (socketRef.current === socket) socketRef.current = null;
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
const send = (data: Record<string, unknown>) => {
|
const send = (data: Record<string, unknown>) => {
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
const message = JSON.stringify(data);
|
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
socket.send(JSON.stringify(data));
|
||||||
if ((window as any).__officerWs !== false) console.log(`[ws ${new URL(url).host}] send`, message.slice(0, 120));
|
|
||||||
socket.send(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((window as any).__officerWs !== false)
|
|
||||||
console.log(`[ws ${new URL(url).host}] QUEUED (socket ${socket?.readyState ?? 'none'})`, message.slice(0, 80));
|
|
||||||
// Not open yet, or reconnecting. Hold it rather than dropping it — see `pendingRef`. Bounded so a
|
|
||||||
// socket that never comes back cannot grow this without limit; the oldest go first, because the
|
|
||||||
// newest message is the one the user is still waiting on.
|
|
||||||
pendingRef.current.push(message);
|
|
||||||
if (pendingRef.current.length > 50) pendingRef.current.shift();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { isConnected, send };
|
return { isConnected, send };
|
||||||
|
|||||||
@@ -119,16 +119,7 @@ export const MusicPlayerHost = () => {
|
|||||||
|
|
||||||
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
||||||
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
|
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
|
||||||
//
|
|
||||||
// DISABLED on the web (Andre, 2026-08-10). The music sidecar is not running on every machine that
|
|
||||||
// serves this app, so every page load fired `/music/now-playing` and logged a 503 in the console of a
|
|
||||||
// browser that was not there for music at all. Restoring a paused track is a nicety; a permanent error
|
|
||||||
// on every load of every screen is not worth it. The player itself is untouched — play something and
|
|
||||||
// it works; it simply no longer asks what WAS playing.
|
|
||||||
const RESTORE_NOW_PLAYING = false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!RESTORE_NOW_PLAYING) return;
|
|
||||||
if (restoredRef.current) return;
|
if (restoredRef.current) return;
|
||||||
restoredRef.current = true;
|
restoredRef.current = true;
|
||||||
if (queue.length) return;
|
if (queue.length) return;
|
||||||
|
|||||||
@@ -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 { usePaneSelection } from './PaneSelection';
|
import { useSelectedChatSession } from '../../channels';
|
||||||
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] = usePaneSelection();
|
const [selected] = useSelectedChatSession();
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
|
||||||
import { serverClient } from 'hooks/useServerClient';
|
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the transcript for a row this pane just opened.
|
|
||||||
*
|
|
||||||
* The screen-level resolver does this for the single-panel layout, but it writes to the shared
|
|
||||||
* channel — which a pane deliberately does not read. So a pane clicked a row, got `{id, title, cwd}`
|
|
||||||
* and nothing else, and rendered an empty conversation while the list behind it reset to the default
|
|
||||||
* directory. It has to resolve its own, from ITS server: two machines can hold the same uuid, so
|
|
||||||
* asking the wrong one is not merely empty, it is wrong.
|
|
||||||
*/
|
|
||||||
const resolvingRef = useRef<string | null>(null);
|
|
||||||
const id = target?.id ?? null;
|
|
||||||
const needsTranscript = !!id && !id.startsWith('new:') && !target?.resumeSessionId;
|
|
||||||
const serverId = target?.serverId ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!needsTranscript || !id) return;
|
|
||||||
if (resolvingRef.current === id) return; // one fetch per row, not one per render
|
|
||||||
resolvingRef.current = id;
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const detail = await serverClient(serverId).get<{
|
|
||||||
model?: string | null;
|
|
||||||
messages: unknown[];
|
|
||||||
total: number;
|
|
||||||
offset: number;
|
|
||||||
cwd: string;
|
|
||||||
title?: string | null;
|
|
||||||
partCount?: number;
|
|
||||||
}>(`/chat/sessions/${id}?limit=20`);
|
|
||||||
if (cancelled) return;
|
|
||||||
onTargetChange({
|
|
||||||
id,
|
|
||||||
serverId,
|
|
||||||
model: detail.model,
|
|
||||||
resumeSessionId: id,
|
|
||||||
initialMessages: detail.messages as never,
|
|
||||||
total: detail.total,
|
|
||||||
initialOffset: detail.offset,
|
|
||||||
cwd: detail.cwd,
|
|
||||||
title: detail.title ?? undefined,
|
|
||||||
partCount: detail.partCount,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Leave the pane on the row it has. Falling back to an empty chat would look like a conversation
|
|
||||||
// that lost its history rather than one that could not be read.
|
|
||||||
if (!cancelled) resolvingRef.current = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [needsTranscript, id, serverId, onTargetChange]);
|
|
||||||
|
|
||||||
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 ? (
|
|
||||||
<SessionList />
|
|
||||||
) : needsTranscript ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChatDetailPanel />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PaneSelectionProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { Columns2, Plus, X } from 'lucide-react';
|
|
||||||
import { connectionLabel } from 'hooks/connections';
|
|
||||||
import { usePublishChatTabName } from '../../page-title';
|
|
||||||
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 [renamingKey, setRenamingKey] = useState<string | null>(null);
|
|
||||||
const [renameValue, setRenameValue] = useState('');
|
|
||||||
const restored = useRef(false);
|
|
||||||
// Read inside the stable callback above, so it never has to be a dependency.
|
|
||||||
const activeKeyRef = useRef(activeKey);
|
|
||||||
activeKeyRef.current = activeKey;
|
|
||||||
|
|
||||||
// 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];
|
|
||||||
|
|
||||||
// Only a name YOU typed is published — a label derived from the conversation would just restate the
|
|
||||||
// title the chat already publishes, one tier lower, and would then outrank a browser-tab name for no
|
|
||||||
// reason the user could see.
|
|
||||||
usePublishChatTabName(active?.title?.trim() || null);
|
|
||||||
|
|
||||||
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 startRename = (tab: Tab) => {
|
|
||||||
setRenamingKey(tab.key);
|
|
||||||
// Seeded with the typed name only, not the derived label: pre-filling a name the user never chose
|
|
||||||
// makes Enter silently adopt it as if they had.
|
|
||||||
setRenameValue(tab.title ?? '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitRename = () => {
|
|
||||||
if (!renamingKey) return;
|
|
||||||
const next = renameValue.trim();
|
|
||||||
// Empty hands the tab back to its derived name — the only way out, and no third state.
|
|
||||||
update(renamingKey, (tab) => ({ ...tab, title: next || undefined }));
|
|
||||||
setRenamingKey(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
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) },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stable across renders on purpose. It is handed to every pane as `onChange`, and a pane passes it
|
|
||||||
// into a context that other components read — an identity that changed every render would make any
|
|
||||||
// effect depending on it re-run forever, which is the render loop this file already caused once.
|
|
||||||
const setPaneTarget = useCallback(
|
|
||||||
(paneKey: string, target: SelectedSession | null) =>
|
|
||||||
setTabs((prev) =>
|
|
||||||
prev.map((tab) =>
|
|
||||||
tab.key !== activeKeyRef.current
|
|
||||||
? 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'));
|
|
||||||
if (renamingKey === tab.key) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
key={tab.key}
|
|
||||||
autoFocus
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(ev) => setRenameValue(ev.target.value)}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') commitRename();
|
|
||||||
if (ev.key === 'Escape') setRenamingKey(null);
|
|
||||||
}}
|
|
||||||
onBlur={commitRename}
|
|
||||||
aria-label="Tab name"
|
|
||||||
placeholder={label}
|
|
||||||
className="w-32 shrink-0 rounded-t border-b border-primary/40 bg-muted px-2 py-1 text-xs outline-none"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => (tab.key === active.key ? startRename(tab) : setActiveKey(tab.key))}
|
|
||||||
onDoubleClick={() => startRename(tab)}
|
|
||||||
title={tab.key === active.key ? 'Click to rename' : label}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -8,14 +8,12 @@ type DirPickerModalProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (absPath: string) => void;
|
onSelect: (absPath: string) => void;
|
||||||
/** Whose filesystem to browse. Absent = this origin. */
|
|
||||||
serverId?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// A simplified file-browser modal for picking a working directory (returns an absolute path).
|
// A simplified file-browser modal for picking a working directory (returns an absolute path).
|
||||||
// Navigates within the home root; dirs elsewhere are reachable via the selector's free-text field.
|
// Navigates within the home root; dirs elsewhere are reachable via the selector's free-text field.
|
||||||
export const DirPickerModal = ({ open, onClose, onSelect, serverId }: DirPickerModalProps) => {
|
export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps) => {
|
||||||
const api = useFilesAPI('home', serverId);
|
const api = useFilesAPI('home');
|
||||||
const [path, setPath] = useState('/'); // root-relative, always starts with '/'
|
const [path, setPath] = useState('/'); // root-relative, always starts with '/'
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
@@ -25,9 +23,7 @@ export const DirPickerModal = ({ open, onClose, onSelect, serverId }: DirPickerM
|
|||||||
const [showHidden, setShowHidden] = useState(false);
|
const [showHidden, setShowHidden] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
// Server in the key: two machines have different trees, and without it one machine's folders
|
queryKey: ['dir-picker', path],
|
||||||
// are served from cache under the other's name.
|
|
||||||
queryKey: ['dir-picker', path, serverId ?? null],
|
|
||||||
queryFn: () => api.listDir(path),
|
queryFn: () => api.listDir(path),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is this component inside a pane?
|
|
||||||
*
|
|
||||||
* A pane owns its conversation AND its directory, so it must not take either from the address bar:
|
|
||||||
* three panes cannot share one URL. Outside a pane the route stays the authority, exactly as before.
|
|
||||||
*/
|
|
||||||
export function useIsInPane(): boolean {
|
|
||||||
return useContext(PaneSelectionContext) !== 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>;
|
|
||||||
};
|
|
||||||
@@ -6,16 +6,14 @@ import { DirPickerModal } from './DirPickerModal';
|
|||||||
type PwdSelectorProps = {
|
type PwdSelectorProps = {
|
||||||
value: string | null; // null = the default general_chat_sessions dir
|
value: string | null; // null = the default general_chat_sessions dir
|
||||||
onChange: (cwd: string | null) => void;
|
onChange: (cwd: string | null) => void;
|
||||||
/** Which Officer's directories to offer. Absent = this origin. */
|
|
||||||
serverId?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// A shorter, friendlier label for a working directory.
|
// A shorter, friendlier label for a working directory.
|
||||||
const shorten = (cwd: string) => cwd.replace(/^\/home\/[^/]+/, '~');
|
const shorten = (cwd: string) => cwd.replace(/^\/home\/[^/]+/, '~');
|
||||||
const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd;
|
const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd;
|
||||||
|
|
||||||
export const PwdSelector = ({ value, onChange, serverId }: PwdSelectorProps) => {
|
export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
|
||||||
const { pwds, defaultCwd } = useChatPwds(serverId);
|
const { pwds, defaultCwd } = useChatPwds();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [browse, setBrowse] = useState(false);
|
const [browse, setBrowse] = useState(false);
|
||||||
const [custom, setCustom] = useState('');
|
const [custom, setCustom] = useState('');
|
||||||
@@ -98,7 +96,7 @@ export const PwdSelector = ({ value, onChange, serverId }: PwdSelectorProps) =>
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DirPickerModal open={browse} onClose={() => setBrowse(false)} onSelect={(p) => pick(p)} serverId={serverId} />
|
<DirPickerModal open={browse} onClose={() => setBrowse(false)} onSelect={(p) => pick(p)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { usePaneSelection, useIsInPane } from './PaneSelection';
|
import { useSelectedChatSession } from '../../channels';
|
||||||
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,16 +19,11 @@ 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 }>();
|
||||||
// Scoped to this pane when inside one, the shared channel otherwise — see PaneSelection.
|
const [selected, setSelected] = useSelectedChatSession();
|
||||||
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.
|
||||||
// In a pane the directory is the pane's, not the route's — three panes cannot share one URL, and
|
const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null;
|
||||||
// letting the address bar win is what reset this list to the default the moment a row was clicked.
|
|
||||||
const inPane = useIsInPane();
|
|
||||||
const [paneCwd, setPaneCwd] = useState<string | null>(null);
|
|
||||||
const activeCwd = inPane ? paneCwd : (cwdFromSplat(splat) ?? selected?.cwd ?? null);
|
|
||||||
|
|
||||||
// Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another
|
// Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another
|
||||||
// machine is the entire point, and a shared "current server" would make that impossible to express.
|
// machine is the entire point, and a shared "current server" would make that impossible to express.
|
||||||
@@ -92,20 +87,17 @@ export const SessionList = () => {
|
|||||||
value={serverId}
|
value={serverId}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setServerId(next);
|
setServerId(next);
|
||||||
setPaneCwd(null);
|
|
||||||
// A path and a conversation from the machine you left name nothing on the one you arrived
|
// A path and a conversation from the machine you left name nothing on the one you arrived
|
||||||
// at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`).
|
// at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`).
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
if (!inPane) navigate(chatListPath(null), { replace: true });
|
navigate(chatListPath(null), { replace: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PwdSelector
|
<PwdSelector
|
||||||
serverId={serverId}
|
|
||||||
value={activeCwd}
|
value={activeCwd}
|
||||||
onChange={(cwd) => {
|
onChange={(cwd) => {
|
||||||
setSelected(null); // sessions belong to a cwd — clear the open one when switching
|
setSelected(null); // sessions belong to a cwd — clear the open one when switching
|
||||||
if (inPane) setPaneCwd(cwd);
|
navigate(chatListPath(cwd), { replace: true });
|
||||||
else navigate(chatListPath(cwd), { replace: true });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
@@ -212,7 +204,7 @@ export const SessionList = () => {
|
|||||||
{/* The row is the link and the actions are its siblings — a <button> inside an <a> is
|
{/* The row is the link and the actions are its siblings — a <button> inside an <a> is
|
||||||
not a thing, and nesting them is what breaks cmd-click on half the app's lists. */}
|
not a thing, and nesting them is what breaks cmd-click on half the app's lists. */}
|
||||||
<DataRow
|
<DataRow
|
||||||
to={inPane ? undefined : linkTo(session.id)}
|
to={linkTo(session.id)}
|
||||||
// Stamp the machine onto the selection BEFORE the route changes. The resolver that
|
// Stamp the machine onto the selection BEFORE the route changes. The resolver that
|
||||||
// fetches the transcript reads it from here — without it a remote row would be
|
// fetches the transcript reads it from here — without it a remote row would be
|
||||||
// looked up on this origin, where that id names nothing (or, worse, names something
|
// looked up on this origin, where that id names nothing (or, worse, names something
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ 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';
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { useClient, getHeaders } from 'hooks/useClient';
|
import { useClient, getHeaders } from 'hooks/useClient';
|
||||||
import { useServerClient } from 'hooks/useServerClient';
|
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
/**
|
export const useFilesAPI = (root: string = 'home') => {
|
||||||
* `serverId` names another Officer; absent is this origin, which is every existing caller.
|
const client = useClient();
|
||||||
*
|
|
||||||
* Load-bearing for the chat directory pickers: a path only means something on the machine it came from,
|
|
||||||
* so browsing without it showed THIS server's folders while the pane was pointed at another — the exact
|
|
||||||
* bug the mobile app has with `browseDirectories`.
|
|
||||||
*/
|
|
||||||
export const useFilesAPI = (root: string = 'home', serverId?: string | null) => {
|
|
||||||
const client = useServerClient(serverId);
|
|
||||||
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
|
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
|
||||||
const withRoot = (url: string) =>
|
const withRoot = (url: string) =>
|
||||||
rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url;
|
rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export * from './channels';
|
export * from './channels';
|
||||||
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
||||||
export { usePageTitleOverride, usePublishPageTitle, useChatTabName, usePublishChatTabName } from './page-title';
|
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
||||||
export type { PageTitleOverride } from './page-title';
|
export type { PageTitleOverride } from './page-title';
|
||||||
export * from './AppRegistry';
|
export * from './AppRegistry';
|
||||||
export * from './WidgetRegistry';
|
export * from './WidgetRegistry';
|
||||||
@@ -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, ChatTabs } from './apps/ChatHistory';
|
export { SessionList, ChatDetailPanel } 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';
|
||||||
|
|||||||
@@ -53,39 +53,3 @@ export function usePublishPageTitle(override: PageTitleOverride | null) {
|
|||||||
return () => setOverrideRef.current(null);
|
return () => setOverrideRef.current(null);
|
||||||
}, [id, title]);
|
}, [id, title]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The chat tab's own name, which outranks everything ──
|
|
||||||
//
|
|
||||||
// `/chat` can hold several tabs, each with several conversations. A name you type on a chat tab is the
|
|
||||||
// most specific statement anyone has made about what this page is: more specific than the conversation
|
|
||||||
// showing inside it (there may be three), and more deliberate than a browser-tab name typed earlier for
|
|
||||||
// a different screen.
|
|
||||||
//
|
|
||||||
// So it sits ABOVE both in `usePageTitle`: chatTabName ?? label ?? override ?? route. Clearing it hands
|
|
||||||
// the page back to the chain below, which is the only way out and leaves no third state to get stuck in.
|
|
||||||
const CHAT_TAB_NAME = 'CHAT_TAB_NAME';
|
|
||||||
|
|
||||||
/** The active chat tab's typed name, or null. For the shell. */
|
|
||||||
export const useChatTabName = () => useGlobal<string | null>(CHAT_TAB_NAME, null)[0];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish the active chat tab's name. `null` when it has none, or when leaving /chat.
|
|
||||||
*
|
|
||||||
* Cleared on unmount for the same reason the override is: navigating away leaves nothing to reset it,
|
|
||||||
* and a leftover name is a header calling every other screen by the chat tab you last had open.
|
|
||||||
*/
|
|
||||||
export function usePublishChatTabName(name: string | null) {
|
|
||||||
const [, setName] = useGlobal<string | null>(CHAT_TAB_NAME, null);
|
|
||||||
|
|
||||||
// Through a ref, and depending on the STRING only — exactly as `usePublishPageTitle` does, for the
|
|
||||||
// reason documented there: `useGlobal` rebuilds its setter every render, so naming it as a dependency
|
|
||||||
// re-runs this effect on every render, which sets global state, which renders again. That is React
|
|
||||||
// error #185, and it took the whole page down until the deps were narrowed to the value itself.
|
|
||||||
const setNameRef = useRef(setName);
|
|
||||||
setNameRef.current = setName;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNameRef.current(name);
|
|
||||||
return () => setNameRef.current(null);
|
|
||||||
}, [name]);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user