sync: update from private repo (d31b280)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 11:28:40 +00:00
parent d061ad08d8
commit 3b1645cc91
96 changed files with 4627 additions and 287 deletions
+2 -2
View File
@@ -227,7 +227,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
</button>
</div>
</div>
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex gap-4 -mb-px">
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex flex-wrap gap-x-4 gap-y-1 -mb-px">
{tabs.map(tab => {
const active = activeTab === tab.id;
const pending = active && tabTransitionPending;
@@ -237,7 +237,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
role="tab"
aria-selected={active}
onClick={() => onTabChange(tab.id)}
className={`pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
className={`whitespace-nowrap pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
active
? 'border-accent text-slate-900 font-semibold'
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
+21 -1
View File
@@ -104,7 +104,27 @@ export function LocalDetailPanel({
refetchInterval: 5000,
});
const showSshTab = consoleStatus?.active === true;
const visibleTabs = LOCAL_TABS.filter((t) => t.id !== 'ssh' || showSshTab);
// Browser tab visibility: mirror SSH — show only once a viewable browser
// session is live for this task. `available: true` means a noVNC session
// exists (the agent has actually used the browser); every other case
// (no_session / headless_mode / display_unavailable / novnc_not_installed)
// is non-viewable, so the tab would show nothing useful and stays hidden.
// Shares the ['task-session', id] query with BrowserTab (deduped by key).
const { data: browserSession } = useQuery<{ available: boolean }>({
queryKey: ['task-session', task?.id],
queryFn: async () => {
const r = await fetch(`/api/local/browser/sessions/task-session/${task!.id}`);
return r.ok ? r.json() : { available: false };
},
enabled: !!task,
refetchInterval: 5000,
});
const showBrowserTab = browserSession?.available === true;
const visibleTabs = LOCAL_TABS.filter(
(t) => (t.id !== 'ssh' || showSshTab) && (t.id !== 'browser' || showBrowserTab),
);
const handleStartEdit = () => {
if (!task) return;
+112 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief } from '../../../api';
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief, updateLocalTask, regenerateTaskTitle } from '../../../api';
import { StatusBadge } from '../../shared/StatusBadge';
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
import { ContextUsageGauge } from '../ContextUsageGauge';
@@ -272,6 +272,116 @@ function MissionCard({ task }: { task: LocalTask }) {
);
}
/**
* Editable task title with on-demand AI regeneration. The title is set to a
* cheap fallback at creation and upgraded by the agent (derived from the
* Mission Brief goal) during the run. A manual edit pins it (title_source =
* 'user') so the agent never overwrites it afterwards.
*/
function TaskTitleRow({ task }: { task: LocalTask }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(task.title);
const [error, setError] = useState<string | null>(null);
// Sync the displayed title with server-side updates (the agent rewrites it
// mid-run) unless the user is actively editing.
useEffect(() => {
if (!editing) setDraft(task.title);
}, [task.title, editing]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
qc.invalidateQueries({ queryKey: ['localTasks'] });
};
const saveMutation = useMutation({
mutationFn: () => updateLocalTask(task.id, { title: draft.trim() }),
onSuccess: () => { setEditing(false); setError(null); invalidate(); },
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to save title'),
});
const regenMutation = useMutation({
mutationFn: () => regenerateTaskTitle(task.id),
onSuccess: () => { setError(null); invalidate(); },
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to regenerate title'),
});
const save = () => { if (draft.trim()) saveMutation.mutate(); };
if (editing) {
return (
<div className="flex flex-col gap-1.5">
<input
value={draft}
autoFocus
maxLength={200}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { setEditing(false); setError(null); setDraft(task.title); }
}}
className="w-full px-2.5 py-1.5 text-lg font-extrabold text-slate-900 border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent"
/>
{error && <div className="text-2xs text-red-600">{error}</div>}
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => { setEditing(false); setError(null); setDraft(task.title); }}
disabled={saveMutation.isPending}
className="px-3 h-7 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors disabled:opacity-50"
>
{t('title.cancel')}
</button>
<button
type="button"
onClick={save}
disabled={saveMutation.isPending || !draft.trim()}
className="px-3 h-7 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
>
{saveMutation.isPending ? t('title.saving') : t('title.save')}
</button>
</div>
</div>
);
}
return (
<div>
<div className="group flex items-start justify-between gap-2">
<div className="text-lg font-extrabold text-slate-900 break-words leading-tight min-w-0">{task.title}</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => regenMutation.mutate()}
disabled={regenMutation.isPending}
title={t('title.regenerate')}
aria-label={t('title.regenerate')}
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-accent hover:bg-surface transition-colors disabled:opacity-50"
>
<svg className={`w-3.5 h-3.5 ${regenMutation.isPending ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3h-3" />
</svg>
</button>
<button
type="button"
onClick={() => { setDraft(task.title); setEditing(true); setError(null); }}
title={t('title.edit')}
aria-label={t('title.edit')}
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11.5 2.5l2 2L6 12l-2.5.5L4 10l7.5-7.5z" />
</svg>
</button>
</div>
</div>
{error && <div className="text-2xs text-red-600 mt-1">{error}</div>}
</div>
);
}
interface OverviewTabProps {
task: LocalTask;
subtaskActivities?: SubtaskActivity[];
@@ -284,7 +394,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
return (
<div className="flex flex-col gap-3">
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="text-lg font-extrabold text-slate-900">{task.title}</div>
<TaskTitleRow task={task} />
<div className="flex flex-wrap gap-2 mt-2">
<StatusBadge status={status} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
+19 -7
View File
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
import { isPreviewable, formatFileDate } from '../../lib/utils';
import { splitFileName } from '../../lib/fileType';
import { FileTypeIcon } from './FileTypeIcon';
interface FileBrowserProps {
section: 'workspace' | 'input' | 'output' | 'logs';
@@ -211,20 +213,30 @@ export function FileBrowser({
key={`${entry.kind}:${entry.path}`}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-canvas border border-hairline hover:bg-surface transition-colors"
>
<span className="text-slate-400 flex-shrink-0" aria-hidden="true">
<span className="flex-shrink-0" aria-hidden="true">
{entry.kind === 'directory' ? (
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg className="w-4 h-4 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
<path d="M9 2v4.5h4.5" />
</svg>
<FileTypeIcon name={entry.name} />
)}
</span>
<div className="min-w-0 flex-1">
<div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
{entry.kind === 'file'
? (() => {
// Truncate the stem but never the extension, so the file
// type stays readable on long names: `long-na…me.xlsx`.
const { stem, ext } = splitFileName(entry.name);
return (
<div className="flex items-baseline text-[13px] text-slate-800 min-w-0" title={entry.name}>
<span className="truncate min-w-0">{stem}</span>
{ext && <span className="flex-shrink-0">{ext}</span>}
</div>
);
})()
: <div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
}
{entry.kind === 'file' && entry.modifiedAt && (
<div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div>
)}
+18 -1
View File
@@ -65,6 +65,7 @@ interface FilePreviewProps {
section?: string;
filePath?: string;
editable?: boolean;
trustedHtmlUrl?: string;
}
// --- CSV ---
@@ -622,7 +623,7 @@ function renderJsonl(content: string): JSX.Element {
}
// --- FilePreview ---
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) {
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl }: FilePreviewProps) {
const { t } = useTranslation('files');
const [mode, setMode] = useState<'view' | 'edit'>('view');
const [editContent, setEditContent] = useState(content);
@@ -634,6 +635,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
const canEdit = editable && taskId != null && section && filePath;
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
const canOpenTrustedHtml = mode === 'view' && !!trustedHtmlUrl && /\.html?$/i.test(name);
const handlePrint = async () => {
if (printing) return;
@@ -756,6 +758,21 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-surface z-10 gap-2">
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
<div className="flex items-center gap-1 flex-shrink-0">
{canOpenTrustedHtml && (
<a
href={trustedHtmlUrl}
target="_blank"
rel="noopener noreferrer"
title={t('trustedHtml.tooltip')}
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3.5A1.5 1.5 0 0 0 2 4.5v8A1.5 1.5 0 0 0 3.5 14h8a1.5 1.5 0 0 0 1.5-1.5V10" />
<path d="M9 2h5v5M8 8l6-6" />
</svg>
{t('trustedHtml.button')}
</a>
)}
{isMarkdownFile && mode === 'view' && (
<button
onClick={handlePrint}
+89
View File
@@ -0,0 +1,89 @@
import { fileCategory, categoryColorClass, type FileCategory } from '../../lib/fileType';
/** カテゴリごとの 16x16 ストロークグリフ (既存ファイル行のアイコンと同じスタイル)。 */
function glyph(cat: FileCategory) {
switch (cat) {
case 'image':
return (
<>
<rect x="2" y="3" width="12" height="10" rx="1.5" />
<circle cx="5.5" cy="6.5" r="1" />
<path d="M3 12l3-3 2.5 2.5L11 7l2 2" />
</>
);
case 'spreadsheet':
return (
<>
<rect x="2.5" y="3" width="11" height="10" rx="1" />
<path d="M2.5 6.5h11M2.5 10h11M6 3v10M10 3v10" />
</>
);
case 'document':
return (
<>
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
<path d="M9 2v4.5h4.5" />
<path d="M5.5 9h5M5.5 11h3" />
</>
);
case 'presentation':
return (
<>
<rect x="2" y="3" width="12" height="8" rx="1" />
<path d="M8 11v2M6 14h4" />
</>
);
case 'code':
return <path d="M6 5L3 8l3 3M10 5l3 3-3 3" />;
case 'archive':
return (
<>
<rect x="3" y="3" width="10" height="11" rx="1" />
<path d="M8 3v11" strokeDasharray="1.5 1.5" />
</>
);
case 'audio':
return (
<>
<circle cx="5.3" cy="11.5" r="1.7" />
<circle cx="10.7" cy="9.7" r="1.7" />
<path d="M7 11.5V4l5.4-1.5v7.2" />
</>
);
case 'video':
return (
<>
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
<path d="M7 6.3l3 1.7-3 1.7z" />
</>
);
case 'pdf':
case 'other':
default:
// 折れ角のあるページ。pdf は色 (赤) で識別、other は灰色。
return (
<>
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
<path d="M9 2v4.5h4.5" />
</>
);
}
}
export function FileTypeIcon({ name, className }: { name: string; className?: string }) {
const cat = fileCategory(name);
return (
<svg
className={`${className ?? 'w-4 h-4'} ${categoryColorClass(cat)}`}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{glyph(cat)}
</svg>
);
}
+11 -2
View File
@@ -5,7 +5,8 @@ import { useBackdropClose } from '../../lib/useBackdropClose';
export interface NavItem {
id: PageId;
label: string;
/** i18n key resolved against the `layout` namespace at render time. */
labelKey: string;
}
interface NavDrawerProps {
@@ -85,6 +86,14 @@ const NAV_ICONS: Record<PageId, ReactNode> = {
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
),
usage: (
<svg {...ICON_PROPS}>
<line x1="4" y1="20" x2="20" y2="20" />
<rect x="6" y="11" width="3" height="6" />
<rect x="11" y="7" width="3" height="10" />
<rect x="16" y="13" width="3" height="4" />
</svg>
),
};
export function NavDrawer({
@@ -211,7 +220,7 @@ export function NavDrawer({
}`}
>
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
<span className="flex-1 text-left">{item.label}</span>
<span className="flex-1 text-left">{t(item.labelKey)}</span>
</button>
);
})}
+92 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App';
@@ -17,6 +17,9 @@ interface TopBarProps {
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
navDrawerOpen?: boolean;
onOpenCommandK?: () => void;
/** Reports the measured compact state up so App can drive the nav drawer /
* edge-swipe. Called whenever the collapse decision flips. */
onCompactChange?: (compact: boolean) => void;
}
// labelKey resolves against the `layout` i18n namespace at render time (module
@@ -26,14 +29,29 @@ export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
{ id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false },
{ id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false },
{ id: 'usage', labelKey: 'nav.usage', adminOnly: false, requiresAuth: false },
{ id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false },
{ id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true },
{ id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false },
{ id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false },
];
export function estimateCollapseThreshold(navCount: number): number {
return 430 + navCount * 78 + 60;
/**
* Decide whether the inline nav must collapse into the hamburger, from MEASURED
* widths (not a width estimate). Collapse exactly when the nav's natural width
* no longer fits the space left after the fixed left content (logo/name/version)
* and the right-side controls. The buffer keeps a small gap so tabs never touch
* the controls. Before anything is measured (zero widths) we assume it fits, so
* the bar paints with tabs rather than flashing the hamburger.
*/
export function shouldCollapseNav(
rowWidth: number,
fixedWidth: number,
navWidth: number,
buffer = 40,
): boolean {
if (rowWidth <= 0 || navWidth <= 0) return false;
return navWidth > rowWidth - fixedWidth - buffer;
}
export function useViewportNarrow(threshold: number): boolean {
@@ -58,11 +76,6 @@ export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
});
}
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
const visible = visibleNavItemsFor(isAdmin, authEnabled);
return useViewportNarrow(estimateCollapseThreshold(visible.length));
}
export function TopBar({
currentPage,
onNavigate,
@@ -75,12 +88,43 @@ export function TopBar({
hamburgerButtonRef,
navDrawerOpen = false,
onOpenCommandK,
onCompactChange,
}: TopBarProps) {
const { t } = useTranslation('layout');
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
const [showPwChange, setShowPwChange] = useState(false);
// Collapse-to-hamburger decision from MEASURED widths, so it flips exactly
// when the tabs stop fitting — no width estimate, no 2-line state.
const rowRef = useRef<HTMLDivElement>(null);
const leftFixedRef = useRef<HTMLDivElement>(null); // logo + appName + version (NOT the nav)
const rightRef = useRef<HTMLDivElement>(null); // right-side controls
const navRulerRef = useRef<HTMLDivElement>(null); // off-screen full-width nav, always present
const [compactMode, setCompactMode] = useState(false);
useLayoutEffect(() => {
const row = rowRef.current;
if (!row || typeof ResizeObserver === 'undefined') return;
const measure = () => {
const leftFixed = leftFixedRef.current?.offsetWidth ?? 0;
const right = rightRef.current?.offsetWidth ?? 0;
const navWidth = navRulerRef.current?.offsetWidth ?? 0;
setCompactMode(shouldCollapseNav(row.clientWidth, leftFixed + right, navWidth));
};
const ro = new ResizeObserver(measure);
ro.observe(row);
// Observe the ruler + right group too: their widths change with language
// (label lengths) and login state, which also moves the collapse point.
if (navRulerRef.current) ro.observe(navRulerRef.current);
if (rightRef.current) ro.observe(rightRef.current);
measure();
return () => ro.disconnect();
}, [visibleNav.length, t]);
useEffect(() => {
onCompactChange?.(compactMode);
}, [compactMode, onCompactChange]);
return (
<div
className="flex-shrink-0 bg-canvas border-b border-hairline px-4 flex items-center"
@@ -89,7 +133,26 @@ export function TopBar({
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
}}
>
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5">
<div ref={rowRef} className="relative flex items-center justify-between gap-3 flex-nowrap w-full py-1.5 min-w-0">
{/* Off-screen full-width nav ruler. Always rendered (absolute, out of
flow) so its width is measurable even while the visible nav is
collapsed — that constant width is what keeps the collapse decision
from oscillating. INVARIANT: this ruler must stay a width-SUPERSET
of the real nav (same px/gap, every label font-semibold = the widest
state). If it ever underestimates the real nav, the 2-line bug
returns. Do NOT add per-item active/inactive weight here. */}
<nav
ref={navRulerRef}
aria-hidden="true"
className="absolute left-0 top-0 invisible pointer-events-none flex gap-5 ml-2"
>
{visibleNav.map(item => (
<span key={item.id} className="px-0.5 text-xs font-semibold whitespace-nowrap">
{t(item.labelKey)}
</span>
))}
</nav>
<div className="flex items-center gap-4 min-w-0 self-stretch">
{compactMode && (
<button
@@ -109,20 +172,25 @@ export function TopBar({
</svg>
</button>
)}
<img
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
alt=""
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
/>
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
{appName}
</span>
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
v{__APP_VERSION__}
</span>
{/* leftFixedRef must wrap ONLY logo/name/version — the hamburger is a
sibling above, kept out of this measurement so toggling it doesn't
change leftFixed width (which would oscillate the collapse). */}
<div ref={leftFixedRef} className="flex items-center gap-4 flex-shrink-0">
<img
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
alt=""
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
/>
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
{appName}
</span>
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
v{__APP_VERSION__}
</span>
</div>
{!compactMode && (
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label={t('nav.mainNav')}>
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2 flex-shrink-0" aria-label={t('nav.mainNav')}>
{visibleNav.map(item => {
const active = currentPage === item.id;
return (
@@ -131,7 +199,7 @@ export function TopBar({
type="button"
onClick={() => onNavigate(item.id)}
aria-current={active ? 'page' : undefined}
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
className={`relative whitespace-nowrap px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
active
? 'font-semibold text-slate-900 border-accent'
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
@@ -145,7 +213,7 @@ export function TopBar({
)}
</div>
<div className="flex items-center gap-2">
<div ref={rightRef} className="flex items-center gap-2 flex-shrink-0">
{onOpenCommandK && (
<button
type="button"
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { shouldCollapseNav } from './TopBar';
describe('shouldCollapseNav', () => {
it('does not collapse while widths are unmeasured (avoids hamburger flash)', () => {
expect(shouldCollapseNav(0, 0, 0)).toBe(false);
expect(shouldCollapseNav(1000, 300, 0)).toBe(false); // navWidth 0 = not measured yet
});
it('keeps tabs when the nav fits the leftover space (minus buffer)', () => {
// row 1000, fixed 300 → 700 available, minus 40 buffer = 660. nav 600 fits.
expect(shouldCollapseNav(1000, 300, 600)).toBe(false);
});
it('collapses when the nav no longer fits', () => {
// 700 available - 40 buffer = 660. nav 700 does not fit.
expect(shouldCollapseNav(1000, 300, 700)).toBe(true);
});
it('respects the buffer at the boundary', () => {
// available 660 after buffer; nav exactly 660 fits, 661 collapses.
expect(shouldCollapseNav(1000, 300, 660)).toBe(false);
expect(shouldCollapseNav(1000, 300, 661)).toBe(true);
});
it('honors a custom buffer', () => {
expect(shouldCollapseNav(1000, 300, 690, 10)).toBe(false); // 690 <= 690
expect(shouldCollapseNav(1000, 300, 691, 10)).toBe(true);
});
});
+10 -7
View File
@@ -19,7 +19,7 @@ interface TaskListPanelProps {
onSelectTask: (id: number) => void;
onOpenCreate: () => void;
/**
* Owner scope (mine/all). Only meaningful when scopeEnabled — auth must be
* Owner scope (mine/others). Only meaningful when scopeEnabled — auth must be
* on and the viewer known; otherwise everything is owner 'local' and the
* control is hidden.
*/
@@ -36,12 +36,12 @@ interface TaskListPanelProps {
function ScopeToggle({
scope,
mineCount,
allCount,
othersCount,
onScopeChange,
}: {
scope: TaskScope;
mineCount: number;
allCount: number;
othersCount: number;
onScopeChange: (scope: TaskScope) => void;
}) {
const { t } = useTranslation('list');
@@ -62,7 +62,7 @@ function ScopeToggle({
return (
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
{seg('mine', t('scope.mine'), mineCount)}
{seg('all', t('scope.all'), allCount)}
{seg('others', t('scope.others'), othersCount)}
</div>
);
}
@@ -88,8 +88,11 @@ export function TaskListPanel({
const { t } = useTranslation('list');
// Owner scope is the outermost filter: status counts / search / sort all
// operate on the scoped list so "自分" mode never counts others' tasks.
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all';
const localTasks = filterTasksByScope(allTasks, effectiveScope, currentUserId);
// When the toggle is disabled (no-auth: every task is owner 'local'), the
// scope partition is meaningless — show the full list unfiltered.
const localTasks = scopeEnabled
? filterTasksByScope(allTasks, scope, currentUserId)
: allTasks;
if (mode === 'rail') {
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
@@ -169,7 +172,7 @@ export function TaskListPanel({
<ScopeToggle
scope={scope}
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
allCount={allTasks.length}
othersCount={filterTasksByScope(allTasks, 'others', currentUserId).length}
onScopeChange={onScopeChange}
/>
)}
@@ -23,6 +23,7 @@ import { NotificationsForm } from './NotificationsForm';
import { BrandingForm } from './BrandingForm';
import { MemoryLearningForm } from './MemoryLearningForm';
import { MetricsForm } from './MetricsForm';
import { ServerTlsForm } from './ServerTlsForm';
import { ReflectionForm } from './ReflectionForm';
import { McpForm } from './McpForm';
import { SshForm } from './SshForm';
@@ -195,6 +196,9 @@ function ConfigFormInner({ section }: ConfigFormProps) {
case 'gateway-server': return <GatewayServerForm {...formProps} />;
case 'llm-metrics': return <MetricsForm {...formProps} />;
// ── Server / Network
case 'server-tls': return <ServerTlsForm {...formProps} />;
// ── Agent Runtime
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
case 'context': return <ContextForm {...formProps} />;
@@ -0,0 +1,119 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
import type { SectionFormProps } from './types';
/**
* Server TLS settings — binds to the `server.tls` config object.
*
* Editable fields: enabled, certFile, keyFile, httpRedirect,
* httpRedirectPort, redirectHost, selfSignedHosts.
*
* Fields intentionally omitted from the UI (left to config.yaml):
* minVersion, selfSignedDir. The onChange path mechanism in ConfigFormInner
* uses setNestedValue which does a shallow-merge, so unedited fields are
* preserved on save automatically.
*
* TODO(server-tls-info): cert source/expiry/fingerprint panel + regenerate
* button need a GET /api/server/tls-info endpoint (future).
*/
export function ServerTlsForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
// Navigate to the server.tls sub-object; fall back to empty object if absent.
const tls = (config?.server?.tls) ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">
{t('serverTls.title')}
</h2>
{/* Restart-required banner — always visible */}
<div className="px-3 py-2.5 rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30 text-xs text-amber-800 dark:text-amber-300">
{t('serverTls.restartBanner')}
</div>
{/* Enable HTTPS */}
<div>
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={tls.enabled === true}
onChange={e => onChange('server.tls.enabled', e.target.checked)}
/>
<span>{t('serverTls.enabled')}</span>
</label>
<HelpText>{t('serverTls.enabledHelp')}</HelpText>
</div>
{/* Certificate file */}
<div>
<FieldLabel>{t('serverTls.certFile')}</FieldLabel>
<FieldInput
value={tls.certFile ?? ''}
onChange={v => onChange('server.tls.certFile', v || undefined)}
placeholder="/etc/ssl/certs/server.pem"
/>
</div>
{/* Private key file */}
<div>
<FieldLabel>{t('serverTls.keyFile')}</FieldLabel>
<FieldInput
value={tls.keyFile ?? ''}
onChange={v => onChange('server.tls.keyFile', v || undefined)}
placeholder="/etc/ssl/private/server.key"
/>
<HelpText>{t('serverTls.certHelp')}</HelpText>
</div>
{/* HTTP → HTTPS redirect */}
<div>
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={tls.httpRedirect === true}
onChange={e => onChange('server.tls.httpRedirect', e.target.checked)}
/>
<span>{t('serverTls.httpRedirect')}</span>
</label>
</div>
{/* HTTP redirect port */}
<div>
<FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel>
<FieldInput
type="number"
value={tls.httpRedirectPort != null ? String(tls.httpRedirectPort) : ''}
onChange={v => {
const n = parseInt(v, 10);
onChange('server.tls.httpRedirectPort', isNaN(n) ? undefined : n);
}}
placeholder="9080"
/>
</div>
{/* Redirect host (optional) */}
<div>
<FieldLabel>{t('serverTls.redirectHost')}</FieldLabel>
<FieldInput
value={tls.redirectHost ?? ''}
onChange={v => onChange('server.tls.redirectHost', v || undefined)}
placeholder="example.com"
/>
</div>
{/* Additional SAN hostnames for self-signed cert */}
<div>
<FieldLabel>{t('serverTls.selfSignedHosts')}</FieldLabel>
<StringArrayEditor
value={tls.selfSignedHosts ?? []}
onChange={v => onChange('server.tls.selfSignedHosts', v)}
placeholder="example.com / 10.0.0.10"
/>
</div>
</div>
);
}
@@ -90,6 +90,13 @@ const CONFIG_GROUPS = [
{ id: 'ssh', label: 'Admin SSH' },
],
},
{
label: 'Network',
adminOnly: true,
sections: [
{ id: 'server-tls', label: 'HTTPS / TLS' },
],
},
] as const;
/**
+367
View File
@@ -0,0 +1,367 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { getUsageDaily, type UsageBucket, type UsageCounters } from '../../api';
type Preset = 'last7' | 'last30' | 'last90' | 'ytd' | 'custom';
type Gran = 'day' | 'week' | 'month';
function utcToday(): string {
return new Date().toISOString().slice(0, 10);
}
function shiftDay(day: string, delta: number): string {
const x = new Date(`${day}T00:00:00.000Z`);
x.setUTCDate(x.getUTCDate() + delta);
return x.toISOString().slice(0, 10);
}
function yearStart(): string {
return `${new Date().getUTCFullYear()}-01-01`;
}
function fmtTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function total(c: UsageCounters): number {
return c.tokensIn + c.tokensOut;
}
function zeroCounters(): UsageCounters {
return { tokensIn: 0, tokensOut: 0, requests: 0 };
}
// Mirror the server's bucket keys (usage-api.ts) so we can fill empty buckets
// and keep the chart's time axis linear instead of index-based.
function isoWeekKey(day: string): string {
const d = new Date(`${day}T00:00:00.000Z`);
const dayNum = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000));
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}
function bucketKey(day: string, g: Gran): string {
if (g === 'month') return day.slice(0, 7);
if (g === 'week') return isoWeekKey(day);
return day;
}
/** Ordered, gap-free bucket list across [from, to] at the chosen granularity. */
function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran): UsageBucket[] {
const byKey = new Map(series.map((b) => [b.bucket, b]));
const out: UsageBucket[] = [];
const seen = new Set<string>();
let day = from;
let guard = 0;
while (day <= to && guard++ < 2000) {
const key = bucketKey(day, g);
if (!seen.has(key)) {
seen.add(key);
out.push(byKey.get(key) ?? { bucket: key, gateway: zeroCounters(), direct: zeroCounters() });
}
day = shiftDay(day, 1);
}
return out;
}
function rangeFor(preset: Preset, customFrom: string, customTo: string): { from: string; to: string } {
const to = utcToday();
switch (preset) {
case 'last7': return { from: shiftDay(to, -6), to };
case 'last90': return { from: shiftDay(to, -89), to };
case 'ytd': return { from: yearStart(), to };
case 'custom': return { from: customFrom || shiftDay(to, -29), to: customTo || to };
case 'last30':
default: return { from: shiftDay(to, -29), to };
}
}
const GW = '#6366f1'; // indigo-500 — gateway
const DR = '#22c55e'; // green-500 — direct
/** i18next t with interpolation support (widened from the bare key signature). */
type TFn = (key: string, opts?: Record<string, unknown>) => string;
export function UsagePage() {
const { t } = useTranslation('usage');
const [preset, setPreset] = useState<Preset>('last30');
const [granularity, setGranularity] = useState<Gran>('day');
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const { from, to } = rangeFor(preset, customFrom, customTo);
// Client-side guard: don't fire a request the server would 400 on; show an
// inline message instead of a generic error.
const customInvalid = preset === 'custom' && !!customFrom && !!customTo && customFrom > customTo;
const { data, isLoading, error } = useQuery({
queryKey: ['usage-daily', from, to, granularity],
queryFn: () => getUsageDaily({ from, to, granularity }),
enabled: !customInvalid,
});
const presets: Preset[] = ['last7', 'last30', 'last90', 'ytd', 'custom'];
const grans: Gran[] = ['day', 'week', 'month'];
const series: UsageBucket[] = data?.series ?? [];
// Gap-free buckets so the bar/line x-axis is time-linear, not index-based.
const dense = useMemo(
() => (data ? denseBuckets(series, data.from, data.to, granularity) : []),
[series, data, granularity],
);
const maxBucket = useMemo(
() => dense.reduce((m, b) => Math.max(m, total(b.gateway) + total(b.direct)), 0),
[dense],
);
const cumulative = useMemo(() => {
let run = 0;
return dense.map((b) => {
run += total(b.gateway) + total(b.direct);
return run;
});
}, [dense]);
const maxCumulative = cumulative.length ? cumulative[cumulative.length - 1] : 0;
return (
<div className="flex-1 min-h-0 overflow-auto">
<div className="max-w-5xl mx-auto p-6 space-y-5">
<header>
<h1 className="text-lg font-semibold text-slate-800 dark:text-slate-100">{t('title')}</h1>
<p className="text-[13px] text-slate-500 dark:text-slate-400 mt-1">{t('subtitle')}</p>
</header>
{/* Controls */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex gap-1">
{presets.map((p) => (
<button
key={p}
onClick={() => setPreset(p)}
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
preset === p
? 'bg-accent text-white border-accent'
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
}`}
>
{t(`range.${p}`)}
</button>
))}
</div>
{preset === 'custom' && (
<div className="flex items-center gap-1.5 text-xs">
<input type="date" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)}
aria-invalid={customInvalid}
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
<span className="text-slate-400"></span>
<input type="date" value={customTo} onChange={(e) => setCustomTo(e.target.value)}
aria-invalid={customInvalid}
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
</div>
)}
<div className="flex items-center gap-1 ml-auto">
<span className="text-xs text-slate-400 mr-1">{t('granularity.label')}</span>
{grans.map((g) => (
<button
key={g}
onClick={() => setGranularity(g)}
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
granularity === g
? 'bg-accent text-white border-accent'
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
}`}
>
{t(`granularity.${g}`)}
</button>
))}
</div>
</div>
{customInvalid && <div className="text-sm text-amber-600 dark:text-amber-400">{t('range.invalid')}</div>}
{!customInvalid && isLoading && <div className="text-sm text-slate-400 italic">{t('loading')}</div>}
{!customInvalid && error && <div className="text-sm text-red-600">{t('error')}</div>}
{!customInvalid && data && (
<>
<TotalsCards totals={data.totals} t={t} />
{series.length === 0 ? (
<div className="border border-hairline rounded-lg p-8 text-center text-sm text-slate-400 italic">
{t('empty')}
</div>
) : (
<>
<StackedBars series={dense} maxBucket={maxBucket} t={t} />
<CumulativeLine series={dense} cumulative={cumulative} max={maxCumulative} t={t} />
</>
)}
{data.byUser && data.byUser.length > 0 && <ByUserTable rows={data.byUser} t={t} />}
</>
)}
</div>
</div>
);
}
function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct: UsageCounters }; t: TFn }) {
const combined: UsageCounters = {
tokensIn: totals.gateway.tokensIn + totals.direct.tokensIn,
tokensOut: totals.gateway.tokensOut + totals.direct.tokensOut,
requests: totals.gateway.requests + totals.direct.requests,
};
const card = (label: string, c: UsageCounters, dot?: string) => (
<div className="border border-hairline rounded-lg p-3">
<div className="flex items-center gap-1.5 mb-2">
{dot && <span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: dot }} />}
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div>
<div className="text-[11px] text-slate-400">{t('totals.input')}</div>
<div className="font-mono">{fmtTokens(c.tokensIn)}</div>
</div>
<div>
<div className="text-[11px] text-slate-400">{t('totals.output')}</div>
<div className="font-mono">{fmtTokens(c.tokensOut)}</div>
</div>
<div>
<div className="text-[11px] text-slate-400">{t('totals.requests')}</div>
<div className="font-mono">{c.requests.toLocaleString()}</div>
</div>
</div>
</div>
);
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{card(t('totals.combined'), combined)}
{card(t('totals.gateway'), totals.gateway, GW)}
{card(t('totals.direct'), totals.direct, DR)}
</div>
);
}
function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucket: number; t: TFn }) {
const grand = series.reduce((s, b) => s + total(b.gateway) + total(b.direct), 0);
const ariaLabel = t('chart.barsAria', { title: t('chart.tokensTitle'), total: fmtTokens(grand), count: series.length });
return (
<div className="border border-hairline rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.tokensTitle')}</span>
<Legend t={t} />
</div>
<div className="flex items-end gap-1 h-44" role="img" aria-label={ariaLabel}>
{series.map((b) => {
const g = total(b.gateway);
const d = total(b.direct);
const sum = g + d;
const hPct = maxBucket > 0 ? (sum / maxBucket) * 100 : 0;
const gPct = sum > 0 ? (g / sum) * 100 : 0;
return (
<div key={b.bucket} className="flex-1 min-w-0 flex flex-col items-center group relative">
<div className="w-full flex flex-col justify-end" style={{ height: '100%' }}>
<div className="w-full rounded-t-sm overflow-hidden flex flex-col" style={{ height: `${Math.max(hPct, sum > 0 ? 2 : 0)}%` }}>
<div style={{ height: `${gPct}%`, background: GW }} />
<div style={{ height: `${100 - gPct}%`, background: DR }} />
</div>
</div>
{/* Tooltip (decorative — chart summary is exposed via aria-label) */}
<div aria-hidden="true" className="pointer-events-none absolute bottom-full mb-1 hidden group-hover:block z-10 whitespace-nowrap bg-slate-800 text-white text-[10px] rounded px-1.5 py-1 shadow">
<div className="font-mono">{b.bucket}</div>
<div><span style={{ color: GW }}></span> {fmtTokens(g)}</div>
<div><span style={{ color: DR }}></span> {fmtTokens(d)}</div>
</div>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 text-[10px] text-slate-400 font-mono">
<span>{series[0]?.bucket}</span>
<span>{series[series.length - 1]?.bucket}</span>
</div>
</div>
);
}
function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[]; cumulative: number[]; max: number; t: TFn }) {
const W = 600;
const H = 140;
const pad = 4;
const n = cumulative.length;
const coords = cumulative.map((v, i) => {
const x = n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad);
const y = max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad;
return { x, y };
});
const points = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ');
const ariaLabel = t('chart.lineAria', { total: fmtTokens(max) });
return (
<div className="border border-hairline rounded-lg p-4">
<div className="flex items-baseline justify-between mb-3">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.cumulativeTitle')}</span>
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(max) })}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-36" role="img" aria-label={ariaLabel}>
{/* A single bucket can't form a line — draw the point so it's visible. */}
{n === 1 ? (
<circle cx={coords[0].x} cy={coords[0].y} r={3} fill="var(--accent, #6366f1)" vectorEffect="non-scaling-stroke" />
) : (
<polyline points={points} fill="none" stroke="var(--accent, #6366f1)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
)}
</svg>
<div className="flex justify-between mt-1 text-[10px] text-slate-400 font-mono">
<span>{series[0]?.bucket}</span>
<span>{series[series.length - 1]?.bucket}</span>
</div>
</div>
);
}
function Legend({ t }: { t: TFn }) {
return (
<div className="flex items-center gap-3 text-[11px] text-slate-500">
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: GW }} />{t('chart.legendGateway')}</span>
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: DR }} />{t('chart.legendDirect')}</span>
</div>
);
}
function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: string } & UsageCounters>; t: TFn }) {
// Localize the sentinels; real users show their resolved name with the id as
// a secondary line.
const label = (r: { userId: string; displayName: string }): { primary: string; secondary?: string } => {
if (r.userId === 'local') return { primary: t('byUser.local') };
if (r.userId === 'system') return { primary: t('byUser.system') };
return { primary: r.displayName, secondary: r.displayName === r.userId ? undefined : r.userId };
};
return (
<div className="border border-hairline rounded-lg p-4">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">{t('byUser.title')}</div>
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] text-slate-400 text-left">
<th className="font-medium pb-1">{t('byUser.user')}</th>
<th className="font-medium pb-1 text-right">{t('byUser.input')}</th>
<th className="font-medium pb-1 text-right">{t('byUser.output')}</th>
<th className="font-medium pb-1 text-right">{t('byUser.requests')}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const l = label(r);
return (
<tr key={r.userId} className="border-t border-hairline">
<td className="py-1.5 text-slate-600 dark:text-slate-300">
<span>{l.primary}</span>
{l.secondary && <span className="ml-1.5 text-[11px] text-slate-400 font-mono">{l.secondary}</span>}
</td>
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensIn)}</td>
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensOut)}</td>
<td className="py-1.5 text-right font-mono">{r.requests.toLocaleString()}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}