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
@@ -12,6 +12,7 @@ import { useClaudeSessions } from 'state/useClaudeSessions';
import type { NotifyPreference } from 'state/useClaudeSessions';
import { useChat, EmbeddableChat } from '../Chat';
import type { ChatMessage } from '../Chat/types';
import { useTranslation } from '../../locales';
export type SelectedSession = {
id: string;
@@ -71,6 +72,7 @@ function DetailBar({
onDisconnect,
notifyPreference,
}: DetailBarProps) {
const { t } = useTranslation();
// 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
// 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 className="flex-1 min-w-0 px-3 text-center">
<EditableTitle
value={sessionTitle ?? 'New chat'}
value={sessionTitle ?? t('chatHistory.newChatTitle')}
onCommit={commitRename}
readOnly={!sessionId}
ariaLabel="Session title"
hint="Click to rename this chat"
ariaLabel={t('chatHistory.sessionTitle')}
hint={t('chatHistory.renameHint')}
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"
/>
@@ -142,7 +144,7 @@ function DetailBar({
<button
type="button"
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"
>
<Unplug className="h-3.5 w-3.5" />
@@ -177,6 +179,7 @@ type NewChatProps = {
};
function NewChat(props: NewChatProps) {
const { t } = useTranslation();
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, planMode } = props;
const location = useLocation();
const locationState = location.state as ChatLocationState;
@@ -254,6 +257,7 @@ function NewChat(props: NewChatProps) {
}
export const ChatDetailPanel = () => {
const { t } = useTranslation();
const [selected] = useSelectedChatSession();
// 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) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Select a session to view
{t('chatHistory.selectSession')}
</div>
);
}
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { Folder, ChevronRight, Loader2, FolderPlus, Check, Eye, EyeOff } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { useTranslation } from '../../locales';
type DirPickerModalProps = {
open: boolean;
@@ -13,6 +14,7 @@ type DirPickerModalProps = {
// 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.
export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps) => {
const { t, tUi } = useTranslation();
const api = useFilesAPI('home');
const [path, setPath] = useState('/'); // root-relative, always starts with '/'
const [creating, setCreating] = useState(false);
@@ -54,7 +56,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 p-0">
<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>
{/* 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
// 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">
{hiddenCount > 0
? `No visible subfolders — ${hiddenCount} hidden ${hiddenCount === 1 ? 'one is' : 'ones are'} filtered out`
: 'No subfolders here'}
{hiddenCount > 0 ? t('chatHistory.noSubfolders', { count: hiddenCount }) : 'No subfolders here'}
</div>
) : (
dirs.map((d) => (
@@ -113,14 +113,14 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
onKeyDown={(ev) => {
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"
/>
<button
type="submit"
className="rounded px-2 py-1 text-xs text-duck-teal hover:bg-duck-teal/10 cursor-pointer"
>
Create
{t('chatHistory.create')}
</button>
</form>
) : (
@@ -129,7 +129,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
onClick={() => setCreating(true)}
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>
{/* 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. */}
@@ -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"
>
{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>
</div>
)}
@@ -151,7 +155,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel
{tUi('button.cancel')}
</button>
<button
onClick={() => {
@@ -161,7 +165,7 @@ export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps)
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"
>
<Check className="h-3.5 w-3.5" /> Select this folder
<Check className="h-3.5 w-3.5" /> {t('chatHistory.selectFolder')}
</button>
</div>
</div>
@@ -1,13 +1,15 @@
import { Bell, BellOff, Check } from 'lucide-react';
import * as Dropdown from '@/components/ui/dropdown-menu';
import type { NotifyPreference } from 'state/useClaudeSessions';
import type { PanelTranslationKey } from '../../locales';
import { useTranslation } from '../../locales';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
const OPTIONS: { value: NotifyPreference; label: string }[] = [
{ value: 'every_turn', label: 'Every end of turn' },
{ value: 'input_needed', label: 'Only when input is needed' },
{ value: 'none', label: 'None' },
const OPTIONS: { value: NotifyPreference; labelKey: PanelTranslationKey }[] = [
{ value: 'every_turn', labelKey: 'chatHistory.notify.everyTurn' },
{ value: 'input_needed', labelKey: 'chatHistory.notify.inputNeeded' },
{ value: 'none', labelKey: 'chatHistory.notify.none' },
];
/**
@@ -40,6 +42,7 @@ type NotifyMenuProps = {
* variant, which nothing here uses.
*/
export const NotifyMenu = ({ preference, onChange }: NotifyMenuProps) => {
const { t } = useTranslation();
const pick = (value: NotifyPreference) => {
onChange(value);
// 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>
<button
type="button"
title={`Notifications: ${OPTIONS.find((o) => o.value === preference)?.label ?? 'None'}`}
aria-label="Notification preference"
title={t('chatHistory.notificationsLabel', {
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]}`}
>
{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) => (
<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'}`} />
{opt.label}
{t(opt.labelKey)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -13,6 +13,8 @@ import { chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
import { DirPickerModal } from './DirPickerModal';
import { LiveMarker, SessionRow, shortenPath, useScrollActiveIntoView, useSessionRowActions } from './SessionRow';
import { SessionTree } from './SessionTree';
import type { PanelTranslationKey } from '../../locales';
import { useTranslation } from '../../locales';
/*
The /chat sidebar: one panel, three views.
@@ -30,10 +32,10 @@ import { SessionTree } from './SessionTree';
type View = 'tree' | 'latest' | 'live';
const TABS: { value: View; label: string }[] = [
{ value: 'tree', label: 'Tree' },
{ value: 'latest', label: 'Latest' },
{ value: 'live', label: 'Live' },
const TABS: { value: View; labelKey: PanelTranslationKey }[] = [
{ value: 'tree', labelKey: 'chatHistory.tabs.tree' },
{ value: 'latest', labelKey: 'chatHistory.tabs.latest' },
{ value: 'live', labelKey: 'chatHistory.tabs.live' },
];
/*
@@ -52,6 +54,7 @@ const LIVE_KIND_KEY = 'chat.sessions.liveKind';
const EXPANDED_KEY = 'chat.sessions.expanded';
export const SessionList = () => {
const { t } = useTranslation();
const navigate = useNavigate();
// 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
@@ -157,7 +160,7 @@ export const SessionList = () => {
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"
>
{tab.label}
{t(tab.labelKey)}
{/* The count that decides whether Live is worth a click. Only when non-zero — a permanent
`0` is the same as no information. */}
{tab.value === 'live' && liveSessions.length > 0 && (
@@ -173,8 +176,8 @@ export const SessionList = () => {
<button
onClick={refresh}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
title="Refresh"
aria-label="Refresh sessions"
title={t('chatHistory.refresh')}
aria-label={t('chatHistory.refreshSessions')}
>
<RefreshCw className={`h-3.5 w-3.5 ${latest.isLoading ? 'animate-spin' : ''}`} />
</button>
@@ -184,10 +187,10 @@ export const SessionList = () => {
<button
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"
title="New chat in a directory…"
title={t('chatHistory.newChatInDir')}
>
<Plus className="h-3.5 w-3.5" />
New Chat
{t('chatHistory.newChat')}
</button>
</div>
</div>
@@ -209,25 +212,25 @@ export const SessionList = () => {
<>
{latest.error ? (
<ErrorBlock
title="Could not load sessions"
title={t('chatHistory.loadFailed')}
message={errorText(latest.error)}
action={
<button
onClick={() => void latest.refetch()}
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>
}
/>
) : 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
// account with no history.
<LoadingBlock label="Reading transcripts…" />
<LoadingBlock label={t('chatHistory.reading')} />
) : latest.sessions.length === 0 ? (
<EmptyBlock
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."
/>
) : (
@@ -277,12 +280,13 @@ export const SessionList = () => {
*/
type LiveKind = 'agents' | 'commanders';
const LIVE_TABS: { value: LiveKind; label: string }[] = [
{ value: 'agents', label: 'Agents' },
{ value: 'commanders', label: 'Commanders' },
const LIVE_TABS: { value: LiveKind; labelKey: PanelTranslationKey }[] = [
{ value: 'agents', labelKey: 'chatHistory.tabs.agents' },
{ value: 'commanders', labelKey: 'chatHistory.tabs.commanders' },
];
const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => {
const { t } = useTranslation();
const { live: sessions, isLoading, error, refetch } = useLiveSessions();
const [kind, setKind] = useLocalStorageState<LiveKind>(LIVE_KIND_KEY, 'agents');
@@ -297,20 +301,20 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }
if (error) {
return (
<ErrorBlock
title="Could not read live sessions"
title={t('chatHistory.liveFailed')}
message={errorText(error)}
action={
<button
onClick={() => void refetch()}
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>
}
/>
);
}
if (isLoading && sessions.length === 0) return <LoadingBlock label="Asking the agent…" />;
if (isLoading && sessions.length === 0) return <LoadingBlock label={t('chatHistory.asking')} />;
return (
<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}
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>
</TabsTrigger>
);
@@ -5,6 +5,8 @@ import { DataRow, MetaItem, RelativeTime } from '@/components/Data';
import { errorText } from 'helpers/error-text';
import type { ClaudeSessionSummary, NotifyPreference } from 'state/useClaudeSessions';
import { chatSessionPath } from './chat-routes';
import { useTranslation } from '../../locales';
import { Untranslated } from '@/components/Untranslated';
/*
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' }) =>
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>;
deleteSession: (id: string) => Promise<unknown>;
}) {
const { t } = useTranslation();
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(null);
@@ -134,7 +141,7 @@ export function useSessionRowActions(actions: {
try {
await actions.renameSession(id, title);
} catch (err) {
toast.error(`Could not rename the session: ${errorText(err)}`);
toast.error(t('chatHistory.renameFailed', { reason: errorText(err) }));
}
}, [editingId, editValue, actions]);
@@ -144,7 +151,7 @@ export function useSessionRowActions(actions: {
try {
await actions.deleteSession(id);
} catch (err) {
toast.error(`Could not delete the session: ${errorText(err)}`);
toast.error(t('chatHistory.deleteFailed', { reason: errorText(err) }));
}
},
[actions],
@@ -188,6 +195,7 @@ export const SessionRow = ({
actions,
activeRef,
}: SessionRowProps) => {
const { t, tUi } = useTranslation();
const isActive = activeId === session.id;
const isEditing = actions.editingId === session.id;
const isConfirming = actions.confirmingId === session.id;
@@ -209,15 +217,15 @@ export const SessionRow = ({
if (ev.key === 'Escape') actions.setEditingId(null);
}}
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"
/>
<button
type="submit"
onMouseDown={(ev) => ev.preventDefault()}
className="cursor-pointer p-1 text-success hover:opacity-80"
title="Save"
aria-label="Save title"
title={t('common.save')}
aria-label={t('chatHistory.saveTitle')}
>
<Check className="h-3.5 w-3.5" />
</button>
@@ -226,8 +234,8 @@ export const SessionRow = ({
onMouseDown={(ev) => ev.preventDefault()}
onClick={() => actions.setEditingId(null)}
className="cursor-pointer p-1 opacity-50 hover:opacity-100"
title="Cancel"
aria-label="Cancel rename"
title={tUi('button.cancel')}
aria-label={t('chatHistory.cancelRename')}
>
<X className="h-3.5 w-3.5" />
</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
conversation giving no warning that it is three files is exactly the surprise this prevents. */}
<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>
<button
onClick={() => void actions.handleDelete(session.id)}
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
title="Confirm delete"
aria-label={`Confirm deleting ${session.title}`}
title={t('chatHistory.confirmDelete')}
aria-label={t('chatHistory.confirmDeletingNamed', { name: session.title })}
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => actions.setConfirmingId(null)}
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
title="Cancel"
aria-label="Cancel delete"
title={tUi('button.cancel')}
aria-label={t('chatHistory.cancelDelete')}
>
<X className="h-3.5 w-3.5" />
</button>
@@ -304,16 +314,16 @@ export const SessionRow = ({
<button
onClick={() => actions.startRename(session.id, session.title)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-foreground"
title="Rename"
aria-label={`Rename ${session.title}`}
title={t('chatHistory.rename')}
aria-label={t('chatHistory.renameNamed', { name: session.title })}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => actions.setConfirmingId(session.id)}
className="cursor-pointer rounded p-1.5 text-muted-foreground transition-colors hover:text-destructive"
title="Delete session"
aria-label={`Delete ${session.title}`}
title={t('chatHistory.deleteSession')}
aria-label={t('chatHistory.deleteNamed', { name: session.title })}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -7,6 +7,7 @@ import type { LiveSession } from 'state/useLiveSessions';
import { chatNewPath } from './chat-routes';
import { SessionRow, shortenPath, type SessionRowActions, useScrollActiveIntoView } from './SessionRow';
import { useSelectedChatSession } from '../../channels';
import { useTranslation } from '../../locales';
/*
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 { t } = useTranslation();
const navigate = useNavigate();
const [, setSelected] = useSelectedChatSession();
const activeRef = useScrollActiveIntoView();
@@ -104,8 +106,8 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions
<button
type="button"
onClick={startHere}
title={`New chat in ${shortenPath(pwd.cwd, home)}`}
aria-label={`New chat in ${shortenPath(pwd.cwd, home)}`}
title={t('chatHistory.newChatIn', { path: 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"
>
<Plus className="h-3.5 w-3.5" />
@@ -123,11 +125,11 @@ const DirectoryNode = ({ pwd, home, expanded, onToggle, live, activeId, actions
{expanded && (
<div className="bg-background/40 py-2 pl-8">
{isLoading && sessions.length === 0 ? (
<LoadingBlock label="Reading transcripts…" />
<LoadingBlock label={t('chatHistory.reading')} />
) : sessions.length === 0 ? (
// 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.
<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">
{sessions.map((session) => (
@@ -167,11 +169,12 @@ export const SessionTree = ({
activeId,
actions,
}: SessionTreeProps) => {
const { t } = useTranslation();
if (!pwds.length) {
return (
<EmptyBlock
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."
/>
);
@@ -257,5 +257,63 @@
"startHint": "It will look around this directory, then ask you what to watch for.",
"startBlocked": "{{missing}} — then this starts it.",
"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.",
"startBlocked": "{{missing}} — depois é isto que o inicia.",
"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"
}
}