rebuild the chat session list on the shared data primitives
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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'react';
|
|||||||
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
||||||
import type { LayoutNode, SelectedSession } from 'officerdev';
|
import type { LayoutNode, SelectedSession } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { useIsMobile } from 'hooks/useIsMobile';
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
@@ -96,8 +97,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
total: detail.total,
|
total: detail.total,
|
||||||
initialOffset: detail.offset,
|
initialOffset: detail.offset,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
if (!cancelled) setSelected({ id: sessionId });
|
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 () => {
|
return () => {
|
||||||
|
|||||||
@@ -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) => (
|
|
||||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-background/60">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 truncate px-3">
|
|
||||||
{sessionTitle ?? 'New chat'}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50">
|
|
||||||
{!isConnected ? (
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
|
|
||||||
) : isGenerating ? (
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
|
||||||
) : (
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
|
||||||
)}
|
|
||||||
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
|
||||||
<button
|
|
||||||
onClick={onToggleFullscreen}
|
|
||||||
className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -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 (
|
|
||||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
onClick={handleButtonClick}
|
|
||||||
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors opacity-0 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 min-w-[120px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onDelete(sessionId);
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useRef, useState, useCallback } from 'react';
|
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 { 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 { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import type { SelectedSession } from './ChatDetailPanel';
|
import type { SelectedSession } from './ChatDetailPanel';
|
||||||
@@ -16,7 +18,10 @@ export const SessionList = () => {
|
|||||||
// this way, and it survives a refresh.
|
// this way, and it survives a refresh.
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const activeCwd = searchParams.get('cwd');
|
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<SelectedSession>('chat:selected-session', null);
|
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState('');
|
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) => {
|
const startRename = (id: string, current: string) => {
|
||||||
setConfirmingId(null);
|
setConfirmingId(null);
|
||||||
setEditingId(id);
|
setEditingId(id);
|
||||||
setEditValue(current);
|
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 commitRename = async () => {
|
||||||
const id = editingId;
|
const id = editingId;
|
||||||
const title = editValue.trim();
|
const title = editValue.trim();
|
||||||
setEditingId(null);
|
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) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (selected?.id === id) setSelected(null);
|
|
||||||
setConfirmingId(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 (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{/* Header */}
|
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-3 py-2">
|
||||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
|
||||||
<PwdSelector
|
<PwdSelector
|
||||||
value={activeCwd}
|
value={activeCwd}
|
||||||
onChange={(cwd) => {
|
onChange={(cwd) => {
|
||||||
@@ -71,8 +91,9 @@ export const SessionList = () => {
|
|||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="p-1 rounded text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal cursor-pointer transition-colors"
|
className="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
|
aria-label="Refresh session list"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3.5 w-3.5 ${isLoading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-3.5 w-3.5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -80,9 +101,12 @@ export const SessionList = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected({ id: `new:${Date.now()}` });
|
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.
|
// 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"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
New Chat
|
New Chat
|
||||||
@@ -90,134 +114,155 @@ export const SessionList = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Session list */}
|
<div className="min-h-0 flex-1">
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
|
{error ? (
|
||||||
{sessions.length === 0 && (
|
<ErrorBlock
|
||||||
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
|
title="Could not load sessions"
|
||||||
{isLoading ? 'Loading…' : 'No sessions yet. Start a new chat to see it here.'}
|
message={errorText(error)}
|
||||||
</div>
|
action={
|
||||||
)}
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : 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.
|
||||||
|
<LoadingBlock label="Reading transcripts…" />
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<EmptyBlock
|
||||||
|
icon={MessageSquare}
|
||||||
|
title="No sessions yet"
|
||||||
|
hint="Conversations started in this folder show up here. Start one with New Chat."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataList>
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const isActive = sessionId === session.id;
|
||||||
|
const isEditing = editingId === session.id;
|
||||||
|
const isConfirming = confirmingId === session.id;
|
||||||
|
|
||||||
{sessions.map((session) => {
|
if (isEditing) {
|
||||||
const isActive = selected?.id === session.id;
|
return (
|
||||||
const isEditing = editingId === session.id;
|
<div key={session.id} className="flex min-w-0 items-center gap-2 px-4 py-3">
|
||||||
const isConfirming = confirmingId === session.id;
|
<input
|
||||||
return (
|
autoFocus
|
||||||
<div
|
value={editValue}
|
||||||
key={session.id}
|
onChange={(ev) => setEditValue(ev.target.value)}
|
||||||
ref={isActive ? selectedRef : undefined}
|
onKeyDown={(ev) => {
|
||||||
className={`group flex items-center rounded-lg border transition-colors ${
|
if (ev.key === 'Enter') commitRename();
|
||||||
isActive
|
if (ev.key === 'Escape') setEditingId(null);
|
||||||
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
|
}}
|
||||||
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
|
onBlur={commitRename}
|
||||||
}`}
|
aria-label="Session title"
|
||||||
>
|
className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none"
|
||||||
{isEditing ? (
|
/>
|
||||||
<div className="flex flex-1 items-center gap-2 px-4 py-3 min-w-0">
|
<button
|
||||||
<input
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
autoFocus
|
onClick={commitRename}
|
||||||
value={editValue}
|
className="cursor-pointer p-1 text-success hover:opacity-80"
|
||||||
onChange={(ev) => setEditValue(ev.target.value)}
|
title="Save"
|
||||||
onKeyDown={(ev) => {
|
aria-label="Save title"
|
||||||
if (ev.key === 'Enter') commitRename();
|
>
|
||||||
if (ev.key === 'Escape') setEditingId(null);
|
<Check className="h-3.5 w-3.5" />
|
||||||
}}
|
</button>
|
||||||
onBlur={commitRename}
|
<button
|
||||||
className="flex-1 min-w-0 bg-transparent border-b border-duck-teal/40 text-sm outline-none"
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
/>
|
onClick={() => setEditingId(null)}
|
||||||
<button
|
className="cursor-pointer p-1 opacity-50 hover:opacity-100"
|
||||||
onMouseDown={(ev) => ev.preventDefault()}
|
title="Cancel"
|
||||||
onClick={commitRename}
|
aria-label="Cancel rename"
|
||||||
className="p-1 text-duck-teal hover:opacity-80 cursor-pointer"
|
>
|
||||||
title="Save"
|
<X className="h-3.5 w-3.5" />
|
||||||
>
|
</button>
|
||||||
<Check className="h-3.5 w-3.5" />
|
</div>
|
||||||
</button>
|
);
|
||||||
<button
|
}
|
||||||
onMouseDown={(ev) => ev.preventDefault()}
|
|
||||||
onClick={() => setEditingId(null)}
|
return (
|
||||||
className="p-1 opacity-50 hover:opacity-100 cursor-pointer"
|
<div
|
||||||
title="Cancel"
|
key={session.id}
|
||||||
>
|
ref={isActive ? selectedRef : undefined}
|
||||||
<X className="h-3.5 w-3.5" />
|
className={`group flex items-center ${isActive ? 'bg-muted' : ''}`}
|
||||||
</button>
|
>
|
||||||
</div>
|
{/* 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. */}
|
||||||
<>
|
<DataRow
|
||||||
<Link
|
to={linkTo(session.id)}
|
||||||
to={{ pathname: `/chat/${session.id}`, search: searchParams.toString() }}
|
title={session.title}
|
||||||
className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
|
selected={isActive}
|
||||||
>
|
className="min-w-0 flex-1"
|
||||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
meta={[
|
||||||
<div className="min-w-0 flex-1">
|
<RelativeTime key="t" value={session.updatedAt} />,
|
||||||
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
|
session.harness === 'opencode' ? (
|
||||||
{session.title}
|
<span key="h" className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">
|
||||||
</div>
|
OpenCode
|
||||||
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
|
|
||||||
<span>
|
|
||||||
{new Date(session.updatedAt).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
<span>·</span>
|
) : (
|
||||||
{session.harness === 'opencode' ? (
|
`${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`
|
||||||
<span className="rounded bg-duck-teal/10 px-1.5 py-0.5 font-medium text-duck-teal">
|
),
|
||||||
OpenCode
|
]}
|
||||||
</span>
|
/>
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isConfirming ? (
|
{isConfirming ? (
|
||||||
<div className="flex shrink-0 items-center gap-1 mr-2">
|
<div className="mr-2 flex shrink-0 items-center gap-1">
|
||||||
<span className="text-xs text-red-500">Delete?</span>
|
<span className="text-xs text-destructive">Delete?</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(session.id)}
|
onClick={() => handleDelete(session.id)}
|
||||||
className="p-1 rounded text-red-500 hover:bg-red-500/10 cursor-pointer"
|
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
|
||||||
title="Confirm delete"
|
title="Confirm delete"
|
||||||
|
aria-label={`Confirm deleting ${session.title}`}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmingId(null)}
|
onClick={() => setConfirmingId(null)}
|
||||||
className="p-1 rounded opacity-50 hover:opacity-100 cursor-pointer"
|
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
|
aria-label="Cancel delete"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex shrink-0 items-center mr-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
// focus-within, not just hover: keyboard users could never reach these at all.
|
||||||
|
<div className="mr-2 flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={() => startRename(session.id, session.title)}
|
onClick={() => startRename(session.id, session.title)}
|
||||||
className="p-1.5 rounded text-duck-dark/30 dark:text-foreground/30 hover:text-duck-teal cursor-pointer transition-colors"
|
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||||
title="Rename"
|
title="Rename"
|
||||||
|
aria-label={`Rename ${session.title}`}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmingId(session.id)}
|
onClick={() => setConfirmingId(session.id)}
|
||||||
className="p-1.5 rounded text-duck-dark/30 dark:text-foreground/30 hover:text-red-500 cursor-pointer transition-colors"
|
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-destructive"
|
||||||
title="Delete session"
|
title="Delete session"
|
||||||
|
aria-label={`Delete ${session.title}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</DataList>
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** `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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { MessageSquare, List } from 'lucide-react';
|
|||||||
import { SessionList } from './SessionList';
|
import { SessionList } from './SessionList';
|
||||||
import { ChatDetailPanel } from './ChatDetailPanel';
|
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||||
|
|
||||||
export { SessionBar } from './SessionBar';
|
|
||||||
export { SessionList };
|
export { SessionList };
|
||||||
export { ChatDetailPanel };
|
export { ChatDetailPanel };
|
||||||
export type { SelectedSession } from './ChatDetailPanel';
|
export type { SelectedSession } from './ChatDetailPanel';
|
||||||
|
|||||||
@@ -24,7 +24,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 { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
||||||
export type { SelectedSession } from './apps/ChatHistory';
|
export type { SelectedSession } from './apps/ChatHistory';
|
||||||
export { CodeEditorView } from './apps/CodeEditor';
|
export { CodeEditorView } from './apps/CodeEditor';
|
||||||
// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
|
// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function useClaudeSessions(cwd?: string | null) {
|
|||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const q = cwdQuery(cwd);
|
const q = cwdQuery(cwd);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
|
const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
|
||||||
queryKey: [SESSIONS_KEY, cwd ?? 'default'],
|
queryKey: [SESSIONS_KEY, cwd ?? 'default'],
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
|
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
|
||||||
@@ -93,5 +93,16 @@ export function useClaudeSessions(cwd?: string | null) {
|
|||||||
[client, q, invalidate],
|
[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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user