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 ? (
+
+ ) : (
+