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
+17 -9
View File
@@ -21,7 +21,7 @@ import { useBackdropClose } from './lib/useBackdropClose';
import { TopBar } from './components/layout/TopBar';
import { NavDrawer } from './components/layout/NavDrawer';
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
import { visibleNavItemsFor, useCompactNav } from './components/layout/TopBar';
import { visibleNavItemsFor } from './components/layout/TopBar';
import { ResizeHandle } from './components/layout/ResizeHandle';
import { TaskListPanel } from './components/list/TaskListPanel';
import { ChatPane } from './components/chat/ChatPane';
@@ -40,6 +40,7 @@ import { UsersPage } from './pages/UsersPage';
import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
import { SharedView } from './pages/SharedView';
import { UserFolderTab } from './components/userfolder/UserFolderTab';
import { UsagePage } from './components/usage/UsagePage';
import { HelpPage } from './pages/HelpPage';
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
import type { ConsoleStatus } from './lib/ssh-console-types';
@@ -163,7 +164,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
const hamburgerRef = useRef<HTMLButtonElement>(null);
const compactMode = useCompactNav(isAdmin, authEnabled);
// compactMode is measured by TopBar (actual fit) and reported up here so the
// nav drawer / edge-swipe stay in sync with whether the hamburger is shown.
const [compactMode, setCompactMode] = useState(false);
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const openNavDrawer = () => {
@@ -355,10 +358,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
sortMode: sort,
searchQuery: search,
activeTaskId: localTaskId,
// Owner scope (自分/すべて). Only active when auth is on — in no-auth mode
// Owner scope (自分/他のユーザ). Only active when auth is on — in no-auth mode
// every task is owned by 'local' and the toggle would be meaningless.
scope: urlState.scope,
onScopeChange: (scope: 'mine' | 'all') => setUrlState(prev => ({ ...prev, scope })),
onScopeChange: (scope: 'mine' | 'others') => setUrlState(prev => ({ ...prev, scope })),
currentUserId: user?.id ?? null,
scopeEnabled: authEnabled && !!user,
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
@@ -451,6 +454,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
hamburgerButtonRef={hamburgerRef}
navDrawerOpen={navDrawerOpen}
onOpenCommandK={() => setCmdkOpen(true)}
onCompactChange={setCompactMode}
/>
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
@@ -471,6 +475,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
{page === 'usage' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><UsagePage /></div>}
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
@@ -563,8 +568,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
)}
</div>
{/* タブレット: 2カラム (sm 〜 lg) */}
<div className="hidden sm:grid lg:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
{/* タブレット: 2カラム (sm 〜 xl)。3列デスクトップは横幅が足りないと詳細列が
狭まりタブが折り返すため、切替を xl(1280px) まで上げて中間帯はこの
2列+詳細オーバーレイで運用する。 */}
<div className="hidden sm:grid xl:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
<TaskListWithSidePanel
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
@@ -583,9 +590,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
</div>
{/* デスクトップ: >= lg (1024px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
{/* デスクトップ: >= xl (1280px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
<div
className="hidden lg:grid gap-2 p-2 h-full"
className="hidden xl:grid gap-2 p-2 h-full"
data-focused-grid={isFocused ? '1' : undefined}
style={gridStyle}
>
@@ -642,7 +649,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{/* Tablet: detail overlay */}
{tabletDetailOpen && panelOpen && (
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
<div className="hidden sm:block xl:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
{localTaskId && (
<LocalDetailPanel
@@ -675,6 +682,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
section={previewState.section}
filePath={previewState.filePath}
editable={previewState.editable}
trustedHtmlUrl={user || !authEnabled ? previewState.trustedHtmlUrl : undefined}
/>
)}
{branding.footerText && (
+61 -1
View File
@@ -99,9 +99,13 @@ export interface SubtaskActivity {
activityLog: string;
}
export type TitleSource = 'auto' | 'agent' | 'user';
export interface LocalTask {
id: number;
title: string;
/** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */
titleSource?: TitleSource;
body: string;
pieceName: string;
profile: string;
@@ -255,7 +259,7 @@ export async function postLocalTaskComment(taskId: number, body: string, author:
export async function updateLocalTask(
taskId: number,
updates: { visibility?: Visibility; visibilityScopeOrgId?: string | null },
updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
method: 'PATCH',
@@ -267,6 +271,14 @@ export async function updateLocalTask(
return data.task;
}
/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */
export async function regenerateTaskTitle(taskId: number): Promise<string> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title');
return data.title as string;
}
export async function continueTaskWithPiece(
taskId: number,
body: { piece: string; instruction: string },
@@ -319,6 +331,11 @@ export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path, trusted: '1' });
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
method: 'PUT',
@@ -1266,3 +1283,46 @@ export async function postTestNotification(): Promise<{ ok: boolean }> {
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
return notificationsJsonOrThrow(res, 'failed to send test notification');
}
// ============================================================
// LLM usage dashboard (per-user, gateway + direct).
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
export interface UsageCounters {
tokensIn: number;
tokensOut: number;
requests: number;
}
export interface UsageBucket {
bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM'
gateway: UsageCounters;
direct: UsageCounters;
}
export interface UsageByUser extends UsageCounters {
userId: string;
/** Resolved display name (real users); 'local' / 'system' for sentinels. */
displayName: string;
}
export interface UsageDailyResponse {
from: string;
to: string;
granularity: 'day' | 'week' | 'month';
scope: 'all' | 'self';
series: UsageBucket[];
totals: { gateway: UsageCounters; direct: UsageCounters };
byUser?: UsageByUser[]; // admin / local mode only
}
export async function getUsageDaily(params: {
from?: string;
to?: string;
granularity?: 'day' | 'week' | 'month';
}): Promise<UsageDailyResponse> {
const qs = new URLSearchParams();
if (params.from) qs.set('from', params.from);
if (params.to) qs.set('to', params.to);
if (params.granularity) qs.set('granularity', params.granularity);
const q = qs.toString();
const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`);
if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
return res.json();
}
+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>
);
}
+7 -1
View File
@@ -4,6 +4,7 @@ import {
fetchLocalFileContent,
fetchSubtaskFileContent,
getLocalFileRawUrl,
getTrustedLocalHtmlUrl,
subtaskFileRawUrl,
} from '../api';
import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable } from '../lib/utils';
@@ -17,6 +18,7 @@ export interface PreviewState {
section?: string;
filePath?: string;
editable?: boolean;
trustedHtmlUrl?: string;
}
export function useFilePreview(onError: (msg: string) => void) {
@@ -32,7 +34,11 @@ export function useFilePreview(onError: (msg: string) => void) {
try {
const canEdit = section === 'output' && isTextPreviewable(name);
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
setPreviewState({ name, content: '', imageSrc: getLocalFileRawUrl(taskId, section, filePath), taskId, section, filePath, editable: false });
const imageSrc = getLocalFileRawUrl(taskId, section, filePath);
const trustedHtmlUrl = isHtmlPreviewable(name)
? getTrustedLocalHtmlUrl(taskId, section, filePath)
: undefined;
setPreviewState({ name, content: '', imageSrc, trustedHtmlUrl, taskId, section, filePath, editable: false });
return;
}
const content = await fetchLocalFileContent(taskId, section, filePath);
+1
View File
@@ -21,6 +21,7 @@
},
"focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" },
"feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" },
"title": { "edit": "Edit title", "regenerate": "Regenerate title with AI", "save": "Save", "saving": "Saving...", "cancel": "Cancel" },
"mission": {
"pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save",
"emptyHelp": "No Mission Brief yet. The agent writes here automatically as needed, but pinning the goal / progress / remaining work manually keeps the essence in view even through long conversations.",
+1
View File
@@ -11,6 +11,7 @@
"preparing": "Preparing...",
"button": "PDF / Print"
},
"trustedHtml": { "button": "Open as trusted", "tooltip": "Open in a new tab with scripts allowed" },
"cancel": "Cancel",
"saving": "Saving...",
"save": "Save",
+2 -1
View File
@@ -9,7 +9,8 @@
"settings": "Settings",
"users": "Users",
"help": "Help",
"userfolder": "User Folder"
"userfolder": "User Folder",
"usage": "Usage"
},
"commandPalette": {
"open": "Open command palette",
+1 -1
View File
@@ -41,6 +41,6 @@
"scope": {
"aria": "Task display scope",
"mine": "Mine",
"all": "All"
"others": "Others"
}
}
+13
View File
@@ -779,5 +779,18 @@
},
"settingsPage": {
"sectionList": "Section list"
},
"serverTls": {
"title": "HTTPS / TLS",
"enabled": "Serve over HTTPS",
"enabledHelp": "Terminate TLS in the app. Self-signed by default — browsers (and the noVNC / SSH console over wss) will warn until you install a real certificate. Requires a restart to apply.",
"certFile": "Certificate file (PEM)",
"keyFile": "Private key file (PEM)",
"certHelp": "Leave both empty to use an auto-generated self-signed certificate.",
"httpRedirect": "Redirect HTTP to HTTPS",
"httpRedirectPort": "HTTP redirect port",
"redirectHost": "Redirect host (optional)",
"selfSignedHosts": "Additional certificate hostnames",
"restartBanner": "Changes to HTTPS settings require a server restart to take effect."
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"title": "LLM Usage",
"subtitle": "Combined token usage across AAO Gateway and direct calls, aggregated per UTC day. This is a separate view from the gateway per-key billing panel.",
"range": {
"last7": "Last 7 days",
"last30": "Last 30 days",
"last90": "Last 90 days",
"ytd": "Year to date",
"custom": "Custom",
"invalid": "Start date must be on or before the end date."
},
"granularity": {
"label": "Granularity",
"day": "Day",
"week": "Week",
"month": "Month"
},
"totals": {
"input": "Input tokens",
"output": "Output tokens",
"requests": "Requests",
"gateway": "Gateway",
"direct": "Direct",
"combined": "Combined"
},
"chart": {
"tokensTitle": "Tokens by period (gateway vs direct)",
"cumulativeTitle": "Cumulative tokens",
"legendGateway": "Gateway",
"legendDirect": "Direct",
"total": "Total {{value}}",
"barsAria": "{{title}}: {{total}} tokens total across {{count}} periods",
"lineAria": "Cumulative tokens over time, reaching {{total}} total"
},
"byUser": {
"title": "By user",
"user": "User",
"input": "Input",
"output": "Output",
"requests": "Requests",
"local": "Local",
"system": "System"
},
"empty": "No usage recorded in this range yet. Aggregation starts from the day this feature went live; past usage cannot be backfilled.",
"loading": "Loading usage…",
"error": "Failed to load usage."
}
+1
View File
@@ -21,6 +21,7 @@
},
"focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" },
"feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" },
"title": { "edit": "タイトルを編集", "regenerate": "AIでタイトルを再生成", "save": "保存", "saving": "保存中...", "cancel": "キャンセル" },
"mission": {
"pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存",
"emptyHelp": "まだ Mission Brief は設定されていません。エージェントが必要に応じて自動で書き込みますが、手動で目標 / 進捗 / 残タスクをここに固定しておくことで、長い会話の途中でも本質を見失わないように誘導できます。",
+1
View File
@@ -11,6 +11,7 @@
"preparing": "準備中...",
"button": "PDF / 印刷"
},
"trustedHtml": { "button": "信頼済みとして開く", "tooltip": "スクリプトを許可して別タブで開く" },
"cancel": "キャンセル",
"saving": "保存中...",
"save": "保存",
+2 -1
View File
@@ -9,7 +9,8 @@
"settings": "設定",
"users": "ユーザー",
"help": "ヘルプ",
"userfolder": "ユーザーフォルダ"
"userfolder": "ユーザーフォルダ",
"usage": "使用量"
},
"commandPalette": {
"open": "コマンドパレットを開く",
+1 -1
View File
@@ -41,6 +41,6 @@
"scope": {
"aria": "タスクの表示範囲",
"mine": "自分",
"all": "すべて"
"others": "他のユーザ"
}
}
+13
View File
@@ -779,5 +779,18 @@
},
"settingsPage": {
"sectionList": "セクション一覧"
},
"serverTls": {
"title": "HTTPS / TLS",
"enabled": "HTTPS で配信",
"enabledHelp": "アプリ内で TLS を終端します。デフォルトは自己署名証明書のため、正式な証明書を導入するまでブラウザ(および wss 経由の noVNC・SSH コンソール)に警告が表示されます。反映にはサーバーの再起動が必要です。",
"certFile": "証明書ファイル(PEM",
"keyFile": "秘密鍵ファイル(PEM",
"certHelp": "両方を空欄にすると、自動生成の自己署名証明書を使用します。",
"httpRedirect": "HTTP を HTTPS へリダイレクト",
"httpRedirectPort": "HTTP リダイレクトポート",
"redirectHost": "リダイレクト先ホスト(省略可)",
"selfSignedHosts": "証明書の追加ホスト名",
"restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。"
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"title": "LLM 使用量",
"subtitle": "AAO Gateway 経由と Direct を合算したトークン使用量を UTC 日次で集計します。ゲートウェイのキー別課金パネルとは別集計です。",
"range": {
"last7": "直近7日",
"last30": "直近30日",
"last90": "直近90日",
"ytd": "年初来",
"custom": "カスタム",
"invalid": "開始日は終了日以前にしてください。"
},
"granularity": {
"label": "粒度",
"day": "日",
"week": "週",
"month": "月"
},
"totals": {
"input": "入力トークン",
"output": "出力トークン",
"requests": "リクエスト",
"gateway": "Gateway",
"direct": "Direct",
"combined": "合計"
},
"chart": {
"tokensTitle": "期間別トークン(Gateway / Direct",
"cumulativeTitle": "累積トークン",
"legendGateway": "Gateway",
"legendDirect": "Direct",
"total": "合計 {{value}}",
"barsAria": "{{title}}:合計 {{total}} トークン、{{count}} 期間",
"lineAria": "累積トークンの推移、合計 {{total}} に到達"
},
"byUser": {
"title": "ユーザー別",
"user": "ユーザー",
"input": "入力",
"output": "出力",
"requests": "リクエスト",
"local": "ローカル",
"system": "システム"
},
"empty": "この期間の使用量はまだありません。集計はこの機能の稼働開始日から始まり、過去分は遡って集計できません。",
"loading": "使用量を読み込み中…",
"error": "使用量の読み込みに失敗しました。"
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { cronToFormState } from './cronForm';
/**
* The backend's convertToCron (src/scheduler.ts) — mirrored here so the test
* asserts a true round-trip: editing a saved schedule and saving it back must
* not change the cron expression.
*/
function convertToCron(f: ReturnType<typeof cronToFormState>): string {
const m = f.minute ?? 0;
const h = f.hour ?? 0;
switch (f.scheduleType) {
case 'daily': return `${m} ${h} * * *`;
case 'weekly': return `${m} ${h} * * ${f.dayOfWeek ?? 0}`;
case 'monthly': return `${m} ${h} ${f.dayOfMonth ?? 1} * *`;
case 'cron': return f.cronExpression;
case 'once': return 'once';
}
}
describe('cronToFormState', () => {
it('classifies simple presets (daily/weekly/monthly)', () => {
expect(cronToFormState('0 9 * * *')).toMatchObject({ scheduleType: 'daily', hour: 9, minute: 0 });
expect(cronToFormState('30 14 * * *')).toMatchObject({ scheduleType: 'daily', hour: 14, minute: 30 });
expect(cronToFormState('0 9 * * 3')).toMatchObject({ scheduleType: 'weekly', hour: 9, minute: 0, dayOfWeek: 3 });
expect(cronToFormState('0 9 15 * *')).toMatchObject({ scheduleType: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 });
});
it("keeps custom expressions as 'cron' and preserves the raw expression", () => {
for (const expr of [
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *', '15 9 1,15 * *', '0 9 1 6 *',
'0 9 * * 7', // Sunday-as-7 is out of the editor's 0-6 select range
'0 9 * * *', // double space → 6 tokens, not a clean preset
]) {
const f = cronToFormState(expr);
expect(f.scheduleType).toBe('cron');
expect(f.cronExpression).toBe(expr);
}
});
it('classifies Sunday-as-0 weekly', () => {
expect(cronToFormState('0 9 * * 0')).toMatchObject({ scheduleType: 'weekly', dayOfWeek: 0 });
});
it('never produces NaN fields for custom expressions', () => {
const f = cronToFormState('0 9 * * 1-5');
expect(Number.isNaN(f.hour)).toBe(false);
expect(Number.isNaN(f.minute)).toBe(false);
expect(Number.isNaN(f.dayOfWeek)).toBe(false);
expect(Number.isNaN(f.dayOfMonth)).toBe(false);
});
it("maps 'once' to the once type", () => {
expect(cronToFormState('once')).toMatchObject({ scheduleType: 'once' });
});
it('treats a malformed (non 5-field) string as a raw cron expression', () => {
expect(cronToFormState('garbage')).toMatchObject({ scheduleType: 'cron', cronExpression: 'garbage' });
});
it('round-trips losslessly through the backend convertToCron', () => {
const cases = [
'0 9 * * *', '30 14 * * *', '0 9 * * 3', '0 9 15 * *',
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *',
'15 9 1,15 * *', '0 9 1 6 *', 'once',
];
for (const expr of cases) {
expect(convertToCron(cronToFormState(expr))).toBe(expr);
}
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* cronForm.ts — 保存済み cron 文字列を、スケジュール編集フォームのフィールドに
* 復元する純関数。
*
* スケジュールは DB 上ではすべて cron 文字列なので、編集時にどの「種別」
* (daily / weekly / monthly / cron / once) として表示するかをここで判定する。
*
* 重要: daily/weekly/monthly のプリセットへ変換するのは、対象フィールドが
* **単純な整数** (例: `0 9 * * *`) のときだけ。範囲 (`1-5`) / ステップ (`*/30`) /
* リスト (`9,17`) / 非 `*` の月を含む式は、プリセットでは表現できない。これらを
* 無理にプリセット化すると `Number()` が NaN を返してフィールドが壊れ、かつ
* 元の cron 式が失われる。そうした式は `cron` 種別のまま生の式を保持する。
*/
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'cron' | 'once';
export interface ScheduleFields {
scheduleType: ScheduleType;
hour: number;
minute: number;
dayOfWeek: number;
dayOfMonth: number;
cronExpression: string;
}
const isInt = (s: string): boolean => /^\d+$/.test(s);
const inRange = (s: string, lo: number, hi: number): boolean => {
if (!isInt(s)) return false;
const n = Number(s);
return n >= lo && n <= hi;
};
export function cronToFormState(cron: string): ScheduleFields {
// 任意の cron 文字列をそのまま保持するフォールバック (生の式を捨てない)。
const asCron: ScheduleFields = {
scheduleType: 'cron',
hour: 9,
minute: 0,
dayOfWeek: 1,
dayOfMonth: 1,
cronExpression: cron,
};
if (cron === 'once') {
return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
}
const parts = cron.split(' ');
if (parts.length !== 5) return asCron;
const [min, hour, dom, mon, dow] = parts;
// プリセットは「分・時が整数」かつ「月は毎月 (*)」が前提。満たさなければ cron 扱い。
if (mon !== '*' || !isInt(min) || !isInt(hour)) return asCron;
const h = Number(hour);
const m = Number(min);
if (dom === '*' && dow === '*') {
return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
}
// weekly/monthly only when the day value is within the editor's select range
// (dow 0-6, dom 1-31). Out-of-range forms like `* * * * 7` (Sunday-as-7) stay
// as raw cron so re-saving never rewrites them to a different value.
if (dom === '*' && inRange(dow, 0, 6)) {
return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
}
if (inRange(dom, 1, 31) && dow === '*') {
return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
}
// dom と dow が両方指定など、プリセットに当てはまらない組み合わせは cron 扱い。
return asCron;
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { splitFileName, fileCategory, categoryColorClass } from './fileType';
describe('splitFileName', () => {
it('splits a normal name into stem + ext (ext keeps the dot)', () => {
expect(splitFileName('report.xlsx')).toEqual({ stem: 'report', ext: '.xlsx' });
});
it('uses the LAST dot for multi-dot names', () => {
expect(splitFileName('archive.tar.gz')).toEqual({ stem: 'archive.tar', ext: '.gz' });
});
it('treats a leading-dot dotfile as having no extension', () => {
expect(splitFileName('.gitignore')).toEqual({ stem: '.gitignore', ext: '' });
});
it('returns empty ext when there is no dot', () => {
expect(splitFileName('README')).toEqual({ stem: 'README', ext: '' });
});
});
describe('fileCategory', () => {
it('maps known extensions (case-insensitive)', () => {
expect(fileCategory('a.PNG')).toBe('image');
expect(fileCategory('a.pdf')).toBe('pdf');
expect(fileCategory('a.xlsx')).toBe('spreadsheet');
expect(fileCategory('a.md')).toBe('document');
expect(fileCategory('a.pptx')).toBe('presentation');
expect(fileCategory('main.ts')).toBe('code');
expect(fileCategory('a.zip')).toBe('archive');
expect(fileCategory('a.mp3')).toBe('audio');
expect(fileCategory('a.mp4')).toBe('video');
});
it('falls back to other for unknown or missing extensions', () => {
expect(fileCategory('a.unknownext')).toBe('other');
expect(fileCategory('README')).toBe('other');
expect(fileCategory('.gitignore')).toBe('other');
});
});
describe('categoryColorClass', () => {
it('returns a literal tailwind text-color class for every category', () => {
for (const cat of ['image', 'pdf', 'spreadsheet', 'document', 'presentation', 'code', 'archive', 'audio', 'video', 'other'] as const) {
expect(categoryColorClass(cat)).toMatch(/^text-\w+-\d{3}$/);
}
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* fileType.ts — ファイル名から拡張子・種別カテゴリ・表示色を導く純関数。
*
* ファイルタブで (a) 種別ごとの色付きアイコン表示、(b) 長い名前でも拡張子を
* 常に見せる中間省略表示、の両方に使う。
*/
export type FileCategory =
| 'image'
| 'pdf'
| 'spreadsheet'
| 'document'
| 'presentation'
| 'code'
| 'archive'
| 'audio'
| 'video'
| 'other';
/**
* ファイル名を「本体」と「拡張子」に分割する。拡張子には先頭の `.` を含む。
* - `report.xlsx` → { stem: 'report', ext: '.xlsx' }
* - `archive.tar.gz` → { stem: 'archive.tar', ext: '.gz' }(最後のドット基準)
* - `.gitignore`(ドットファイル)→ { stem: '.gitignore', ext: '' }
* - `README`(拡張子なし)→ { stem: 'README', ext: '' }
*/
export function splitFileName(name: string): { stem: string; ext: string } {
const dot = name.lastIndexOf('.');
if (dot <= 0) return { stem: name, ext: '' }; // 先頭ドット(ドットファイル) or ドット無し
return { stem: name.slice(0, dot), ext: name.slice(dot) };
}
// 拡張子(小文字・ドット無し) → カテゴリ
const EXT_CATEGORY: Record<string, FileCategory> = {};
const register = (cat: FileCategory, exts: string[]) => {
for (const e of exts) EXT_CATEGORY[e] = cat;
};
register('image', ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tif', 'tiff', 'heic']);
register('pdf', ['pdf']);
register('spreadsheet', ['xlsx', 'xls', 'xlsm', 'csv', 'tsv', 'ods']);
register('document', ['doc', 'docx', 'odt', 'rtf', 'txt', 'md', 'markdown', 'log']);
register('presentation', ['ppt', 'pptx', 'odp', 'key']);
register('code', [
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt',
'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'php', 'swift', 'sh', 'bash', 'zsh', 'sql',
'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'htm', 'css', 'scss', 'less', 'ipynb',
]);
register('archive', ['zip', 'tar', 'gz', 'tgz', 'rar', '7z', 'bz2', 'xz', 'zst']);
register('audio', ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus']);
register('video', ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
export function fileCategory(name: string): FileCategory {
const { ext } = splitFileName(name);
if (!ext) return 'other';
return EXT_CATEGORY[ext.slice(1).toLowerCase()] ?? 'other';
}
// Tailwind の content スキャンに拾わせるため、色クラスはリテラル文字列で持つ
// (動的に `text-${x}-500` を組み立てると JIT が purge する)。
const CATEGORY_COLOR: Record<FileCategory, string> = {
image: 'text-cyan-500',
pdf: 'text-red-500',
spreadsheet: 'text-green-600',
document: 'text-blue-500',
presentation: 'text-orange-500',
code: 'text-indigo-500',
archive: 'text-amber-500',
audio: 'text-purple-500',
video: 'text-pink-500',
other: 'text-slate-400',
};
export function categoryColorClass(cat: FileCategory): string {
return CATEGORY_COLOR[cat];
}
+11 -3
View File
@@ -14,12 +14,20 @@ describe('filterTasksByScope', () => {
expect(filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id)).toEqual([1, 3]);
});
it("scope='all' returns everything", () => {
expect(filterTasksByScope(tasks, 'all', 'alice')).toHaveLength(5);
it("scope='others' keeps everyone else's tasks (incl. legacy null owners)", () => {
expect(filterTasksByScope(tasks, 'others', 'alice').map(t => t.id)).toEqual([2, 4, 5]);
});
it('no current user (auth disabled) returns everything even for mine', () => {
it('mine + others partition the full list with no overlap', () => {
const mine = filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id);
const others = filterTasksByScope(tasks, 'others', 'alice').map(t => t.id);
expect([...mine, ...others].sort()).toEqual([1, 2, 3, 4, 5]);
expect(mine.filter(id => others.includes(id))).toEqual([]);
});
it('no current user (auth disabled) returns everything for either scope', () => {
expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5);
expect(filterTasksByScope(tasks, 'others', null)).toHaveLength(5);
});
it('legacy null/undefined owners never match mine', () => {
+10 -7
View File
@@ -2,20 +2,23 @@
* taskScope.ts — タスク一覧の所有者スコープフィルタ。
*
* visibility (public / org) のせいで他ユーザーのタスクが一覧に混ざり
* 「自分のタスクを見失う」問題への対策。'mine' は ownerId が自分のものだけを
* 残す。認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので
* 全件を返す。
* 「自分のタスクを見失う」問題への対策。重複のない2分割:
* 'mine' — ownerId が自分のものだけ
* 'others' — それ以外 (他ユーザー + legacy null owner)
* 認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので全件を返す。
*/
export type TaskScope = 'mine' | 'all';
export type TaskScope = 'mine' | 'others';
export const TASK_SCOPES: TaskScope[] = ['mine', 'all'];
export const TASK_SCOPES: TaskScope[] = ['mine', 'others'];
export function filterTasksByScope<T extends { ownerId?: string | null }>(
tasks: T[],
scope: TaskScope,
currentUserId: string | null,
): T[] {
if (scope !== 'mine' || !currentUserId) return tasks;
return tasks.filter(t => t.ownerId === currentUserId);
if (!currentUserId) return tasks;
return scope === 'mine'
? tasks.filter(t => t.ownerId === currentUserId)
: tasks.filter(t => t.ownerId !== currentUserId);
}
+1 -1
View File
@@ -139,7 +139,7 @@ describe('buildUiUrlStateSearch', () => {
status: 'waiting_human',
search: 'foo bar',
sort: 'status',
scope: 'all',
scope: 'others',
detailTab: 'trace',
mobileTab: 'files',
taskId: 99,
+6 -5
View File
@@ -2,7 +2,7 @@ const COLUMNS = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retr
const DETAIL_TABS = ['overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
const MOBILE_TABS = ['chat', 'overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
const SORT_MODES = ['updated', 'status', 'title'] as const;
const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'help'] as const;
const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'usage', 'help'] as const;
const SETTINGS_SECTIONS = [
// User group
'preferences',
@@ -76,8 +76,9 @@ export interface UiUrlState {
status: 'all' | StatusColumn;
search: string;
sort: SortMode;
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on). */
scope: 'mine' | 'all';
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on),
* 'others' = everyone else's tasks. */
scope: 'mine' | 'others';
detailTab: DetailTabId;
mobileTab: MobileTabId;
taskId: number | null;
@@ -131,7 +132,7 @@ export function readUiUrlState(): UiUrlState {
: 'all',
search: params.get('q') ?? '',
sort: sort && SORT_MODES.includes(sort as SortMode) ? sort as SortMode : 'updated',
scope: params.get('scope') === 'all' ? 'all' : 'mine',
scope: params.get('scope') === 'others' ? 'others' : 'mine',
detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId)
? detailTab as DetailTabId
: 'overview',
@@ -154,7 +155,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
if (state.status !== 'all') params.set('status', state.status);
if (state.search) params.set('q', state.search);
if (state.sort !== 'updated') params.set('sort', state.sort);
if (state.scope === 'all') params.set('scope', 'all');
if (state.scope === 'others') params.set('scope', 'others');
if (state.detailTab !== 'overview') params.set('tab', state.detailTab);
if (state.mobileTab !== 'chat') params.set('mobileTab', state.mobileTab);
if (state.taskId) params.set('task', String(state.taskId));
+5 -12
View File
@@ -8,6 +8,7 @@ import { StatChip } from '../components/shared/StatChip';
import { usePieceList } from '../hooks/usePieces';
import { resolvePieceOptions } from '../lib/splitPieces';
import { ownerDisplayName } from '../lib/owner';
import { cronToFormState } from '../lib/cronForm';
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
import { useAuthState } from '../App';
@@ -95,18 +96,10 @@ function parseCronToDisplay(cron: string, t: TFunction): string {
return cron;
}
function cronToFormState(cron: string): Pick<ScheduleFormState, 'scheduleType' | 'hour' | 'minute' | 'dayOfWeek' | 'dayOfMonth' | 'cronExpression'> {
if (cron === 'once') return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
const parts = cron.split(' ');
if (parts.length !== 5) return { scheduleType: 'cron', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron };
const [min, hour, dom, , dow] = parts;
const h = Number(hour), m = Number(min);
if (dom !== '*' && dow === '*') return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
if (dow !== '*' && dom === '*') return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
if (dom === '*' && dow === '*') return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
return { scheduleType: 'cron', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron };
}
// cronToFormState lives in ../lib/cronForm so it can be unit-tested in isolation.
// It only maps to daily/weekly/monthly presets when every field is a simple
// integer; custom expressions (ranges/steps/lists) stay as raw 'cron' so editing
// a saved schedule never mangles it to NaN or drops the expression.
function taskToFormState(task: ScheduledTask): ScheduleFormState {
const cronState = cronToFormState(task.cronExpression);