activity, capabilities, chat history and the locale picker (21 -> 2)
The locale picker's labels are now AUTONYMS computed from Intl -- each language named in its own language, so "Português europeu" reads as Portuguese whichever locale the UI is in. That is the convention for a language picker and the useful behaviour: somebody who cannot read the current language still has to find theirs. CapabilityPage interpolated the capability KIND into six strings by lowercasing it in code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now. What is left is two strings, both deliberate: see the next commit message.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
|
||||
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
|
||||
@@ -15,6 +16,7 @@ const MAX_LINES = 600;
|
||||
// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and
|
||||
// the screen re-derives the param from the registry row — one address shape for both kinds.
|
||||
export const ActivityScreen = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, get } = useClient();
|
||||
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
|
||||
const [regLoaded, setRegLoaded] = useState(false);
|
||||
@@ -87,30 +89,32 @@ export const ActivityScreen = () => {
|
||||
<div className="flex h-full w-full">
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<ActivityIcon size={16} className="text-primary" /> Activity
|
||||
<ActivityIcon size={16} className="text-primary" /> {t('activity.title')}
|
||||
</div>
|
||||
|
||||
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Background tasks
|
||||
{t('activity.backgroundTasks')}
|
||||
</div>
|
||||
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
|
||||
{reg.tasks.map((t) => (
|
||||
{reg.tasks.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t('activity.noneRunning')}</p>
|
||||
)}
|
||||
{reg.tasks.map((task) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
to={`/activity/${encodeURIComponent(t.id)}`}
|
||||
className={rowCls(selectedId === t.id)}
|
||||
title={t.cwd}
|
||||
key={task.id}
|
||||
to={`/activity/${encodeURIComponent(task.id)}`}
|
||||
className={rowCls(selectedId === task.id)}
|
||||
title={task.cwd}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${task.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
|
||||
/>
|
||||
<span className="truncate font-mono text-xs">{t.id}</span>
|
||||
<span className="truncate font-mono text-xs">{task.id}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{reg.detached.length > 0 && (
|
||||
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Detached
|
||||
{t('activity.detached')}
|
||||
</div>
|
||||
)}
|
||||
{reg.detached.map((d) => (
|
||||
@@ -169,7 +173,7 @@ export const ActivityScreen = () => {
|
||||
no run called <span className="font-mono">{selectedId}</span> is in the registry — it finished, or
|
||||
it never started.{' '}
|
||||
<Link to="/activity" className="underline">
|
||||
Back to the list
|
||||
{t('activity.backToList')}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
@@ -187,7 +191,7 @@ export const ActivityScreen = () => {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a task to follow its live output
|
||||
{t('activity.selectTask')}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useChat, EmbeddableChat } from 'officerdev';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
type CapabilitySummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
@@ -301,6 +302,7 @@ export const CapabilityChat = ({
|
||||
};
|
||||
|
||||
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -310,7 +312,7 @@ export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<span className="text-xs font-medium">Frontmatter</span>
|
||||
<span className="text-xs font-medium">{t('capability.frontmatter')}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
||||
@@ -330,6 +332,7 @@ export const CapabilityList = ({
|
||||
showCreate,
|
||||
onShowCreateChange,
|
||||
}: CapabilityListProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [internalCreating, setInternalCreating] = useState(false);
|
||||
@@ -356,7 +359,7 @@ export const CapabilityList = ({
|
||||
setNewName('');
|
||||
onCreate(res.dirName);
|
||||
} catch {
|
||||
toast.error(`Failed to create ${kind}`);
|
||||
toast.error(t('capability.createFailed', { kind: kind.toLowerCase() }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -427,7 +430,7 @@ export const CapabilityList = ({
|
||||
<input
|
||||
value={internalSearch}
|
||||
onChange={(ev) => setInternalSearch(ev.target.value)}
|
||||
placeholder={`Search ${kind.toLowerCase()}s...`}
|
||||
placeholder={t('capability.searchPlaceholder', { kind: `${kind.toLowerCase()}s` })}
|
||||
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -452,7 +455,7 @@ export const CapabilityList = ({
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
||||
)}
|
||||
{items.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">{t('capability.noMatches')}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -469,6 +472,7 @@ export const CapabilityList = ({
|
||||
* them for free — no reset effect racing the navigation. `edit` also makes the editor pane linkable.
|
||||
*/
|
||||
export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: CapabilityPageProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
@@ -497,7 +501,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
navigate(basePath, { replace: true });
|
||||
await qc.invalidateQueries({ queryKey: [queryKey] });
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${kind}`);
|
||||
toast.error(t('capability.deleteFailed', { kind: kind.toLowerCase() }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -526,7 +530,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</Link>
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
||||
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
||||
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
|
||||
</span>
|
||||
{detail && (
|
||||
<>
|
||||
@@ -554,10 +558,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
) : detail ? (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">Empty file</p>
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">{t('capability.emptyFile')}</p>
|
||||
) : (
|
||||
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
||||
Select a {kind.toLowerCase()} to view its contents
|
||||
{t('capability.selectToView', { kind: kind.toLowerCase() })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -606,10 +610,10 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||
Delete
|
||||
{t('capability.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errorText } from 'helpers/error-text';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||
import { defaultLayout, hasAppType } from './defaultLayout';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
||||
const CHAT_TAIL = 20;
|
||||
@@ -18,6 +19,7 @@ type SessionListPageProps = {
|
||||
};
|
||||
|
||||
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
|
||||
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
||||
const groupCwd = cwdFromSplat(splat);
|
||||
@@ -122,7 +124,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
|
||||
// no hint that the transcript could not be read, which is indistinguishable from a new session.
|
||||
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
|
||||
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
|
||||
toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
|
||||
setSelected({ id: sessionId });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { CodeEditorView } from 'officerdev';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const CodeEditor = () => (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
||||
export const CodeEditor = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Widget title={t('nav.codeEditor')} className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||
{/* The screen is the one editor that owns the address bar, so the open file is `?file=`. The panel
|
||||
wrapper deliberately does not pass this: a dashboard can hold two editors. */}
|
||||
<CodeEditorView className="h-full w-full" urlState />
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
<CodeEditorView className="h-full w-full" urlState />
|
||||
</Widget>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,10 @@ import { KeyRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
export const PasskeyGate = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, createPasskeyCredentials } = useAuth();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
@@ -31,7 +33,7 @@ export const PasskeyGate = () => {
|
||||
<KeyRound className="h-8 w-8 text-duck-teal" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-duck-dark">Set up your passkey</h2>
|
||||
<h2 className="text-2xl font-bold text-duck-dark">{t('passkeyGate.setUp')}</h2>
|
||||
|
||||
<p className="text-duck-dark/70">
|
||||
Passkeys provide a secure, passwordless way to access your account. Each device or browser needs its own
|
||||
|
||||
Reference in New Issue
Block a user