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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user