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

This commit is contained in:
oss-sync
2026-06-10 08:40:41 +00:00
parent dfc5950117
commit 5502478636
43 changed files with 6452 additions and 18 deletions
+6
View File
@@ -353,6 +353,12 @@ 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
// every task is owned by 'local' and the toggle would be meaningless.
scope: urlState.scope,
onScopeChange: (scope: 'mine' | 'all') => setUrlState(prev => ({ ...prev, scope })),
currentUserId: user?.id ?? null,
scopeEnabled: authEnabled && !!user,
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
onSortChange: (s: string) => setUrlState(prev => ({ ...prev, sort: s as typeof sort })),
onSearchChange: (q: string) => setUrlState(prev => ({ ...prev, search: q })),
+60 -1
View File
@@ -1,6 +1,7 @@
import { LocalTask } from '../../api';
import { matchText } from '../../lib/utils';
import { COLUMN_LIST, SortMode, StatusColumn } from '../../lib/urlState';
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
import { FilterBar } from './FilterBar';
import { LocalTaskListItem } from './TaskListItem';
import { RailPanel } from './RailPanel';
@@ -16,14 +17,56 @@ interface TaskListPanelProps {
onSearchChange: (q: string) => void;
onSelectTask: (id: number) => void;
onOpenCreate: () => void;
/**
* Owner scope (mine/all). Only meaningful when scopeEnabled — auth must be
* on and the viewer known; otherwise everything is owner 'local' and the
* control is hidden.
*/
scope?: TaskScope;
onScopeChange?: (scope: TaskScope) => void;
currentUserId?: string | null;
scopeEnabled?: boolean;
/** 'rail' 時は RailPanel を render する。default 'list'。 */
mode?: 'list' | 'rail';
/** rail mode 時の「リストに戻る」ボタンで呼ばれる。 */
onExitFocused?: () => void;
}
function ScopeToggle({
scope,
mineCount,
allCount,
onScopeChange,
}: {
scope: TaskScope;
mineCount: number;
allCount: number;
onScopeChange: (scope: TaskScope) => void;
}) {
const seg = (value: TaskScope, label: string, count: number) => (
<button
type="button"
onClick={() => onScopeChange(value)}
aria-pressed={scope === value}
className={`flex-1 px-2 py-1 rounded text-[11px] font-medium transition-colors tabular-nums ${
scope === value
? 'bg-surface text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{label} <span className={scope === value ? 'text-slate-500' : 'text-slate-400'}>{count}</span>
</button>
);
return (
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label="タスクの表示範囲">
{seg('mine', '自分', mineCount)}
{seg('all', 'すべて', allCount)}
</div>
);
}
export function TaskListPanel({
localTasks,
localTasks: allTasks,
selectedStatus,
sortMode,
searchQuery,
@@ -33,9 +76,17 @@ export function TaskListPanel({
onSearchChange,
onSelectTask,
onOpenCreate,
scope = 'mine',
onScopeChange,
currentUserId = null,
scopeEnabled = false,
mode = 'list',
onExitFocused,
}: TaskListPanelProps) {
// 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);
if (mode === 'rail') {
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
@@ -111,6 +162,14 @@ export function TaskListPanel({
</svg>
</button>
{scopeEnabled && onScopeChange && (
<ScopeToggle
scope={scope}
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
allCount={allTasks.length}
onScopeChange={onScopeChange}
/>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-500 px-0.5 pb-2.5 font-mono tabular-nums">
<span><span className="font-semibold text-slate-700">{totalCount}</span> </span>
<span aria-hidden="true" className="text-slate-300">·</span>
+157
View File
@@ -0,0 +1,157 @@
// NOTE: renderHelpHtml is NOT covered here — it depends on DOMPurify, which
// requires a real DOM (DOMPurify.isSupported === false in this node test
// environment, sanitize is not callable). Everything else in help.ts is pure.
import { describe, it, expect } from 'vitest';
import {
splitFrontmatter,
validateFrontmatter,
parseHelpDoc,
slugify,
makeSlugger,
filterSections,
type HelpSection,
} from './help';
const DOC = `---
id: getting-started
title: はじめに
category: basic
order: 1
keywords: [setup, 初期設定]
---
# はじめに
本文です。`;
describe('splitFrontmatter', () => {
it('splits the frontmatter block from the body', () => {
const { frontmatter, body } = splitFrontmatter(DOC);
expect(frontmatter).toContain('id: getting-started');
expect(body).toContain('# はじめに');
expect(body).not.toContain('---');
});
it('returns the whole input as body when there is no frontmatter', () => {
const { frontmatter, body } = splitFrontmatter('plain body');
expect(frontmatter).toBe('');
expect(body).toBe('plain body');
});
it('normalizes CRLF and strips a BOM', () => {
const { frontmatter, body } = splitFrontmatter('---\r\nid: x\r\n---\r\nbody');
expect(frontmatter).toBe('id: x');
expect(body).toBe('body');
});
it('ignores a frontmatter block not at the very start', () => {
const { frontmatter } = splitFrontmatter('intro\n---\nid: x\n---\n');
expect(frontmatter).toBe('');
});
});
describe('validateFrontmatter', () => {
const VALID = { id: 'a', title: 't', category: 'basic', order: 1, keywords: ['k'] };
it('accepts a valid frontmatter object', () => {
const r = validateFrontmatter(VALID, 'doc.md');
expect(r.ok).toBe(true);
expect(r.value).toEqual(VALID);
});
it('defaults keywords to an empty array', () => {
const r = validateFrontmatter({ ...VALID, keywords: undefined }, 'doc.md');
expect(r.ok).toBe(true);
expect(r.value?.keywords).toEqual([]);
});
it.each([
['missing id', { ...VALID, id: '' }, /'id' is required/],
['missing title', { ...VALID, title: undefined }, /'title' is required/],
['bad category', { ...VALID, category: 'misc' }, /'category' must be one of/],
['bad order', { ...VALID, order: 'first' }, /'order' is required/],
['bad keywords', { ...VALID, keywords: [1] }, /'keywords' must be a string array/],
])('rejects %s with a sourced error', (_label, data, re) => {
const r = validateFrontmatter(data, 'doc.md');
expect(r.ok).toBe(false);
expect(r.errors.join('\n')).toMatch(re);
expect(r.errors[0]).toContain('doc.md');
});
it('collects multiple errors for non-object input', () => {
const r = validateFrontmatter(null, 'doc.md');
expect(r.ok).toBe(false);
expect(r.errors.length).toBeGreaterThanOrEqual(3);
});
});
describe('parseHelpDoc', () => {
it('parses YAML frontmatter and body', () => {
const { data, body } = parseHelpDoc(DOC);
expect(data).toMatchObject({ id: 'getting-started', category: 'basic', keywords: ['setup', '初期設定'] });
expect(body).toContain('本文です。');
});
it('returns an empty object for a doc without frontmatter', () => {
expect(parseHelpDoc('just text').data).toEqual({});
});
it('throws on YAML syntax errors', () => {
expect(() => parseHelpDoc('---\nid: [unclosed\n---\nbody')).toThrow();
});
});
describe('slugify', () => {
it('lowercases and hyphenates ascii text', () => {
expect(slugify('Getting Started!')).toBe('getting-started');
});
it('keeps Japanese characters', () => {
expect(slugify('タスクの作成 方法')).toBe('タスクの作成-方法');
});
it('strips HTML tags', () => {
expect(slugify('<code>config.yaml</code> settings')).toBe('config-yaml-settings');
});
it('falls back to "section" for empty results', () => {
expect(slugify('!!!')).toBe('section');
});
});
describe('makeSlugger', () => {
it('appends -2, -3 on collisions', () => {
const slug = makeSlugger();
expect(slug('Setup')).toBe('setup');
expect(slug('Setup')).toBe('setup-2');
expect(slug('Setup')).toBe('setup-3');
expect(slug('Other')).toBe('other');
});
});
describe('filterSections', () => {
const sections: HelpSection[] = [
{ id: 'a', title: 'タスクの作成', category: 'basic', order: 1, keywords: ['task'], body: '作成手順' },
{ id: 'b', title: 'Scheduling', category: 'advanced', order: 2, keywords: ['cron'], body: 'periodic runs' },
{ id: 'c', title: 'Admin', category: 'admin', order: 3, keywords: [], body: 'ユーザー管理と権限' },
];
it('returns everything for an empty query', () => {
expect(filterSections(sections, ' ')).toEqual(sections);
});
it('matches the title case-insensitively', () => {
expect(filterSections(sections, 'scheduling').map((s) => s.id)).toEqual(['b']);
});
it('matches keywords', () => {
expect(filterSections(sections, 'CRON').map((s) => s.id)).toEqual(['b']);
});
it('matches the body, including Japanese', () => {
expect(filterSections(sections, '権限').map((s) => s.id)).toEqual(['c']);
});
it('returns an empty list when nothing matches', () => {
expect(filterSections(sections, 'zzz')).toEqual([]);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { filterTasksByScope } from './taskScope';
const tasks = [
{ id: 1, ownerId: 'alice' },
{ id: 2, ownerId: 'bob' },
{ id: 3, ownerId: 'alice' },
{ id: 4, ownerId: null }, // legacy no-auth row
{ id: 5 }, // ownerId undefined
];
describe('filterTasksByScope', () => {
it("scope='mine' keeps only the current user's tasks", () => {
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('no current user (auth disabled) returns everything even for mine', () => {
expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5);
});
it('legacy null/undefined owners never match mine', () => {
expect(filterTasksByScope(tasks, 'mine', 'bob').map(t => t.id)).toEqual([2]);
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* taskScope.ts — タスク一覧の所有者スコープフィルタ。
*
* visibility (public / org) のせいで他ユーザーのタスクが一覧に混ざり
* 「自分のタスクを見失う」問題への対策。'mine' は ownerId が自分のものだけを
* 残す。認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので
* 全件を返す。
*/
export type TaskScope = 'mine' | 'all';
export const TASK_SCOPES: TaskScope[] = ['mine', 'all'];
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);
}
+26
View File
@@ -0,0 +1,26 @@
// NOTE: useUnsavedGuard (the React hook) needs a component render and a DOM,
// neither of which exist in this node test environment — so the dirty-state
// paths cannot be registered here. These tests cover the module's exported
// behavior with an empty checker registry, including that window.confirm is
// NOT shown when nothing is dirty.
import { describe, it, expect, vi, afterEach } from 'vitest';
import { hasUnsavedChanges, confirmDiscardUnsaved } from './unsavedGuard';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('hasUnsavedChanges', () => {
it('is false when no view registered a checker', () => {
expect(hasUnsavedChanges()).toBe(false);
});
});
describe('confirmDiscardUnsaved', () => {
it('proceeds without prompting when nothing is dirty', () => {
const confirm = vi.fn().mockReturnValue(false);
vi.stubGlobal('window', { confirm });
expect(confirmDiscardUnsaved()).toBe(true);
expect(confirm).not.toHaveBeenCalled();
});
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
readUiUrlState,
buildUiUrlStateSearch,
paramsEqualExcept,
COLUMN_LABELS,
COLUMN_LIST,
type UiUrlState,
} from './urlState';
const DEFAULT_STATE: UiUrlState = {
page: 'tasks',
repo: '',
status: 'all',
search: '',
sort: 'updated',
scope: 'mine',
detailTab: 'overview',
mobileTab: 'chat',
taskId: null,
};
function stubLocation(search: string) {
vi.stubGlobal('window', { location: { search } });
}
afterEach(() => vi.unstubAllGlobals());
describe('readUiUrlState', () => {
it('returns defaults when window is undefined (SSR/node)', () => {
// node environment: no window global by default
expect(readUiUrlState()).toEqual(DEFAULT_STATE);
});
it('returns defaults for an empty query string', () => {
stubLocation('');
const state = readUiUrlState();
expect(state).toEqual({
...DEFAULT_STATE,
section: undefined,
piece: undefined,
});
});
it('parses every valid parameter', () => {
stubLocation(
'?page=settings&repo=local%2Ftask-1&status=running&q=hello&sort=title' +
'&tab=files&mobileTab=activity&task=42&section=auth&piece=chat' +
'&pieceSource=user-custom&dashboardWidget=gpu&help=intro',
);
const state = readUiUrlState();
expect(state.page).toBe('settings');
expect(state.repo).toBe('local/task-1');
expect(state.status).toBe('running');
expect(state.search).toBe('hello');
expect(state.sort).toBe('title');
expect(state.detailTab).toBe('files');
expect(state.mobileTab).toBe('activity');
expect(state.taskId).toBe(42);
expect(state.section).toBe('auth');
expect(state.piece).toBe('chat');
expect(state.pieceSource).toBe('user-custom');
expect(state.dashboardWidget).toBe('gpu');
expect(state.help).toBe('intro');
});
it('falls back to defaults on invalid enum values', () => {
stubLocation('?page=bogus&status=bogus&sort=bogus&tab=bogus&mobileTab=bogus&section=bogus&pieceSource=bogus');
const state = readUiUrlState();
expect(state.page).toBe('tasks');
expect(state.status).toBe('all');
expect(state.sort).toBe('updated');
expect(state.detailTab).toBe('overview');
expect(state.mobileTab).toBe('chat');
expect(state.section).toBeUndefined();
expect(state.pieceSource).toBeUndefined();
});
it('rejects non-positive and non-numeric task ids', () => {
for (const task of ['0', '-5', 'abc', '']) {
stubLocation(`?task=${task}`);
expect(readUiUrlState().taskId).toBeNull();
}
stubLocation('?task=7');
expect(readUiUrlState().taskId).toBe(7);
});
it('still parses legacy settings section ids (soft bookmark landing)', () => {
for (const legacy of ['gateway-keys', 'provider', 'workspace', 'browser-sessions']) {
stubLocation(`?section=${legacy}`);
expect(readUiUrlState().section).toBe(legacy);
}
});
it('treats an empty piece param as undefined', () => {
stubLocation('?piece=');
expect(readUiUrlState().piece).toBeUndefined();
});
it('omits optional spread keys entirely when their params are absent', () => {
stubLocation('?page=tasks');
const state = readUiUrlState();
expect('pieceSource' in state).toBe(false);
expect('dashboardWidget' in state).toBe(false);
expect('help' in state).toBe(false);
});
});
describe('buildUiUrlStateSearch', () => {
it('produces an empty string for the default state', () => {
expect(buildUiUrlStateSearch(DEFAULT_STATE)).toBe('');
});
it('only serializes non-default values', () => {
const search = buildUiUrlStateSearch({
...DEFAULT_STATE,
status: 'failed',
taskId: 12,
});
const params = new URLSearchParams(search);
expect(params.get('status')).toBe('failed');
expect(params.get('task')).toBe('12');
expect(Array.from(params.keys()).sort()).toEqual(['status', 'task']);
});
it('omits the default dashboardWidget (worker-status) but keeps others', () => {
expect(
buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'worker-status' }),
).toBe('');
expect(
buildUiUrlStateSearch({ ...DEFAULT_STATE, dashboardWidget: 'gpu' }),
).toBe('dashboardWidget=gpu');
});
it('round-trips through readUiUrlState', () => {
const state: UiUrlState = {
page: 'help',
repo: 'local/task-9',
status: 'waiting_human',
search: 'foo bar',
sort: 'status',
scope: 'all',
detailTab: 'trace',
mobileTab: 'files',
taskId: 99,
section: 'reflection',
piece: 'research',
pieceSource: 'builtin',
dashboardWidget: 'queue',
help: 'pieces',
};
stubLocation(`?${buildUiUrlStateSearch(state)}`);
expect(readUiUrlState()).toEqual(state);
});
});
describe('paramsEqualExcept', () => {
it('considers identical params equal', () => {
const a = new URLSearchParams('a=1&b=2');
const b = new URLSearchParams('a=1&b=2');
expect(paramsEqualExcept(a, b, [])).toBe(true);
});
it('ignores key order', () => {
const a = new URLSearchParams('b=2&a=1');
const b = new URLSearchParams('a=1&b=2');
expect(paramsEqualExcept(a, b, [])).toBe(true);
});
it('detects value differences', () => {
const a = new URLSearchParams('a=1');
const b = new URLSearchParams('a=2');
expect(paramsEqualExcept(a, b, [])).toBe(false);
});
it('skips ignored keys on either side', () => {
const a = new URLSearchParams('a=1&tab=files');
const b = new URLSearchParams('a=1&tab=trace');
expect(paramsEqualExcept(a, b, ['tab'])).toBe(true);
const c = new URLSearchParams('a=1');
expect(paramsEqualExcept(a, c, ['tab'])).toBe(true);
});
it('detects extra non-ignored keys', () => {
const a = new URLSearchParams('a=1&extra=x');
const b = new URLSearchParams('a=1');
expect(paramsEqualExcept(a, b, [])).toBe(false);
});
it('handles repeated keys deterministically', () => {
const a = new URLSearchParams('k=2&k=1');
const b = new URLSearchParams('k=1&k=2');
expect(paramsEqualExcept(a, b, [])).toBe(true);
});
});
describe('column constants', () => {
it('every status column has a label', () => {
for (const col of COLUMN_LIST) {
expect(COLUMN_LABELS[col], `label for ${col}`).toBeTruthy();
}
});
});
+5
View File
@@ -76,6 +76,8 @@ 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';
detailTab: DetailTabId;
mobileTab: MobileTabId;
taskId: number | null;
@@ -97,6 +99,7 @@ export function readUiUrlState(): UiUrlState {
status: 'all',
search: '',
sort: 'updated',
scope: 'mine',
detailTab: 'overview',
mobileTab: 'chat',
taskId: null,
@@ -128,6 +131,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',
detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId)
? detailTab as DetailTabId
: 'overview',
@@ -150,6 +154,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.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));