chat history panel keyed (344 -> 283)

Three module-level tab/option tables carried labels, so they carry labelKey now and
the components resolve them.

The hidden-subfolder hint hand-rolled a plural with a ternary ("one is" / "ones are"),
which is a real plural entry now — verified at 0, 1 and 2 in both locales.

OpenCode and Claude are product names, wrapped rather than keyed.

Scanner: the jsx-text pattern now rejects a preceding `=`, so an arrow return type is
not read as prose — `(id: string) => Promise<unknown>` was being reported as the string
"Promise", seven times across the panels. Verified the delta is exactly those seven.
This commit is contained in:
2026-08-23 15:11:34 +00:00
parent 04c1a6bfc9
commit 1c8e943667
9 changed files with 215 additions and 66 deletions
+4 -1
View File
@@ -52,7 +52,10 @@ const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
*/ */
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [ const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
// >Some visible words< // >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g }, //
// The lookbehind rejects `=>`, so an arrow return type does not read as JSX text: `(id: string) =>
// Promise<unknown>` was reported as the string "Promise".
{ kind: 'jsx text', rx: /(?<!=)>\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends // placeholder="..." and friends
{ {
kind: 'attributes', kind: 'attributes',
@@ -12,6 +12,7 @@ import { useClaudeSessions } from 'state/useClaudeSessions';
import type { NotifyPreference } from 'state/useClaudeSessions'; import type { NotifyPreference } from 'state/useClaudeSessions';
import { useChat, EmbeddableChat } from '../Chat'; import { useChat, EmbeddableChat } from '../Chat';
import type { ChatMessage } from '../Chat/types'; import type { ChatMessage } from '../Chat/types';
import { useTranslation } from '../../locales';
export type SelectedSession = { export type SelectedSession = {
id: string; id: string;
@@ -71,6 +72,7 @@ function DetailBar({
onDisconnect, onDisconnect,
notifyPreference, notifyPreference,
}: DetailBarProps) { }: DetailBarProps) {
const { t } = useTranslation();
// The id to rename comes from the URL, not from `resumeSessionId`. They are the same for an ordinary // The id to rename comes from the URL, not from `resumeSessionId`. They are the same for an ordinary
// conversation and not for a merged `/clear` chain, where the resume target is the tail transcript // conversation and not for a merged `/clear` chain, where the resume target is the tail transcript
// while the list, the links and the server all address the chain by its head. Renaming the tail would // while the list, the links and the server all address the chain by its head. Renaming the tail would
@@ -115,11 +117,11 @@ function DetailBar({
</div> </div>
<div className="flex-1 min-w-0 px-3 text-center"> <div className="flex-1 min-w-0 px-3 text-center">
<EditableTitle <EditableTitle
value={sessionTitle ?? 'New chat'} value={sessionTitle ?? t('chatHistory.newChatTitle')}
onCommit={commitRename} onCommit={commitRename}
readOnly={!sessionId} readOnly={!sessionId}
ariaLabel="Session title" ariaLabel={t('chatHistory.sessionTitle')}
hint="Click to rename this chat" hint={t('chatHistory.renameHint')}
className="block w-full cursor-text truncate text-sm font-medium text-foreground/80 transition-opacity hover:opacity-70" className="block w-full cursor-text truncate text-sm font-medium text-foreground/80 transition-opacity hover:opacity-70"
inputClassName="w-full border-b border-primary/40 bg-transparent text-center text-sm font-medium text-foreground/80 outline-none" inputClassName="w-full border-b border-primary/40 bg-transparent text-center text-sm font-medium text-foreground/80 outline-none"
/> />
@@ -142,7 +144,7 @@ function DetailBar({
<button <button
type="button" type="button"
onClick={onDisconnect} onClick={onDisconnect}
title="End session — kills any running turn and releases it so it can be resumed elsewhere" title={t('chatHistory.endSession')}
className="ml-1 p-1 rounded hover:bg-muted hover:text-destructive transition-colors cursor-pointer" className="ml-1 p-1 rounded hover:bg-muted hover:text-destructive transition-colors cursor-pointer"
> >
<Unplug className="h-3.5 w-3.5" /> <Unplug className="h-3.5 w-3.5" />
@@ -177,6 +179,7 @@ type NewChatProps = {
}; };
function NewChat(props: NewChatProps) { function NewChat(props: NewChatProps) {
const { t } = useTranslation();
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, planMode } = props; const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, planMode } = props;
const location = useLocation(); const location = useLocation();
const locationState = location.state as ChatLocationState; const locationState = location.state as ChatLocationState;
@@ -254,6 +257,7 @@ function NewChat(props: NewChatProps) {
} }
export const ChatDetailPanel = () => { export const ChatDetailPanel = () => {
const { t } = useTranslation();
const [selected] = useSelectedChatSession(); 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
@@ -284,7 +288,7 @@ export const ChatDetailPanel = () => {
if (!selected) { if (!selected) {
return ( return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm"> <div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Select a session to view {t('chatHistory.selectSession')}
</div> </div>
); );
} }
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { Folder, ChevronRight, Loader2, FolderPlus, Check, Eye, EyeOff } from 'lucide-react'; import { Folder, ChevronRight, Loader2, FolderPlus, Check, Eye, EyeOff } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI'; import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { useTranslation } from '../../locales';
type DirPickerModalProps = { type DirPickerModalProps = {
open: boolean; open: boolean;
@@ -13,6 +14,7 @@ type DirPickerModalProps = {
// 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 }: DirPickerModalProps) => { export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps) => {
const { t, tUi } = useTranslation();
const api = useFilesAPI('home'); 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);
@@ -54,7 +56,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 p-0"> <DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 p-0">
<DialogHeader className="border-b border-border px-4 py-3"> <DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle className="text-sm">Choose a working directory</DialogTitle> <DialogTitle className="text-sm">{t('chatHistory.chooseDir')}</DialogTitle>
</DialogHeader> </DialogHeader>
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -82,9 +84,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
// Say when the folder isn't actually empty, just filtered — "No subfolders here" over a // Say when the folder isn't actually empty, just filtered — "No subfolders here" over a
// directory holding thirty dot-folders is a lie the toggle below can't help you spot. // directory holding thirty dot-folders is a lie the toggle below can't help you spot.
<div className="flex h-full items-center justify-center px-4 text-center text-sm opacity-40"> <div className="flex h-full items-center justify-center px-4 text-center text-sm opacity-40">
{hiddenCount > 0 {hiddenCount > 0 ? t('chatHistory.noSubfolders', { count: hiddenCount }) : 'No subfolders here'}
? `No visible subfolders — ${hiddenCount} hidden ${hiddenCount === 1 ? 'one is' : 'ones are'} filtered out`
: 'No subfolders here'}
</div> </div>
) : ( ) : (
dirs.map((d) => ( dirs.map((d) => (
@@ -113,14 +113,14 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
onKeyDown={(ev) => { onKeyDown={(ev) => {
if (ev.key === 'Escape') setCreating(false); if (ev.key === 'Escape') setCreating(false);
}} }}
placeholder="New folder name" placeholder={t('chatHistory.newFolderName')}
className="min-w-0 flex-1 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none" className="min-w-0 flex-1 rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
/> />
<button <button
type="submit" type="submit"
className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer" className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer"
> >
Create {t('chatHistory.create')}
</button> </button>
</form> </form>
) : ( ) : (
@@ -129,7 +129,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
onClick={() => setCreating(true)} onClick={() => setCreating(true)}
className="flex items-center gap-1.5 text-xs opacity-60 hover:opacity-100 cursor-pointer" className="flex items-center gap-1.5 text-xs opacity-60 hover:opacity-100 cursor-pointer"
> >
<FolderPlus className="h-3.5 w-3.5" /> New folder <FolderPlus className="h-3.5 w-3.5" /> {t('chatHistory.newFolder')}
</button> </button>
{/* Shown even at zero, so the control's state is always legible: a folder with nothing {/* Shown even at zero, so the control's state is always legible: a folder with nothing
hidden and one with everything hidden must not look identical. */} hidden and one with everything hidden must not look identical. */}
@@ -139,7 +139,11 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
className="flex items-center gap-1.5 text-xs opacity-60 hover:opacity-100 cursor-pointer" className="flex items-center gap-1.5 text-xs opacity-60 hover:opacity-100 cursor-pointer"
> >
{showHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />} {showHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{showHidden ? 'Hide hidden' : `Show hidden${hiddenCount > 0 ? ` (${hiddenCount})` : ''}`} {showHidden
? t('chatHistory.hideHidden')
: hiddenCount > 0
? t('chatHistory.showHiddenCount', { count: hiddenCount })
: t('chatHistory.showHidden')}
</button> </button>
</div> </div>
)} )}
@@ -151,7 +155,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
onClick={onClose} onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer" className="rounded-md px-3 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
> >
Cancel {tUi('button.cancel')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -161,7 +165,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
disabled={!absCurrent} disabled={!absCurrent}
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-3 py-1.5 text-sm font-medium text-duck-yellow hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer" className="flex items-center gap-1.5 rounded-md bg-duck-teal px-3 py-1.5 text-sm font-medium text-duck-yellow hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
> >
<Check className="h-3.5 w-3.5" /> Select this folder <Check className="h-3.5 w-3.5" /> {t('chatHistory.selectFolder')}
</button> </button>
</div> </div>
</div> </div>
@@ -1,13 +1,15 @@
import { Bell, BellOff, Check } from 'lucide-react'; import { Bell, BellOff, Check } from 'lucide-react';
import * as Dropdown from '@/components/ui/dropdown-menu'; import * as Dropdown from '@/components/ui/dropdown-menu';
import type { NotifyPreference } from 'state/useClaudeSessions'; import type { NotifyPreference } from 'state/useClaudeSessions';
import type { PanelTranslationKey } from '../../locales';
import { useTranslation } from '../../locales';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown; const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
const OPTIONS: { value: NotifyPreference; label: string }[] = [ const OPTIONS: { value: NotifyPreference; labelKey: PanelTranslationKey }[] = [
{ value: 'every_turn', label: 'Every end of turn' }, { value: 'every_turn', labelKey: 'chatHistory.notify.everyTurn' },
{ value: 'input_needed', label: 'Only when input is needed' }, { value: 'input_needed', labelKey: 'chatHistory.notify.inputNeeded' },
{ value: 'none', label: 'None' }, { value: 'none', labelKey: 'chatHistory.notify.none' },
]; ];
/** /**
@@ -40,6 +42,7 @@ type NotifyMenuProps = {
* variant, which nothing here uses. * variant, which nothing here uses.
*/ */
export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => { export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
const { t } = useTranslation();
const pick = (value: NotifyPreference) => { const pick = (value: NotifyPreference) => {
onChange(value); onChange(value);
// Lazy, first-opt-in request — the only place this fires. A denial or an already-decided answer is a // Lazy, first-opt-in request — the only place this fires. A denial or an already-decided answer is a
@@ -55,8 +58,10 @@ export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
title={`Notifications: ${OPTIONS.find((o) => o.value === preference)?.label ?? 'None'}`} title={t('chatHistory.notificationsLabel', {
aria-label="Notification preference" choice: t(OPTIONS.find((o) => o.value === preference)?.labelKey ?? 'chatHistory.notify.none'),
})}
aria-label={t('chatHistory.notifyPreference')}
className={`rounded p-1 transition-colors hover:bg-muted cursor-pointer ${BELL_TONE[preference]}`} className={`rounded p-1 transition-colors hover:bg-muted cursor-pointer ${BELL_TONE[preference]}`}
> >
{preference === 'none' ? <BellOff className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5" />} {preference === 'none' ? <BellOff className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5" />}
@@ -66,7 +71,7 @@ export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
{OPTIONS.map((opt) => ( {OPTIONS.map((opt) => (
<DropdownMenuItem key={opt.value} className="cursor-pointer" onClick={() => pick(opt.value)}> <DropdownMenuItem key={opt.value} className="cursor-pointer" onClick={() => pick(opt.value)}>
<Check className={`mr-2 h-4 w-4 ${opt.value === preference ? 'opacity-100' : 'opacity-0'}`} /> <Check className={`mr-2 h-4 w-4 ${opt.value === preference ? 'opacity-100' : 'opacity-0'}`} />
{opt.label} {t(opt.labelKey)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -13,6 +13,8 @@ import { chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
import { DirPickerModal } from './DirPickerModal'; import { DirPickerModal } from './DirPickerModal';
import { LiveMarker, SessionRow, shortenPath, useScrollActiveIntoView, useSessionRowActions } from './SessionRow'; import { LiveMarker, SessionRow, shortenPath, useScrollActiveIntoView, useSessionRowActions } from './SessionRow';
import { SessionTree } from './SessionTree'; import { SessionTree } from './SessionTree';
import type { PanelTranslationKey } from '../../locales';
import { useTranslation } from '../../locales';
/* /*
The /chat sidebar: one panel, three views. The /chat sidebar: one panel, three views.
@@ -30,10 +32,10 @@ import { SessionTree } from './SessionTree';
type View = 'tree' | 'latest' | 'live'; type View = 'tree' | 'latest' | 'live';
const TABS: { value: View; label: string }[] = [ const TABS: { value: View; labelKey: PanelTranslationKey }[] = [
{ value: 'tree', label: 'Tree' }, { value: 'tree', labelKey: 'chatHistory.tabs.tree' },
{ value: 'latest', label: 'Latest' }, { value: 'latest', labelKey: 'chatHistory.tabs.latest' },
{ value: 'live', label: 'Live' }, { value: 'live', labelKey: 'chatHistory.tabs.live' },
]; ];
/* /*
@@ -52,6 +54,7 @@ const LIVE_KIND_KEY = 'chat.sessions.liveKind';
const EXPANDED_KEY = 'chat.sessions.expanded'; const EXPANDED_KEY = 'chat.sessions.expanded';
export const SessionList = () => { export const SessionList = () => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
// Which session is open, and which project group we're browsing, both come from the route. Reading them // Which session is open, and which project group we're browsing, both come from the route. Reading them
// there rather than from the selection channel means the highlight and the group are correct on a deep // there rather than from the selection channel means the highlight and the group are correct on a deep
@@ -157,7 +160,7 @@ export const SessionList = () => {
value={tab.value} value={tab.value}
className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm" className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
> >
{tab.label} {t(tab.labelKey)}
{/* The count that decides whether Live is worth a click. Only when non-zero — a permanent {/* The count that decides whether Live is worth a click. Only when non-zero — a permanent
`0` is the same as no information. */} `0` is the same as no information. */}
{tab.value === 'live' && liveSessions.length > 0 && ( {tab.value === 'live' && liveSessions.length > 0 && (
@@ -173,8 +176,8 @@ export const SessionList = () => {
<button <button
onClick={refresh} onClick={refresh}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground" className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
title="Refresh" title={t('chatHistory.refresh')}
aria-label="Refresh sessions" aria-label={t('chatHistory.refreshSessions')}
> >
<RefreshCw className={`h-3.5 w-3.5 ${latest.isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-3.5 w-3.5 ${latest.isLoading ? 'animate-spin' : ''}`} />
</button> </button>
@@ -184,10 +187,10 @@ export const SessionList = () => {
<button <button
onClick={() => setBrowsing(true)} onClick={() => setBrowsing(true)}
className="flex cursor-pointer items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90" className="flex cursor-pointer items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
title="New chat in a directory…" title={t('chatHistory.newChatInDir')}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
New Chat {t('chatHistory.newChat')}
</button> </button>
</div> </div>
</div> </div>
@@ -209,25 +212,25 @@ export const SessionList = () => {
<> <>
{latest.error ? ( {latest.error ? (
<ErrorBlock <ErrorBlock
title="Could not load sessions" title={t('chatHistory.loadFailed')}
message={errorText(latest.error)} message={errorText(latest.error)}
action={ action={
<button <button
onClick={() => void latest.refetch()} onClick={() => void latest.refetch()}
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground" className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
> >
Try again {t('chatHistory.tryAgain')}
</button> </button>
} }
/> />
) : latest.isLoading && latest.sessions.length === 0 ? ( ) : latest.isLoading && latest.sessions.length === 0 ? (
// Loading and empty used to be the same grey line of text, so a slow read looked like an // Loading and empty used to be the same grey line of text, so a slow read looked like an
// account with no history. // account with no history.
<LoadingBlock label="Reading transcripts…" /> <LoadingBlock label={t('chatHistory.reading')} />
) : latest.sessions.length === 0 ? ( ) : latest.sessions.length === 0 ? (
<EmptyBlock <EmptyBlock
icon={MessageSquare} icon={MessageSquare}
title="No sessions yet" title={t('chatHistory.noSessions')}
hint="Every conversation you have had shows up here, newest first. Start one with New Chat." hint="Every conversation you have had shows up here, newest first. Start one with New Chat."
/> />
) : ( ) : (
@@ -277,12 +280,13 @@ export const SessionList = () => {
*/ */
type LiveKind = 'agents' | 'commanders'; type LiveKind = 'agents' | 'commanders';
const LIVE_TABS: { value: LiveKind; label: string }[] = [ const LIVE_TABS: { value: LiveKind; labelKey: PanelTranslationKey }[] = [
{ value: 'agents', label: 'Agents' }, { value: 'agents', labelKey: 'chatHistory.tabs.agents' },
{ value: 'commanders', label: 'Commanders' }, { value: 'commanders', labelKey: 'chatHistory.tabs.commanders' },
]; ];
const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => { const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => {
const { t } = useTranslation();
const { live: sessions, isLoading, error, refetch } = useLiveSessions(); const { live: sessions, isLoading, error, refetch } = useLiveSessions();
const [kind, setKind] = useLocalStorageState<LiveKind>(LIVE_KIND_KEY, 'agents'); const [kind, setKind] = useLocalStorageState<LiveKind>(LIVE_KIND_KEY, 'agents');
@@ -297,20 +301,20 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }
if (error) { if (error) {
return ( return (
<ErrorBlock <ErrorBlock
title="Could not read live sessions" title={t('chatHistory.liveFailed')}
message={errorText(error)} message={errorText(error)}
action={ action={
<button <button
onClick={() => void refetch()} onClick={() => void refetch()}
className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground" className="cursor-pointer rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground"
> >
Try again {t('chatHistory.tryAgain')}
</button> </button>
} }
/> />
); );
} }
if (isLoading && sessions.length === 0) return <LoadingBlock label="Asking the agent…" />; if (isLoading && sessions.length === 0) return <LoadingBlock label={t('chatHistory.asking')} />;
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
@@ -328,7 +332,7 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }
value={tab.value} value={tab.value}
className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm" className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
> >
{tab.label} {t(tab.labelKey)}
<span className={`ml-1 tabular-nums ${count > 0 ? 'text-success' : 'opacity-50'}`}>({count})</span> <span className={`ml-1 tabular-nums ${count > 0 ? 'text-success' : 'opacity-50'}`}>({count})</span>
</TabsTrigger> </TabsTrigger>
); );
@@ -5,6 +5,8 @@ import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
import { errorText } from 'helpers/error-text'; import { errorText } from 'helpers/error-text';
import type { ClaudeSessionSummary, NotifyPreference } from 'state/useClaudeSessions'; import type { ClaudeSessionSummary, NotifyPreference } from 'state/useClaudeSessions';
import { chatSessionPath } from './chat-routes'; import { chatSessionPath } from './chat-routes';
import { useTranslation } from '../../locales';
import { Untranslated } from '@/components/Untranslated';
/* /*
One conversation, rendered the same way in all three tabs. One conversation, rendered the same way in all three tabs.
@@ -26,9 +28,13 @@ import { chatSessionPath } from './chat-routes';
*/ */
export const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) => export const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) =>
harness === 'opencode' ? ( harness === 'opencode' ? (
<span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">OpenCode</span> <span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">
<Untranslated reason="product-name">OpenCode</Untranslated>
</span>
) : ( ) : (
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">Claude</span> <span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">
<Untranslated reason="product-name">Claude</Untranslated>
</span>
); );
/** /**
@@ -113,6 +119,7 @@ export function useSessionRowActions(actions: {
renameSession: (id: string, title: string) => Promise<unknown>; renameSession: (id: string, title: string) => Promise<unknown>;
deleteSession: (id: string) => Promise<unknown>; deleteSession: (id: string) => Promise<unknown>;
}) { }) {
const { t } = useTranslation();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(null); const [confirmingId, setConfirmingId] = useState<string | null>(null);
@@ -134,7 +141,7 @@ export function useSessionRowActions(actions: {
try { try {
await actions.renameSession(id, title); await actions.renameSession(id, title);
} catch (err) { } catch (err) {
toast.error(`Could not rename the session: ${errorText(err)}`); toast.error(t('chatHistory.renameFailed', { reason: errorText(err) }));
} }
}, [editingId, editValue, actions]); }, [editingId, editValue, actions]);
@@ -144,7 +151,7 @@ export function useSessionRowActions(actions: {
try { try {
await actions.deleteSession(id); await actions.deleteSession(id);
} catch (err) { } catch (err) {
toast.error(`Could not delete the session: ${errorText(err)}`); toast.error(t('chatHistory.deleteFailed', { reason: errorText(err) }));
} }
}, },
[actions], [actions],
@@ -188,6 +195,7 @@ export const SessionRow = ({
actions, actions,
activeRef, activeRef,
}: SessionRowProps) => { }: SessionRowProps) => {
const { t, tUi } = useTranslation();
const isActive = activeId === session.id; const isActive = activeId === session.id;
const isEditing = actions.editingId === session.id; const isEditing = actions.editingId === session.id;
const isConfirming = actions.confirmingId === session.id; const isConfirming = actions.confirmingId === session.id;
@@ -209,15 +217,15 @@ export const SessionRow = ({
if (ev.key === 'Escape') actions.setEditingId(null); if (ev.key === 'Escape') actions.setEditingId(null);
}} }}
onBlur={() => void actions.commitRename()} onBlur={() => void actions.commitRename()}
aria-label="Session title" aria-label={t('chatHistory.sessionTitle')}
className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none" className="min-w-0 flex-1 border-b border-primary/40 bg-transparent text-sm outline-none"
/> />
<button <button
type="submit" type="submit"
onMouseDown={(ev) => ev.preventDefault()} onMouseDown={(ev) => ev.preventDefault()}
className="cursor-pointer p-1 text-success hover:opacity-80" className="cursor-pointer p-1 text-success hover:opacity-80"
title="Save" title={t('common.save')}
aria-label="Save title" aria-label={t('chatHistory.saveTitle')}
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
</button> </button>
@@ -226,8 +234,8 @@ export const SessionRow = ({
onMouseDown={(ev) => ev.preventDefault()} onMouseDown={(ev) => ev.preventDefault()}
onClick={() => actions.setEditingId(null)} onClick={() => actions.setEditingId(null)}
className="cursor-pointer p-1 opacity-50 hover:opacity-100" className="cursor-pointer p-1 opacity-50 hover:opacity-100"
title="Cancel" title={tUi('button.cancel')}
aria-label="Cancel rename" aria-label={t('chatHistory.cancelRename')}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -279,21 +287,23 @@ export const SessionRow = ({
{/* Say the size of it. Deleting a merged chain removes every part, and a row that looks like one {/* Say the size of it. Deleting a merged chain removes every part, and a row that looks like one
conversation giving no warning that it is three files is exactly the surprise this prevents. */} conversation giving no warning that it is three files is exactly the surprise this prevents. */}
<span className="text-xs text-destructive"> <span className="text-xs text-destructive">
{session.partCount && session.partCount > 1 ? `Delete all ${session.partCount}?` : 'Delete?'} {session.partCount && session.partCount > 1
? t('chatHistory.deleteAll', { count: session.partCount })
: 'Delete?'}
</span> </span>
<button <button
onClick={() => void actions.handleDelete(session.id)} onClick={() => void actions.handleDelete(session.id)}
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10" className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
title="Confirm delete" title={t('chatHistory.confirmDelete')}
aria-label={`Confirm deleting ${session.title}`} aria-label={t('chatHistory.confirmDeletingNamed', { name: session.title })}
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
</button> </button>
<button <button
onClick={() => actions.setConfirmingId(null)} onClick={() => actions.setConfirmingId(null)}
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100" className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
title="Cancel" title={tUi('button.cancel')}
aria-label="Cancel delete" aria-label={t('chatHistory.cancelDelete')}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -304,16 +314,16 @@ export const SessionRow = ({
<button <button
onClick={() => actions.startRename(session.id, session.title)} onClick={() => actions.startRename(session.id, session.title)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground" className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
title="Rename" title={t('chatHistory.rename')}
aria-label={`Rename ${session.title}`} aria-label={t('chatHistory.renameNamed', { name: session.title })}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
<button <button
onClick={() => actions.setConfirmingId(session.id)} onClick={() => actions.setConfirmingId(session.id)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-destructive" className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-destructive"
title="Delete session" title={t('chatHistory.deleteSession')}
aria-label={`Delete ${session.title}`} aria-label={t('chatHistory.deleteNamed', { name: session.title })}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -7,6 +7,7 @@ import type { LiveSession } from 'state/useLiveSessions';
import { chatNewPath } from './chat-routes'; import { chatNewPath } from './chat-routes';
import { SessionRow, shortenPath, type SessionRowActions, useScrollActiveIntoView } from './SessionRow'; import { SessionRow, shortenPath, type SessionRowActions, useScrollActiveIntoView } from './SessionRow';
import { useSelectedChatSession } from '../../channels'; import { useSelectedChatSession } from '../../channels';
import { useTranslation } from '../../locales';
/* /*
Directories as an accordion, each expanding to its own conversations. Directories as an accordion, each expanding to its own conversations.
@@ -36,6 +37,7 @@ type DirectoryNodeProps = {
}; };
const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions }: DirectoryNodeProps) => { const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions }: DirectoryNodeProps) => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [, setSelected] = useSelectedChatSession(); const [, setSelected] = useSelectedChatSession();
const activeRef = useScrollActiveIntoView(); const activeRef = useScrollActiveIntoView();
@@ -104,8 +106,8 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions
<button <button
type="button" type="button"
onClick={startHere} onClick={startHere}
title={`New chat in ${shortenPath(pwd.cwd, home)}`} title={t('chatHistory.newChatIn', { path: shortenPath(pwd.cwd, home) })}
aria-label={`New chat in ${shortenPath(pwd.cwd, home)}`} aria-label={t('chatHistory.newChatIn', { path: shortenPath(pwd.cwd, home) })}
className="mr-2 shrink-0 cursor-pointer rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/dir:opacity-100" className="mr-2 shrink-0 cursor-pointer rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/dir:opacity-100"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@@ -123,11 +125,11 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions
{expanded && ( {expanded && (
<div className="bg-background/40 py-2 pl-8"> <div className="bg-background/40 py-2 pl-8">
{isLoading && sessions.length === 0 ? ( {isLoading && sessions.length === 0 ? (
<LoadingBlock label="Reading transcripts…" /> <LoadingBlock label={t('chatHistory.reading')} />
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
// Reachable: `/chat/pwds` counts transcripts per group, and a group can hold only files this // Reachable: `/chat/pwds` counts transcripts per group, and a group can hold only files this
// reader skips. Better than an accordion that opens onto nothing. // reader skips. Better than an accordion that opens onto nothing.
<div className="py-3 text-xs text-muted-foreground">No readable conversations here.</div> <div className="py-3 text-xs text-muted-foreground">{t('chatHistory.noReadable')}</div>
) : ( ) : (
<DataList className="!h-auto !overflow-visible"> <DataList className="!h-auto !overflow-visible">
{sessions.map((session) => ( {sessions.map((session) => (
@@ -167,11 +169,12 @@ export const SessionTree = ({
activeId, activeId,
actions, actions,
}: SessionTreeProps) => { }: SessionTreeProps) => {
const { t } = useTranslation();
if (!pwds.length) { if (!pwds.length) {
return ( return (
<EmptyBlock <EmptyBlock
icon={MessageSquare} icon={MessageSquare}
title="No conversations yet" title={t('chatHistory.noConversations')}
hint="Every directory you have run a chat in shows up here. Start one with New Chat." hint="Every directory you have run a chat in shows up here. Start one with New Chat."
/> />
); );
@@ -257,5 +257,63 @@
"startHint": "It will look around this directory, then ask you what to watch for.", "startHint": "It will look around this directory, then ask you what to watch for.",
"startBlocked": "{{missing}} — then this starts it.", "startBlocked": "{{missing}} — then this starts it.",
"confirmDeletingNamed": "Confirm deleting {{name}}" "confirmDeletingNamed": "Confirm deleting {{name}}"
},
"chatHistory": {
"newChat": "New Chat",
"tryAgain": "Try again",
"refresh": "Refresh",
"refreshSessions": "Refresh sessions",
"newChatInDir": "New chat in a directory…",
"loadFailed": "Could not load sessions",
"reading": "Reading transcripts…",
"noSessions": "No sessions yet",
"liveFailed": "Could not read live sessions",
"asking": "Asking the agent…",
"tabs": {
"tree": "Tree",
"latest": "Latest",
"live": "Live",
"agents": "Agents",
"commanders": "Commanders"
},
"notifyPreference": "Notification preference",
"notify": {
"everyTurn": "Every end of turn",
"inputNeeded": "Only when input is needed",
"none": "None"
},
"notificationsLabel": "Notifications: {{choice}}",
"chooseDir": "Choose a working directory",
"create": "Create",
"newFolder": "New folder",
"selectFolder": "Select this folder",
"newFolderName": "New folder name",
"noSubfolders": {
"one": "No visible subfolders — {{count}} hidden one is filtered out",
"other": "No visible subfolders — {{count}} hidden ones are filtered out"
},
"showHidden": "Show hidden",
"showHiddenCount": "Show hidden ({{count}})",
"selectSession": "Select a session to view",
"endSession": "End session — kills any running turn and releases it so it can be resumed elsewhere",
"noReadable": "No readable conversations here.",
"noConversations": "No conversations yet",
"newChatIn": "New chat in {{path}}",
"sessionTitle": "Session title",
"saveTitle": "Save title",
"cancelRename": "Cancel rename",
"confirmDelete": "Confirm delete",
"cancelDelete": "Cancel delete",
"rename": "Rename",
"deleteSession": "Delete session",
"renameFailed": "Could not rename the session: {{reason}}",
"deleteFailed": "Could not delete the session: {{reason}}",
"deleteAll": "Delete all {{count}}?",
"confirmDeletingNamed": "Confirm deleting {{name}}",
"renameNamed": "Rename {{name}}",
"deleteNamed": "Delete {{name}}",
"newChatTitle": "New chat",
"renameHint": "Click to rename this chat",
"hideHidden": "Hide hidden"
} }
} }
@@ -257,5 +257,63 @@
"startHint": "Vai observar este diretório e depois perguntar-lhe o que deve vigiar.", "startHint": "Vai observar este diretório e depois perguntar-lhe o que deve vigiar.",
"startBlocked": "{{missing}} — depois é isto que o inicia.", "startBlocked": "{{missing}} — depois é isto que o inicia.",
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}" "confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
},
"chatHistory": {
"newChat": "Novo chat",
"tryAgain": "Tentar novamente",
"refresh": "Atualizar",
"refreshSessions": "Atualizar sessões",
"newChatInDir": "Novo chat num diretório…",
"loadFailed": "Não foi possível carregar as sessões",
"reading": "A ler transcrições…",
"noSessions": "Ainda não há sessões",
"liveFailed": "Não foi possível ler as sessões ativas",
"asking": "A perguntar ao agente…",
"tabs": {
"tree": "Árvore",
"latest": "Recentes",
"live": "Ativas",
"agents": "Agentes",
"commanders": "Comandantes"
},
"notifyPreference": "Preferência de notificações",
"notify": {
"everyTurn": "Ao fim de cada turno",
"inputNeeded": "Apenas quando é preciso responder",
"none": "Nenhuma"
},
"notificationsLabel": "Notificações: {{choice}}",
"chooseDir": "Escolha um diretório de trabalho",
"create": "Criar",
"newFolder": "Nova pasta",
"selectFolder": "Selecionar esta pasta",
"newFolderName": "Nome da nova pasta",
"noSubfolders": {
"one": "Sem subpastas visíveis — {{count}} oculta foi filtrada",
"other": "Sem subpastas visíveis — {{count}} ocultas foram filtradas"
},
"showHidden": "Mostrar ocultas",
"showHiddenCount": "Mostrar ocultas ({{count}})",
"selectSession": "Selecione uma sessão para ver",
"endSession": "Terminar sessão — mata qualquer turno em execução e liberta-a para ser retomada noutro lugar",
"noReadable": "Não há conversas legíveis aqui.",
"noConversations": "Ainda não há conversas",
"newChatIn": "Novo chat em {{path}}",
"sessionTitle": "Título da sessão",
"saveTitle": "Guardar título",
"cancelRename": "Cancelar renomeação",
"confirmDelete": "Confirmar eliminação",
"cancelDelete": "Cancelar eliminação",
"rename": "Renomear",
"deleteSession": "Eliminar sessão",
"renameFailed": "Não foi possível renomear a sessão: {{reason}}",
"deleteFailed": "Não foi possível eliminar a sessão: {{reason}}",
"deleteAll": "Eliminar todas as {{count}}?",
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}",
"renameNamed": "Renomear {{name}}",
"deleteNamed": "Eliminar {{name}}",
"newChatTitle": "Novo chat",
"renameHint": "Clique para renomear este chat",
"hideHidden": "Ocultar ocultas"
} }
} }