From 306def14f3042cedabd18cfdaa1bc8baca3c1495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 6 Aug 2026 23:24:24 +0000 Subject: [PATCH] rebuild the chat session list on the shared data primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the list drew its own border per row on top of nothing, so every boundary between two rows was a double hairline, and it emphasised two things per row where the design language allows one. DataRow/DataList settle both. loading and empty were the same grey sentence, which made a slow transcript read look like an account with no history; they are now LoadingBlock and EmptyBlock, and a failed read gets an ErrorBlock with the actual message instead of rendering as "no sessions". rename and delete swallowed their failures whole — useClient only raises a dialog for 401, 403 and 5xx, and the likely error here is a 404 from a transcript that vanished under you. both toast now, as does a deep link to a session that cannot be read, which used to open an empty pane and say nothing. the New Chat button was duck-teal filled with duck-yellow text: duck-teal is a bright cyan in dark mode and duck-yellow has no dark override, so the pair sat near 2:1 contrast in both themes. active-row highlight now comes from the route rather than the selection channel, so it is right on a deep link before any panel has published, and deleting the open session navigates out of it instead of leaving a dead route. deletes SessionBar and SessionContextMenu: the first was exported through two barrels and imported nowhere, the second was never imported at all and typed its session id as a number. Co-Authored-By: Claude Opus 5 --- .../Screens/Dashboard/ChatHistory/index.tsx | 10 +- .../src/apps/ChatHistory/SessionBar.tsx | 52 ---- .../apps/ChatHistory/SessionContextMenu.tsx | 45 --- .../src/apps/ChatHistory/SessionList.tsx | 257 ++++++++++-------- .../officerdev/src/apps/ChatHistory/index.ts | 1 - src/workspaces/officerdev/src/index.ts | 2 +- src/workspaces/state/src/useClaudeSessions.ts | 15 +- 7 files changed, 173 insertions(+), 209 deletions(-) delete mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/SessionBar.tsx delete mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/SessionContextMenu.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 4148fc03..56681142 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router'; import type { LayoutNode, SelectedSession } from 'officerdev'; import { WorkspaceView } from 'officerdev'; +import { toast } from '@/components/ui/sonner'; import { useIsMobile } from 'hooks/useIsMobile'; import { useClient } from 'hooks/useClient'; import { useDashboardState } from 'state/useDashboardState'; @@ -96,8 +97,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { total: detail.total, initialOffset: detail.offset, }); - } catch { - if (!cancelled) setSelected({ id: sessionId }); + } catch (err) { + if (cancelled) return; + // Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and + // no hint that the transcript could not be read, which is indistinguishable from a new session. + // Most often the id is stale — the transcript was deleted or pruned out from under the link. + toast.error(`Could not load this conversation: ${err instanceof Error ? err.message : 'not found'}`); + setSelected({ id: sessionId }); } })(); return () => { diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionBar.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionBar.tsx deleted file mode 100644 index a3390aa5..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionBar.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Link } from 'react-router'; -import { ArrowLeft, Trash2, Maximize2, Minimize2 } from 'lucide-react'; - -type SessionBarProps = { - listPath: string; - sessionTitle: string | undefined; - isConnected: boolean; - isGenerating: boolean; - fullscreen: boolean; - onDelete: () => void; - onToggleFullscreen: () => void; -}; - -export const SessionBar = ({ - listPath, - sessionTitle, - isConnected, - isGenerating, - fullscreen, - onDelete, - onToggleFullscreen, -}: SessionBarProps) => ( -
-
- - - - -
-
- {sessionTitle ?? 'New chat'} -
-
- {!isConnected ? ( - - ) : isGenerating ? ( - - ) : ( - - )} - {!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''} - -
-
-); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionContextMenu.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionContextMenu.tsx deleted file mode 100644 index 95778a2f..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionContextMenu.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useState } from 'react'; -import { MoreVertical, Trash2 } from 'lucide-react'; - -type SessionContextMenuProps = { - sessionId: number; - onDelete: (id: number) => void; -}; - -export function SessionContextMenu({ sessionId, onDelete }: SessionContextMenuProps) { - const [isOpen, setIsOpen] = useState(false); - - function handleButtonClick(e: React.MouseEvent) { - e.stopPropagation(); - setIsOpen(!isOpen); - } - - return ( -
e.stopPropagation()}> - - - {isOpen && ( - <> -
setIsOpen(false)} /> -
- -
- - )} -
- ); -} diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 0883eb2b..fcc1668e 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -1,6 +1,8 @@ import { useRef, useState, useCallback } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router'; +import { useNavigate, useParams, useSearchParams } from 'react-router'; import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X } from 'lucide-react'; +import { toast } from '@/components/ui/sonner'; +import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, RelativeTime } from '@/components/Data'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useClaudeSessions } from 'state/useClaudeSessions'; import type { SelectedSession } from './ChatDetailPanel'; @@ -16,7 +18,10 @@ export const SessionList = () => { // this way, and it survives a refresh. const [searchParams, setSearchParams] = useSearchParams(); const activeCwd = searchParams.get('cwd'); - const { sessions, isLoading, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); + // Which session is open is the URL too. Reading it from the route rather than the selection channel + // means the highlight is correct on a deep link and on a back/forward, before any panel has published. + const { sessionId } = useParams<{ sessionId: string }>(); + const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); const [selected, setSelected] = usePanelChannel('chat:selected-session', null); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); @@ -30,29 +35,44 @@ export const SessionList = () => { } }, []); + const search = searchParams.toString(); + const linkTo = (id: string) => (search ? `/chat/${id}?${search}` : `/chat/${id}`); + const startRename = (id: string, current: string) => { setConfirmingId(null); setEditingId(id); setEditValue(current); }; + // Rename and delete used to swallow their failures whole. `useClient` only raises a dialog for 401, + // 403 and 5xx, so a 404 — the likely one here, since a transcript can vanish from disk under you — + // put the row back exactly as it was and said nothing, which reads as "the rename didn't take". const commitRename = async () => { const id = editingId; const title = editValue.trim(); setEditingId(null); - if (id && title) await renameSession(id, title); + if (!id || !title) return; + try { + await renameSession(id, title); + } catch (err) { + toast.error(`Could not rename the session: ${errorText(err)}`); + } }; const handleDelete = async (id: string) => { - if (selected?.id === id) setSelected(null); setConfirmingId(null); - await deleteSession(id); + try { + await deleteSession(id); + if (selected?.id === id) setSelected(null); + if (sessionId === id) navigate({ pathname: '/chat', search }, { replace: true }); + } catch (err) { + toast.error(`Could not delete the session: ${errorText(err)}`); + } }; return ( -
- {/* Header */} -
+
+
{ @@ -71,8 +91,9 @@ export const SessionList = () => {
@@ -80,9 +101,12 @@ export const SessionList = () => { onClick={() => { setSelected({ id: `new:${Date.now()}` }); // Carry the cwd: a new chat starts in the pwd the list is showing, and that now lives in the URL. - navigate({ pathname: '/chat/new', search: searchParams.toString() }, { replace: true }); + navigate({ pathname: '/chat/new', search }, { replace: true }); }} - className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium" + // Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and + // duck-yellow has no dark override at all, so the pair sat around 2:1 contrast either way — + // brand colours used as a fill they were never legible against. + className="flex h-7 cursor-pointer items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90" > New Chat @@ -90,134 +114,155 @@ export const SessionList = () => {
- {/* Session list */} -
- {sessions.length === 0 && ( -
- {isLoading ? 'Loading…' : 'No sessions yet. Start a new chat to see it here.'} -
- )} +
+ {error ? ( + refetch()} + className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground" + > + Try again + + } + /> + ) : isLoading && sessions.length === 0 ? ( + // Loading and empty used to be the same grey line of text, so a slow read looked like an + // account with no history. + + ) : sessions.length === 0 ? ( + + ) : ( + + {sessions.map((session) => { + const isActive = sessionId === session.id; + const isEditing = editingId === session.id; + const isConfirming = confirmingId === session.id; - {sessions.map((session) => { - const isActive = selected?.id === session.id; - const isEditing = editingId === session.id; - const isConfirming = confirmingId === session.id; - return ( -
- {isEditing ? ( -
- setEditValue(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter') commitRename(); - if (ev.key === 'Escape') setEditingId(null); - }} - onBlur={commitRename} - className="flex-1 min-w-0 bg-transparent border-b border-duck-teal/40 text-sm outline-none" - /> - - -
- ) : ( - <> - - -
-
- {session.title} -
-
- - {new Date(session.updatedAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} + if (isEditing) { + return ( +
+ setEditValue(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') commitRename(); + if (ev.key === 'Escape') setEditingId(null); + }} + onBlur={commitRename} + aria-label="Session title" + className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none" + /> + + +
+ ); + } + + return ( + -
- + ) : ( + `${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}` + ), + ]} + /> {isConfirming ? ( -
- Delete? +
+ Delete?
) : ( -
+ // focus-within, not just hover: keyboard users could never reach these at all. +
)} - - )} -
- ); - })} +
+ ); + })} + + )}
); }; + +/** `useClient` throws `{ status, message }`, not an Error, so `err.message` alone misses the common case. */ +function errorText(err: unknown): string { + if (typeof err === 'object' && err !== null && 'message' in err) { + const message = (err as { message: unknown }).message; + if (typeof message === 'string' && message.trim()) return message.slice(0, 200); + } + return err instanceof Error ? err.message : 'unknown error'; +} diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts index fbce3b09..4e0450f9 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts +++ b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts @@ -3,7 +3,6 @@ import { MessageSquare, List } from 'lucide-react'; import { SessionList } from './SessionList'; import { ChatDetailPanel } from './ChatDetailPanel'; -export { SessionBar } from './SessionBar'; export { SessionList }; export { ChatDetailPanel }; export type { SelectedSession } from './ChatDetailPanel'; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 3812c55e..f2d482c6 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -24,7 +24,7 @@ export { } from './apps/Chat'; export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat'; export * from './apps/Chat/types'; -export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory'; +export { SessionList, ChatDetailPanel } from './apps/ChatHistory'; export type { SelectedSession } from './apps/ChatHistory'; export { CodeEditorView } from './apps/CodeEditor'; // The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL. diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 2248e5cd..63fdd0e7 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -62,7 +62,7 @@ export function useClaudeSessions(cwd?: string | null) { const { isAuthenticated } = useAuth(); const q = cwdQuery(cwd); - const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ + const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ queryKey: [SESSIONS_KEY, cwd ?? 'default'], enabled: isAuthenticated, queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`), @@ -93,5 +93,16 @@ export function useClaudeSessions(cwd?: string | null) { [client, q, invalidate], ); - return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, renameSession, invalidate }; + return { + sessions: data?.sessions ?? [], + isLoading, + // Surfaced so the list can distinguish "no sessions" from "the read failed" — they rendered + // identically before, and the second one is the only one you can act on. + error, + refetch, + loadSession, + deleteSession, + renameSession, + invalidate, + }; }