sync: update from private repo (ddadfd71)
CI / build-and-test (push) Waiting to run

This commit is contained in:
oss-sync
2026-07-08 23:35:00 +00:00
parent b1292e34b2
commit 77ee3bc426
187 changed files with 19918 additions and 10938 deletions
+6 -6
View File
@@ -353,19 +353,19 @@ test('invite picker lists same-org member, excludes the stranger', async ({ page
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 4. NO visibility selector. The space chat shows the static visibility NOTE and
// never the removed selector control.
test('space chat shows the static visibility note, not a selector', async ({ page }) => {
// 4. NO visibility control at all. Space-chat visibility is a fixed member-only
// scope, so neither the old selector nor the static note chip should render —
// there is nothing for the user to read or choose.
test('space chat exposes neither a visibility selector nor a visibility note (fixed member-only scope)', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, MANAGER.email, MANAGER.password);
await openSharedSpace(page);
await openSeededChat(page);
// The static note is present; the removed selector is absent.
await expect(page.getByTestId('space-chat-visibility-note')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('space-chat-visibility-note')).toContainText('メンバーに公開');
// Both the removed selector and the removed static-note chip are absent.
await expect(page.getByTestId('space-chat-visibility')).toHaveCount(0);
await expect(page.getByTestId('space-chat-visibility-note')).toHaveCount(0);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+2 -2
View File
@@ -1103,8 +1103,8 @@ test('space MCP isolation: server registered in A is visible in A, absent in B',
// Space task/settings feature parity (feat/space-feature-parity-tests).
//
// Phase 1 added a per-chat action toolbar (data-testid="space-chat-actions") to
// the inline SpaceConversation, with delete / share / continue / visibility
// controls reusing the SAME implementation as the Tasks-page DetailHeader. These
// the inline SpaceConversation, with delete / share / continue controls reusing
// the SAME implementation as the Tasks-page DetailHeader. These
// tests prove each action works INSIDE a space without ever bouncing to the
// Tasks page, plus the 概要 (overview) feedback flow and every 設定 sub-tab.
//
+26 -2276
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
// api.ts から分割(挙動不変): ブラウザセッションプロファイル。
import { BASE, buildQuery } from './client';
// --- Browser Session Profiles ---
export interface BrowserSessionProfile {
id: number;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector: string | null;
loginUrlPatterns: string[];
status: 'pending' | 'active' | 'expired' | 'revoked' | 'error';
stateVersion: number;
lastSavedAt: string | null;
lastUsedAt: string | null;
lastValidatedAt: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
/** Owning space (null = legacy/personal). Surfaced for space-scoped listings. */
spaceId?: string | null;
/** The user who created the profile (its DEK owner). */
ownerId?: string;
/** False when the requesting viewer is NOT the owner: visible but unusable
* (the per-user DEK can only be decrypted by its owner). */
decryptableByViewer?: boolean;
}
const SESS_BASE = `${BASE}/browser-sessions`;
export async function listBrowserSessionProfiles(spaceId?: string): Promise<BrowserSessionProfile[]> {
const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`listBrowserSessionProfiles: ${r.status}`);
return (await r.json() as { profiles: BrowserSessionProfile[] }).profiles;
}
export async function createBrowserSessionProfile(
input: Partial<BrowserSessionProfile> & { label: string; startUrl: string },
spaceId?: string,
): Promise<BrowserSessionProfile> {
const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spaceId ? { ...input, spaceId } : input),
});
if (!r.ok) throw new Error(`createBrowserSessionProfile: ${r.status}`);
return (await r.json() as { profile: BrowserSessionProfile }).profile;
}
export async function startBrowserSessionLogin(id: number): Promise<{ sessionId: string; novncPath: string }> {
const r = await fetch(`${SESS_BASE}/profiles/${id}/login`, { method: 'POST', credentials: 'same-origin' });
if (!r.ok) throw new Error((await r.text().catch(() => '')) || `startLogin: ${r.status}`);
return r.json();
}
export async function saveBrowserSession(id: number, sessionId: string): Promise<BrowserSessionProfile> {
const r = await fetch(`${SESS_BASE}/profiles/${id}/save`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
if (!r.ok) throw new Error(`saveBrowserSession: ${r.status}`);
return (await r.json() as { profile: BrowserSessionProfile }).profile;
}
export async function cancelBrowserSession(id: number, sessionId: string): Promise<void> {
await fetch(`${SESS_BASE}/profiles/${id}/cancel`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
}
export async function testBrowserSessionProfile(id: number): Promise<{
verdict: { expired: boolean; reason?: string };
finalUrl: string;
statusCode: number;
}> {
const r = await fetch(`${SESS_BASE}/profiles/${id}/test`, { method: 'POST', credentials: 'same-origin' });
if (!r.ok) throw new Error(`testBrowserSession: ${r.status}`);
return r.json();
}
export async function deleteBrowserSessionProfile(id: number, spaceId?: string): Promise<void> {
const r = await fetch(`${SESS_BASE}/profiles/${id}${buildQuery({ spaceId })}`, { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) throw new Error(`deleteBrowserSessionProfile: ${r.status}`);
}
+145
View File
@@ -0,0 +1,145 @@
// api.ts から分割(挙動不変): スペースカレンダー(予定 + 日次集計 + クロススペース)。
import { BASE } from './client';
// --- Space calendar (予定 + 日次集計) ---
// spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md
export interface CalendarEvent {
id: number;
spaceId: string;
ownerId: string | null;
date: string; // 開始日 YYYY-MM-DD(ローカル日付)
endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日
time: string | null; // 開始 HH:MM。null = 終日
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null
title: string;
description: string | null;
createdBy: 'user' | 'agent';
sourceTaskId: number | null;
createdAt: string;
updatedAt?: string;
}
export interface CalendarDayCounts {
taskCount: number;
eventCount: number;
}
export interface CalendarMonth {
days: Record<string, CalendarDayCounts>;
events: CalendarEvent[];
}
export interface CalendarDayTask {
id: number;
title: string;
state: string;
status: string | null;
createdAt: string;
}
export interface CalendarDayFile {
name: string;
path: string;
size: number;
mtime: string;
}
export interface CalendarDay {
tasks: CalendarDayTask[];
files: CalendarDayFile[];
events: CalendarEvent[];
}
// ── V2: cross-space calendar ──────────────────────────────────────────────
export interface CrossCalendarSpace {
id: string;
name: string;
color: string; // brand_color、無ければ spaceColor(id) で導出済み
}
export interface CrossCalendarMonth {
// days[date][spaceId] = そのスペースのその日の集計
days: Record<string, Record<string, CalendarDayCounts>>;
events: CalendarEvent[]; // 各イベントは spaceId を持つ
spaces: CrossCalendarSpace[];
}
export async function fetchCrossSpaceCalendarMonth(
month: string,
tzOffset: number,
): Promise<CrossCalendarMonth> {
const params = new URLSearchParams({ month, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/calendar/cross?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar');
return data as CrossCalendarMonth;
}
export async function fetchSpaceCalendarMonth(
spaceId: string,
month: string,
tzOffset: number,
): Promise<CalendarMonth> {
const params = new URLSearchParams({ month, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar');
return data as CalendarMonth;
}
export async function fetchSpaceCalendarDay(
spaceId: string,
date: string,
tzOffset: number,
): Promise<CalendarDay> {
const params = new URLSearchParams({ date, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/day?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar day');
return data as CalendarDay;
}
export async function createCalendarEvent(
spaceId: string,
input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; title: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, endTime, ...rest } = input;
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
return data as CalendarEvent;
}
export async function updateCalendarEvent(
spaceId: string,
eventId: number,
patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, endTime, ...rest } = patch;
const body: Record<string, unknown> = { ...rest };
if (endDate !== undefined) body.end_date = endDate;
if (endTime !== undefined) body.end_time = endTime;
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update event');
return data as CalendarEvent;
}
export async function deleteCalendarEvent(spaceId: string, eventId: number): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 204) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
+27
View File
@@ -0,0 +1,27 @@
// api.ts から分割(挙動不変): 全 API モジュール共通の基盤ヘルパー。
export const BASE = '/api';
// Blob を受け取りブラウザのダウンロードを発火する(zip 一括ダウンロード用)。
export function triggerBlobDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/**
* Build a `?key=value&...` query string from defined entries (undefined dropped).
* Used by the space-scoped variants below so passing `spaceId` adds `?spaceId=…`
* and omitting it leaves the per-user URL unchanged (backward compatible).
*/
export function buildQuery(params: Record<string, string | undefined>): string {
const parts = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`);
return parts.length ? `?${parts.join('&')}` : '';
}
+84
View File
@@ -0,0 +1,84 @@
// api.ts から分割(挙動不変): Config(設定 API + シークレット4状態表現)。
import { BASE } from './client';
/**
* 4-state representation of a secret field on the wire. See design doc
* 2026-05-21-settings-ui-and-config-restructure-design.md (Form Behavior →
* Secret Inputs) for the canonical contract.
*
* Phase 1 (this release) keeps the on-wire representation backwards
* compatible with the existing `apiKey: string` shape so that mask-
* preservation in `ConfigManager.updateConfig` keeps working unchanged:
*
* - `unchanged` → serialized as the masked sentinel `'********'`
* - `literal` → serialized as the raw string value
* - `env_ref` → serialized as `${ENV_NAME}`
* - `cleared` → serialized as `''` (empty string)
*
* Phase 2 will switch to a tagged object on the wire and drop the magic
* `'********'` sentinel; the UI form keeps the 4-state union today so the
* Phase 2 migration is a server-side change only.
*/
export type SecretFieldValue =
| { type: 'unchanged' }
| { type: 'literal'; value: string }
| { type: 'env_ref'; env_name: string }
| { type: 'cleared' };
/** Server-side masked sentinel. Kept in sync with `MASKED` in src/config-manager.ts. */
export const SECRET_MASKED_SENTINEL = '********';
/**
* Parse a stored string secret (as received from `GET /api/config`) into
* the 4-state form used by the UI. The server masks literal secrets to
* `'********'`, so any literal-looking string is treated as `unchanged`
* unless it's an `${ENV_REF}` pattern. Empty / missing values map to
* `cleared`.
*/
export function parseSecretValue(raw: string | null | undefined): SecretFieldValue {
if (raw == null || raw === '') return { type: 'cleared' };
if (raw === SECRET_MASKED_SENTINEL) return { type: 'unchanged' };
const envMatch = /^\$\{([A-Z0-9_]+)\}$/.exec(raw.trim());
if (envMatch) return { type: 'env_ref', env_name: envMatch[1] };
// Anything else came back as plaintext (e.g. fresh UI form not yet
// round-tripped through the server mask) — treat as literal.
return { type: 'literal', value: raw };
}
/**
* Serialize a 4-state secret into the string the server currently
* expects. `unchanged` becomes the masked sentinel so server-side
* mask-preservation in `ConfigManager.updateConfig` keeps the existing
* literal in place.
*/
export function serializeSecretValue(v: SecretFieldValue): string {
if (v.type === 'unchanged') return SECRET_MASKED_SENTINEL;
if (v.type === 'literal') return v.value;
if (v.type === 'env_ref') return `\${${v.env_name}}`;
return '';
}
// --- Config ---
export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record<string, boolean> }> {
const res = await fetch(`${BASE}/config`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch config');
return { config: data.config, etag: res.headers.get('etag') ?? '', overriddenByEnv: data.overriddenByEnv ?? {} };
}
export async function updateConfig(config: any, etag: string): Promise<{ ok: boolean; conflict?: boolean }> {
const res = await fetch(`${BASE}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': etag },
body: JSON.stringify(config),
});
const data = await res.json();
if (res.status === 409) return { ok: false, conflict: true };
if (!res.ok) throw new Error(data?.error ?? 'Failed to update config');
return data;
}
export async function reloadConfig(): Promise<void> {
const res = await fetch(`${BASE}/config/reload`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to reload config');
}
+32
View File
@@ -0,0 +1,32 @@
// api.ts から分割(挙動不変): Delegate ObservabilityD0)。
// ※ 分割に伴い dynamic type import のパスのみ ./lib → ../lib に補正(同一モジュールを指す)。
import { BASE } from './client';
// ── Delegate Observability (D0) ──────────────────────────────────────────
// Task 3 API: GET /api/local/tasks/:id/delegate-runs
// GET /api/local/tasks/:id/delegate-runs/:runId/timeline
/** Lightweight event record returned by the delegate-run timeline endpoint. */
export interface TraceEventLite {
eventId: string;
ts: string;
seq: number;
kind: string;
movement?: string;
iteration?: number;
payload: unknown;
}
export async function fetchDelegateRuns(taskId: number): Promise<import('../lib/delegateRuns').DelegateRunsResult> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs`);
if (!res.ok) throw new Error(`fetchDelegateRuns failed: ${res.status}`);
const body = await res.json();
return { runs: body.runs ?? [], subtasks: body.subtasks ?? [] };
}
export async function fetchDelegateRunTimeline(taskId: number, delegateRunId: string, jobId?: string): Promise<TraceEventLite[]> {
const q = jobId ? `?jobId=${encodeURIComponent(jobId)}` : '';
const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs/${encodeURIComponent(delegateRunId)}/timeline${q}`);
if (!res.ok) throw new Error(`fetchDelegateRunTimeline failed: ${res.status}`);
return (await res.json()).events ?? [];
}
+30
View File
@@ -0,0 +1,30 @@
// api.ts から分割(挙動不変): ユーザー/スペースフォルダのファイル(browser-macros / recordings)。
import { BASE, buildQuery } from './client';
// --- User/space folder files (browser-macros / recordings) ---
export interface FolderFileEntry { name: string; size: number; mtime: string }
/**
* List files in a writable user-folder subdir. Passing `spaceId` scopes the
* listing to that space's folder (`data/spaces/{id}/{subdir}`); omitting it
* targets the per-user folder. Matches the convention used by the piece/agents
* space-scoped fetchers above.
*/
export async function listFolderFiles(subdir: 'browser-macros' | 'recordings', spaceId?: string): Promise<FolderFileEntry[]> {
const r = await fetch(`${BASE}/users/me/folder/list${buildQuery({ subdir, spaceId })}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`listFolderFiles: ${r.status}`);
return (await r.json() as { files: FolderFileEntry[] }).files;
}
/** Read one folder file's text content (space-scoped when `spaceId` is given). */
export async function getFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise<string> {
const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`getFolderFile: ${r.status}`);
return r.text();
}
/** Delete one folder file (space-scoped when `spaceId` is given). */
export async function deleteFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise<void> {
const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) throw new Error(`deleteFolderFile: ${r.status}`);
}
+164
View File
@@ -0,0 +1,164 @@
// api.ts から分割(挙動不変): AAO Gatewayvirtual key 管理 + サーバ状態)。
// ── AAO Gateway: virtual key admin (Phase 2a + 2b) ──────────────────────
//
// Talks to /api/admin/gateway/keys/* — requires admin role. The raw
// bearer key is returned ONCE from POST / and POST /:id/rotate; UI must
// surface it to the user immediately and not expose it again.
export interface GatewayKey {
id: string;
object: 'gateway.key';
keyPrefix: string;
team: string;
allowedModels: string[] | null;
source: 'admin' | 'config-import';
createdAt: string;
createdBy: string | null;
revokedAt: string | null;
revokedBy: string | null;
lastUsedAt: string | null;
tokensBudget: number | null;
rateLimitRpm: number | null;
/** Only present on POST / rotate responses. */
key?: string;
}
export interface GatewayKeyUsageResponse {
keyId: string;
currentPeriod: string;
tokensIn: number;
tokensOut: number;
tokensTotal: number;
tokensBudget: number | null;
remaining: number | null;
requestsThisMonth: number;
rateLimitRpm: number | null;
// Phase 3a F9: `rateRecentRequests` removed. The field was always
// null because the admin process doesn't own the gateway's live
// RateLimiter. Phase 3b/3c may re-add it via gateway IPC.
history: Array<{ period: string; tokensIn: number; tokensOut: number; requests: number }>;
}
export async function listGatewayKeys(params?: { team?: string; activeOnly?: boolean }): Promise<GatewayKey[]> {
const q = new URLSearchParams();
if (params?.team) q.set('team', params.team);
if (params?.activeOnly) q.set('activeOnly', 'true');
const qs = q.toString();
const res = await fetch(`/api/admin/gateway/keys${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error(`Failed to list gateway keys: ${res.status}`);
return (await res.json()).keys;
}
export async function createGatewayKey(input: {
team: string;
allowedModels?: string[];
tokensBudget?: number | null;
rateLimitRpm?: number | null;
}): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to create gateway key (${res.status}): ${text}`);
}
return res.json();
}
export async function getGatewayKey(id: string): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`Failed to get gateway key: ${res.status}`);
return res.json();
}
export async function patchGatewayKey(
id: string,
patch: {
tokensBudget?: number | null;
rateLimitRpm?: number | null;
allowedModels?: string[] | null;
},
): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to update gateway key (${res.status}): ${text}`);
}
return res.json();
}
export async function revokeGatewayKey(id: string): Promise<void> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to revoke gateway key (${res.status}): ${text}`);
}
}
export async function rotateGatewayKey(id: string): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/rotate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to rotate gateway key (${res.status}): ${text}`);
}
return res.json();
}
export async function deleteGatewayKey(id: string): Promise<void> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to delete gateway key (${res.status}): ${text}`);
}
}
export async function getGatewayKeyUsage(id: string): Promise<GatewayKeyUsageResponse> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/usage`);
if (!res.ok) throw new Error(`Failed to get gateway key usage: ${res.status}`);
return res.json();
}
// ============================================================
// Gateway Server status (Phase 3c) — read-only admin endpoint.
// Drives the Settings → Gateway Server badge and error list.
// ============================================================
export type GatewayServerState =
| 'unavailable'
| 'disabled'
| 'starting'
| 'running'
| 'stopping'
| 'misconfigured';
export interface GatewayServerStatus {
state: GatewayServerState;
/** Desired-enabled flag read from current config. Null when no ConfigManager. */
enabled: boolean | null;
/** Validation errors that prevented the gateway from starting. */
errors: string[];
mounted: boolean;
sharedPort: number;
/** Only present when state==='unavailable'. */
message?: string;
}
export async function getGatewayServerStatus(): Promise<GatewayServerStatus> {
const res = await fetch('/api/admin/gateway/status');
if (!res.ok) throw new Error(`Failed to get gateway status: ${res.status}`);
return res.json();
}
+107
View File
@@ -0,0 +1,107 @@
// api.ts から分割(挙動不変): 通知 V2(Web Push)。
import { BASE } from './client';
// ── Notifications V2 (Web Push) ───────────────────────────────────────
export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human';
export interface NotificationPrefsDTO {
userId: string;
enabled: boolean;
events: Record<NotifyEventType, boolean>;
includeDetails: boolean;
v1Migrated: boolean;
updatedAt: string;
}
export interface NotificationPrefsInput {
enabled?: boolean;
events?: Partial<Record<NotifyEventType, boolean>>;
includeDetails?: boolean;
}
export interface PushSubscriptionPublic {
id: string;
endpointHost: string;
userAgent: string | null;
createdAt: string;
lastSuccessAt: string | null;
lastFailureAt: string | null;
failureCount: number;
}
export interface VapidPublicKeyDTO {
publicKey: string;
keyId: string;
}
async function notificationsJsonOrThrow<T>(res: Response, fallback: string): Promise<T> {
const data = await res.json().catch(() => ({} as Record<string, unknown>));
if (!res.ok) throw new Error((data as { error?: string }).error ?? fallback);
return data as T;
}
export async function fetchVapidPublicKey(): Promise<VapidPublicKeyDTO> {
const res = await fetch(`${BASE}/notifications/vapid-public-key`);
return notificationsJsonOrThrow(res, 'failed to fetch VAPID key');
}
export async function listPushSubscriptions(): Promise<PushSubscriptionPublic[]> {
const res = await fetch(`${BASE}/notifications/subscriptions`);
const data = await notificationsJsonOrThrow<{ subscriptions: PushSubscriptionPublic[] }>(
res, 'failed to list subscriptions',
);
return data.subscriptions;
}
export async function postPushSubscription(input: {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string;
}): Promise<{ id: string }> {
const res = await fetch(`${BASE}/notifications/subscriptions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return notificationsJsonOrThrow(res, 'failed to register subscription');
}
export async function deletePushSubscription(id: string): Promise<void> {
const res = await fetch(`${BASE}/notifications/subscriptions/${id}`, { method: 'DELETE' });
await notificationsJsonOrThrow(res, 'failed to delete subscription');
}
export async function fetchNotificationPrefs(): Promise<NotificationPrefsDTO> {
const res = await fetch(`${BASE}/notifications/preferences`);
return notificationsJsonOrThrow(res, 'failed to fetch preferences');
}
export async function updateNotificationPrefs(
input: NotificationPrefsInput,
): Promise<NotificationPrefsDTO> {
const res = await fetch(`${BASE}/notifications/preferences`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return notificationsJsonOrThrow(res, 'failed to update preferences');
}
export async function migrateLocalStoragePrefs(
input: NotificationPrefsInput,
): Promise<{ ok: boolean; prefs: NotificationPrefsDTO } | { alreadyMigrated: true }> {
const res = await fetch(`${BASE}/notifications/preferences/migrate-from-localstorage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (res.status === 409) return { alreadyMigrated: true };
return notificationsJsonOrThrow(res, 'failed to migrate preferences');
}
export async function postTestNotification(): Promise<{ ok: boolean }> {
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
return notificationsJsonOrThrow(res, 'failed to send test notification');
}
+92
View File
@@ -0,0 +1,92 @@
// api.ts から分割(挙動不変): User Folder Pets。
// --- User Folder Pets ---
export interface PetSettings {
enabled: boolean;
activePetId: string | null;
size: 32 | 48 | 64 | 80;
position: 'bottom-right';
sound: boolean;
reducedMotion: boolean;
toolSparkEnabled: boolean;
workerPets: Record<string, string>;
}
export interface PetSummary {
id: string;
name: string;
description: string | null;
spriteFile: string | null;
previewFile: string | null;
frameWidth: number | null;
frameHeight: number | null;
gridCols: number | null;
gridRows: number | null;
updatedAt: string;
}
export interface PetDetail extends PetSummary {
manifest: Record<string, unknown>;
}
export interface PetsResponse {
pets: PetSummary[];
settings: PetSettings;
}
const PETS_BASE = '/api/users/me/pets';
export function petAssetUrl(petId: string, file: string): string {
return `${PETS_BASE}/${encodeURIComponent(petId)}/assets/${encodeURIComponent(file)}`;
}
export async function fetchPets(): Promise<PetsResponse> {
const res = await fetch(PETS_BASE, { credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pets');
return data;
}
export async function fetchPet(petId: string): Promise<PetDetail> {
const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pet');
return data.pet;
}
export async function importPet(file: File, options: { petId?: string; overwrite?: boolean } = {}): Promise<PetDetail> {
const params = new URLSearchParams();
params.set('filename', file.name);
if (options.petId) params.set('petId', options.petId);
if (options.overwrite) params.set('overwrite', 'true');
const res = await fetch(`${PETS_BASE}/import?${params.toString()}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/zip' },
body: await file.arrayBuffer(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to import pet');
return data.pet;
}
export async function deletePet(petId: string): Promise<void> {
const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete pet');
}
export async function updatePetSettings(patch: Partial<PetSettings>): Promise<PetSettings> {
const res = await fetch(`${PETS_BASE}/settings`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update pet settings');
return data.settings;
}
+53
View File
@@ -0,0 +1,53 @@
// api.ts から分割(挙動不変): Piece CRUD。
import { BASE, buildQuery } from './client';
// --- Pieces ---
export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null }
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] }
export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] }
/** Full response from GET /api/pieces/:name — includes the server-resolved source. */
export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string }
export async function fetchPieces(spaceId?: string): Promise<PieceSummary[]> {
const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pieces');
return data.pieces;
}
export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise<PieceFetchResult> {
const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
return { piece: data.piece, source: data.source, ownerId: data.ownerId };
}
export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise<void> {
const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`;
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece),
});
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
}
export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' }
export async function createPiece(piece: PieceDef, spaceId?: string): Promise<PieceCreateResult> {
const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece),
});
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
const d = await res.json();
return { source: d.source ?? 'user-custom' };
}
export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise<void> {
const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`;
const res = await fetch(url, { method: 'DELETE' });
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
}
+22
View File
@@ -0,0 +1,22 @@
// api.ts から分割(挙動不変): Reflection(タスク別最新スナップショット)。
import { BASE } from './client';
// --- Reflection ---
export interface LatestReflectionForTask {
snapshotId: string;
outcome: string;
memoryChanges: number | null;
pieceEdited: boolean;
}
export async function getLatestReflectionForTask(
taskId: number,
): Promise<LatestReflectionForTask | null> {
const res = await fetch(`${BASE}/local/reflection/latest-for-task/${taskId}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`getLatestReflectionForTask: ${res.status}`);
const data = await res.json();
// API returns null body when no reflection exists
if (!data || !data.snapshotId) return null;
return data as LatestReflectionForTask;
}
+157
View File
@@ -0,0 +1,157 @@
// api.ts から分割(挙動不変): タスク共有リンク + 公開アプリ共有リンク。
import { BASE } from './client';
import type { LocalTask, LocalTaskComment, SubtaskActivity } from './tasks';
import type { LocalFileEntry } from './task-files';
import type { SubtaskFiles } from './subtasks';
// --- Share ---
export async function shareTask(taskId: number): Promise<{ shareToken: string; shareUrl: string }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to share task');
return data;
}
export async function unshareTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to unshare task');
}
export async function fetchSharedTask(token: string): Promise<LocalTask> {
const res = await fetch(`${BASE}/shared/${token}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Not found');
return data.task;
}
export async function fetchSharedTaskComments(token: string): Promise<LocalTaskComment[]> {
const res = await fetch(`${BASE}/shared/${token}/comments`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch comments');
return data.comments ?? [];
}
export async function fetchSharedFiles(token: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams();
if (path) params.set('path', path);
const res = await fetch(`${BASE}/shared/${token}/files?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export async function fetchSharedFileContent(token: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/shared/${token}/files/content?${params.toString()}`);
if (!res.ok) throw new Error('Failed to read file');
return res.text();
}
export function getSharedFileRawUrl(token: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/shared/${token}/files/raw?${params.toString()}`;
}
export async function fetchSharedSubtaskActivities(token: string): Promise<SubtaskActivity[]> {
const res = await fetch(`${BASE}/shared/${token}/subtasks/activities`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities');
return data.subtasks ?? [];
}
// 個別サブタスク(共有・read-only)。本体の fetchSubtaskActivity / fetchSubtaskFiles の
// 共有版。TaskDataSource(shared) から使う。封じ込めはサーバ側(share-api.ts)。
export async function fetchSharedSubtaskActivity(token: string, jobId: string): Promise<string> {
const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/activity`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity');
return data.activityLog ?? '';
}
export async function fetchSharedSubtaskFiles(token: string, jobId: string): Promise<SubtaskFiles> {
const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files');
return { files: data.files ?? [], categories: data.categories ?? {} };
}
export function sharedSubtaskFileRawUrl(token: string, jobId: string, filePath: string): string {
return `${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files/${filePath}`;
}
// --- 公開アプリ共有リンク(read-only・認証なし) ---
// スペース内の 1 ワークスペースアプリ(apps/{app}/)をログイン不要の公開トークンで
// read-only 配信する。公開取得(/api/app-share/:token/*)は認証なし、発行/失効
// /api/local/spaces/:id/apps/:app/share)は canManageSpaceowner/admin)が必要。
// サーバ側で apps/{app}/output/ にパス封じ込め。詳細は src/bridge/app-share-api.ts。
// 公開アプリのメタ。entryPath は entry HTMLapps/{app}/index.html 等)。空アプリ等で
// 見つからなければ null になりうる(呼び出し側で 404 表示)。失効/不正トークンは throw。
export async function fetchAppShareMeta(token: string): Promise<{ appName: string; entryPath: string | null }> {
const res = await fetch(`${BASE}/app-share/${token}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Not found');
return data.app;
}
export async function fetchAppShareFileContent(token: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/app-share/${token}/files/content?${params.toString()}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to read file');
}
return res.text();
}
export async function fetchAppShareFiles(
token: string,
dir: string = '',
): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams();
if (dir) params.set('dir', dir);
const qs = params.toString();
const res = await fetch(`${BASE}/app-share/${token}/files/list${qs ? `?${qs}` : ''}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export function getAppShareRawUrl(token: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/app-share/${token}/files/raw?${params.toString()}`;
}
// 発行/失効/取得(canManageSpace)。shareUrl は相対パス(/ui/app/:token)。表示時は
// window.location.origin を前置する。
export async function createAppShareLink(
spaceId: string,
app: string,
): Promise<{ token: string; shareUrl: string }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, {
method: 'POST',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to create share link');
return data as { token: string; shareUrl: string };
}
export async function getAppShareLink(
spaceId: string,
app: string,
): Promise<{ token: string | null; shareUrl?: string; revokedAt?: string | null }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch share link');
return data as { token: string | null; shareUrl?: string; revokedAt?: string | null };
}
export async function revokeAppShareLink(spaceId: string, app: string): Promise<{ ok: true }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, {
method: 'DELETE',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to revoke share link');
return data as { ok: true };
}
+82
View File
@@ -0,0 +1,82 @@
// api.ts から分割(挙動不変): Skills API。
import { buildQuery } from './client';
// ── Skills API ──────────────────────────────────────────────────────────
export interface SkillSummary {
name: string;
description: string;
triggers: string[];
source: 'system' | 'user';
hasDir: boolean;
}
export interface SkillDetail extends SkillSummary {
/** Body only (frontmatter stripped) — for read-only preview. */
content: string;
/** Full SKILL.md incl. frontmatter — what the editor must edit/save. */
raw: string;
files: string[];
findings: Array<{ severity: 'medium' | 'high'; pattern: string; match: string; line: number; file?: string }>;
maxSeverity: 'high' | 'medium' | 'none';
}
export async function fetchSkills(scope?: string, spaceId?: string): Promise<SkillSummary[]> {
const res = await fetch(`/api/skills${buildQuery({ scope, spaceId })}`);
if (!res.ok) throw new Error('Failed to fetch skills');
const data = await res.json();
return data.skills;
}
export async function fetchSkillDetail(name: string, scope?: string, spaceId?: string): Promise<SkillDetail> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`);
if (!res.ok) throw new Error('Skill not found');
return res.json();
}
export async function createSkill(name: string, content: string, scope: string, spaceId?: string): Promise<{ name: string; severity: string; findings: any[] }> {
const res = await fetch(`/api/skills${buildQuery({ spaceId })}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spaceId ? { name, content, scope, spaceId } : { name, content, scope }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
return res.json();
}
export async function updateSkill(name: string, content: string, scope: string, spaceId?: string): Promise<any> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spaceId ? { content, spaceId } : { content }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
return res.json();
}
export async function deleteSkill(name: string, scope: string, spaceId?: string): Promise<void> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
}
export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[], spaceId?: string): Promise<any> {
const res = await fetch(`/api/skills/install-from-url${buildQuery({ spaceId })}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spaceId ? { url, scope, selectedSkills, spaceId } : { url, scope, selectedSkills }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
return res.json();
}
+145
View File
@@ -0,0 +1,145 @@
// api.ts から分割(挙動不変): スペースの永続ワークスペースファイル。
import { BASE, triggerBlobDownload } from './client';
import type { LocalFileEntry } from './task-files';
// --- Space files (永続ワークスペース {worktreeDir}/space/{id}/files) ---
// タスク版の files APIgetLocalFileRawUrl 等)と同形だが、スペース id でキーする。
export async function fetchSpaceFiles(spaceId: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams();
if (path) params.set('path', path);
const qs = params.toString();
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files${qs ? `?${qs}` : ''}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export async function fetchSpaceFileContent(spaceId: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/content?${params.toString()}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to read file');
}
return await res.text();
}
export function getSpaceFileRawUrl(spaceId: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`;
}
export function getSpaceTrustedHtmlUrl(spaceId: string, path: string): string {
const params = new URLSearchParams({ path, trusted: '1' });
return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`;
}
export function getSpaceFileOfficePreviewUrl(spaceId: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/local/spaces/${spaceId}/files/office-preview?${params.toString()}`;
}
export async function uploadSpaceFiles(
spaceId: string,
path: string,
files: { name: string; contentBase64: string }[],
): Promise<{ uploaded: { name: string; path: string }[] }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, files }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files');
return data as { uploaded: { name: string; path: string }[] };
}
// スペースのワークスペースファイルを削除する。複数選択は paths(相対パス配列)で渡す。
// 各パスはサーバ側で spaceFilesDir に封じ込め(traversal は 400)、ファイルのみ対象。
// 存在しないパスは冪等にスキップされる。
export async function deleteSpaceFiles(
spaceId: string,
paths: string[],
): Promise<{ deleted: string[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files');
return data as { deleted: string[]; skipped: string[] };
}
// スペースのワークスペースに空フォルダを作る(owner/editor のみ)。path は親からの
// 相対パス。サーバ側で spaceFilesDir に封じ込め(traversal は 400)。
export async function createSpaceFolder(
spaceId: string,
path: string,
): Promise<{ created: string }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/mkdir`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create folder');
return data as { created: string };
}
// スペースのファイル/フォルダをリネーム・移動する(owner/editor のみ)。from→to は
// いずれも相対パス完全形。構造ディレクトリ(input/output/logs/apps/readonly)と隠しは
// 不可。衝突時はサーバが `{stem} (N){ext}` に自動リネームし、確定先 path を返す。
export async function moveSpaceFile(
spaceId: string,
from: string,
to: string,
): Promise<{ from: string; to: string }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to move');
return data as { from: string; to: string };
}
// スペースの複数ファイルを zip でまとめてダウンロード(read)。サーバが paths を
// spaceFilesDir に封じ込め、ファイルのみ zip 化。1 件でも zip にする。
export async function downloadSpaceFilesZip(
spaceId: string,
paths: string[],
filename = 'files.zip',
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/download-zip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to download files');
}
triggerBlobDownload(await res.blob(), filename);
}
// スペースのワークスペースファイルを path 指定で 1 件書き込む(上書き許可)。
// contentUTF-8 テキスト)または contentBase64(任意バイナリ)のどちらかを渡す。
// サーバ側で canEditInSpace + ensurePathWithin によりゲートされる。ワークスペース・
// アプリの postMessage ブリッジ writeFile の代理に使う。
export async function writeSpaceFile(
spaceId: string,
path: string,
body: { content: string } | { contentBase64: string },
): Promise<{ path: string; bytes: number }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, ...body }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to write file');
return data as { path: string; bytes: number };
}
+290
View File
@@ -0,0 +1,290 @@
// api.ts から分割(挙動不変): スペース(CRUD・メンバー・招待・ツールポリシー・Python パッケージ)。
import { BASE } from './client';
// --- Spaces (個人/案件) ---------------------------------------------------
export interface Space {
id: string;
kind: 'personal' | 'case';
title: string;
description: string;
ownerId: string | null;
visibility: 'private' | 'org' | 'public';
visibilityScopeOrgId: string | null;
status: 'open' | 'archived';
brandColor: string | null;
workspaceDir: string | null;
createdAt: string;
updatedAt: string;
/** 閲覧者自身のメンバーロール。一覧(GET /spaces)・詳細(GET /spaces/:id)の両方で付与。
* 非メンバー(根オーナー含む。owner_id はメンバー行ではない)は null。 */
myRole?: SpaceMemberRole | null;
}
export async function fetchSpaces(): Promise<Space[]> {
const res = await fetch(`${BASE}/local/spaces`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch spaces');
return data as Space[];
}
export type SpaceMemberRole = 'owner' | 'editor' | 'viewer';
export interface SpaceMember {
userId: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
role: SpaceMemberRole;
isOwner: boolean;
}
export async function fetchSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch members');
return data as SpaceMember[];
}
export async function addSpaceMember(
spaceId: string,
input: { userId: string; role: SpaceMemberRole },
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to add member');
}
}
export async function updateSpaceMemberRole(
spaceId: string,
userId: string,
role: SpaceMemberRole,
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to update member role');
}
}
export async function removeSpaceMember(spaceId: string, userId: string): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to remove member');
}
}
// ─── ワークスペース招待リンク(再利用トークン)──────────────────────
export type SpaceInviteRole = 'editor' | 'viewer';
export interface SpaceInviteInfo {
token: string;
/** 相対パス。origin を前置して共有する。 */
url: string;
role: SpaceInviteRole;
createdAt: string;
expiresAt: string | null;
revokedAt: string | null;
valid: boolean;
}
/** 現行の招待リンク(canManageSpace)。無ければ null。 */
export async function fetchSpaceInvite(spaceId: string): Promise<SpaceInviteInfo | null> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch invite');
return (data?.invite ?? null) as SpaceInviteInfo | null;
}
/** 招待リンクを (再)生成する(canManageSpace)。 */
export async function createSpaceInvite(
spaceId: string,
input: { role: SpaceInviteRole; expiresInDays?: number | null },
): Promise<SpaceInviteInfo> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to create invite');
return data.invite as SpaceInviteInfo;
}
/** 招待リンクを無効化する(canManageSpace)。 */
export async function revokeSpaceInvite(spaceId: string): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to revoke invite');
}
}
export interface InvitePreview {
spaceId: string;
spaceTitle: string;
role: SpaceInviteRole;
}
/**
* 招待トークンのプレビュー。`status` で分岐を呼び出し側に渡す:
* 'ok' → preview / 'unauthorized'(要ログイン)/ 'invalid'(無効・期限切れ・不明)。
*/
export async function fetchInvitePreview(
token: string,
): Promise<{ status: 'ok'; preview: InvitePreview } | { status: 'unauthorized' | 'invalid' }> {
const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}`);
if (res.status === 401) return { status: 'unauthorized' };
if (!res.ok) return { status: 'invalid' };
const data = await res.json();
return { status: 'ok', preview: data as InvitePreview };
}
/** 招待を受諾して参加する。参加先 spaceId を返す。 */
export async function acceptSpaceInvite(token: string): Promise<{ spaceId: string; alreadyMember: boolean }> {
const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}/accept`, {
method: 'POST',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to accept invite');
return data as { spaceId: string; alreadyMember: boolean };
}
// ─── ワークスペース ツールポリシー ────────────────────────────────────────
export interface ToolCategory {
name: string;
sensitive: boolean;
enabled: boolean;
}
export interface SpaceToolPolicy {
disabledSafe: string[];
enabledSensitive: string[];
}
export interface SpaceToolPolicyResponse {
policy: SpaceToolPolicy;
categories: ToolCategory[];
sensitiveTools: { name: string; enabled: boolean }[];
}
export async function fetchSpaceToolPolicy(spaceId: string): Promise<SpaceToolPolicyResponse> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch tool policy');
return data as SpaceToolPolicyResponse;
}
export async function updateSpaceToolPolicy(
spaceId: string,
patch: { disabledSafe?: string[]; enabledSensitive?: string[] },
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to update tool policy');
}
}
// ─── Per-space Python packages ────────────────────────────────────────────
export interface SpacePythonPackage {
name: string;
spec: string;
addedAt: string;
}
export interface SpacePythonPackagesResponse {
enabled: boolean;
maxPackagesPerSpace: number;
preflight: { ok: boolean; reason?: string };
packages: SpacePythonPackage[];
}
export async function fetchSpacePythonPackages(spaceId: string): Promise<SpacePythonPackagesResponse> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch python packages');
return data as SpacePythonPackagesResponse;
}
export async function addSpacePythonPackage(
spaceId: string,
spec: string,
): Promise<{ packages: SpacePythonPackage[] }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spec }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to add package');
return data as { packages: SpacePythonPackage[] };
}
export async function removeSpacePythonPackage(
spaceId: string,
name: string,
): Promise<{ packages: SpacePythonPackage[] }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to remove package');
return data as { packages: SpacePythonPackage[] };
}
export async function createSpace(input: {
title: string;
description?: string;
brandColor?: string | null;
visibility?: Space['visibility'];
}): Promise<Space> {
const res = await fetch(`${BASE}/local/spaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create space');
return data as Space;
}
export async function updateSpace(
id: string,
patch: Partial<Pick<Space, 'title' | 'description' | 'brandColor' | 'visibility'>>,
): Promise<Space> {
const res = await fetch(`${BASE}/local/spaces/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update space');
return data as Space;
}
export async function archiveSpace(id: string): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${id}/archive`, { method: 'POST' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to archive space');
}
}
+40
View File
@@ -0,0 +1,40 @@
// api.ts から分割(挙動不変): サブタスク(ファイル・進捗)。
import { BASE } from './client';
import type { SubtaskActivity } from './tasks';
// --- Subtasks ---
export interface SubtaskFiles {
files: string[];
categories: Record<string, string[]>;
}
export async function fetchSubtaskFiles(taskId: number, jobId: string): Promise<SubtaskFiles> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files');
return { files: data.files ?? [], categories: data.categories ?? {} };
}
export function subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string {
return `${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files/${filePath}`;
}
export async function fetchSubtaskFileContent(taskId: number, jobId: string, filePath: string): Promise<string> {
const res = await fetch(subtaskFileRawUrl(taskId, jobId, filePath));
if (!res.ok) throw new Error('Failed to fetch subtask file content');
return res.text();
}
export async function fetchSubtaskActivities(taskId: number): Promise<SubtaskActivity[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/activities`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities');
return data.subtasks ?? [];
}
export async function fetchSubtaskActivity(taskId: number, jobId: string): Promise<string> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/activity`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity');
return data.activityLog ?? '';
}
+191
View File
@@ -0,0 +1,191 @@
// api.ts から分割(挙動不変): タスクワークスペースのファイル(一覧・内容・Office プレビュー・アップロード/削除/zip)。
import { BASE, triggerBlobDownload } from './client';
// Mirrors the API's safe projection (local-files-api.ts): task IDs + source
// kind + piece/movement + timestamps only. Job UUIDs, checksum, and the
// free-text note are intentionally NOT sent to the client (adversarial-review D4).
export interface FileProvenance {
relPath: string;
sourceKind: string;
createdByTaskId: number | null;
createdByPiece: string | null;
createdByMovement: string | null;
firstSeenAt: string | null;
lastModifiedByTaskId: number | null;
lastModifiedAt: string | null;
}
/** Fetch the provenance record for one workspace file (null when untracked). */
export async function fetchFileProvenance(
taskId: number,
section: string,
path: string,
): Promise<FileProvenance | null> {
const qs = new URLSearchParams({ section, path });
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/provenance?${qs.toString()}`);
if (!res.ok) return null;
const data = await res.json();
return (data.provenance ?? null) as FileProvenance | null;
}
export interface LocalFileEntry {
name: string;
path: string;
kind: 'directory' | 'file';
size: number;
modifiedAt: string;
}
export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams({ section });
if (path) params.set('path', path);
const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise<string> {
const params = new URLSearchParams({ section, path });
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to read file');
}
return await res.text();
}
export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path });
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()}`;
}
// ── Office プレビュー (Excel / PowerPoint) ───────────────────────────────
// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。
export interface OfficeSpreadsheetSheet {
name: string;
rows: string[][];
rowCount: number;
colCount: number;
truncated: boolean;
}
export interface OfficeSpreadsheetPreview {
kind: 'spreadsheet';
sheets: OfficeSpreadsheetSheet[];
truncated: boolean;
}
export interface OfficePresentationPreview {
kind: 'presentation';
slides: { index: number; dataUrl: string }[];
slideCount: number;
truncated: boolean;
}
export interface OfficeDocumentPreview {
kind: 'document';
pages: { index: number; dataUrl: string }[];
pageCount: number;
truncated: boolean;
}
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview;
/** office-preview エンドポイントの失敗を、変換エンジン未導入(503)とそれ以外で区別できる型。 */
export class OfficePreviewError extends Error {
/** サーバが返した error コード ('converter_unavailable' 等)。 */
code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = 'OfficePreviewError';
this.code = code;
}
}
export async function fetchOfficePreview(url: string): Promise<OfficePreview> {
const res = await fetch(url);
if (!res.ok) {
const data = await res.json().catch(() => ({} as { error?: string; message?: string }));
throw new OfficePreviewError(data?.message ?? data?.error ?? 'Failed to load preview', data?.error);
}
return (await res.json()) as OfficePreview;
}
export function getLocalFileOfficePreviewUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path });
return `${BASE}/local/tasks/${taskId}/files/office-preview?${params.toString()}`;
}
// アップロード・削除が許される区分。サーバ側 WRITABLE_SECTIONS と一致させること。
export type WritableTaskSection = 'input' | 'output';
// タスクワークスペースの input/output へファイルをアップロード(複数可)。サーバが
// O_EXCL で衝突回避リネーム + ensurePathWithin で section root に封じ込め。実行中は 409。
export async function uploadLocalFiles(
taskId: number,
section: WritableTaskSection,
path: string,
files: { name: string; contentBase64: string }[],
): Promise<{ uploaded: { name: string; path: string }[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, path, files }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files');
return data as { uploaded: { name: string; path: string }[] };
}
// タスクワークスペースの input/output ファイルを削除(複数選択は paths 配列)。サーバ側で
// section root に封じ込め、ファイルのみ対象、存在しないものは冪等スキップ。
export async function deleteLocalFiles(
taskId: number,
section: WritableTaskSection,
paths: string[],
): Promise<{ deleted: string[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, paths }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files');
return data as { deleted: string[]; skipped: string[] };
}
// タスクワークスペースの複数ファイルを zip でまとめてダウンロード(全 section 可、read)。
// サーバが paths を section root に封じ込め、ファイルのみ zip 化。1 件でも zip にする。
export async function downloadLocalFilesZip(
taskId: number,
section: 'workspace' | 'input' | 'output' | 'logs',
paths: string[],
filename = 'files.zip',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/download-zip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, paths }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to download files');
}
triggerBlobDownload(await res.blob(), filename);
}
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, path, content }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
+369
View File
@@ -0,0 +1,369 @@
// api.ts から分割(挙動不変): ローカルタスク(CRUD・コメント・ツール要求・プロンプトコーチ・フィードバック)。
import { BASE } from './client';
export type PieceName = string; // Dynamically loaded from API
export type ProfileName = 'auto' | 'fast' | 'quality';
export type OutputFormat = 'text' | 'markdown' | 'json';
export type AskPolicy = 'low' | 'high';
export type Priority = 'low' | 'medium' | 'high';
export type Visibility = 'private' | 'org' | 'public';
export interface SubtaskInfo {
id: string;
issueNumber: number;
status: string;
instruction: string;
worktreePath: string | null;
createdAt: string;
updatedAt: string;
children?: SubtaskInfo[];
childCount?: number;
childCompleted?: number;
}
export interface SubtaskActivity {
jobId: string;
issueNumber: number;
status: string;
currentMovement: string | null;
currentActivity: string | null;
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;
outputFormat: string;
askPolicy: string;
priority: string;
state: string;
workspacePath: string | null;
ownerId?: string | null;
ownerName?: string | null;
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
visibilityScopeOrgName?: string | null;
/** Spaces foundation: which space this task belongs to (null = legacy/個人). */
spaceId?: string | null;
createdAt: string;
updatedAt: string;
latestJob?: {
id: string;
status: string;
waitReason?: string | null;
currentMovement?: string | null;
currentActivity?: string | null;
workerId?: string | null;
/**
* Physical backend id (e.g. LiteLLM deployment) for jobs run through
* a proxy worker. NULL until the proxy has resolved a backend or for
* direct workers entirely.
* Phase A: docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
*/
lastBackendId?: string | null;
contextPromptTokens?: number | null;
contextLimitTokens?: number | null;
contextUpdatedAt?: string | null;
} | null;
subtasks?: SubtaskInfo[];
subtaskCount?: number;
subtaskCompleted?: number;
feedbackRating?: 'good' | 'bad' | null;
feedbackTags?: string[] | null;
feedbackComment?: string | null;
feedbackAt?: string | null;
shareToken?: string | null;
sharedAt?: string | null;
missionBrief?: MissionBrief | null;
}
export interface MissionBrief {
goal: string;
done: string;
open: string;
clarifications: string;
user_constraints?: string;
decisions?: string;
current_focus?: string;
}
export async function updateMissionBrief(
taskId: number,
patch: Partial<MissionBrief>,
): Promise<MissionBrief | null> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/mission`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
return data.missionBrief ?? null;
}
// ─── Tool-request mechanism ────────────────────────────────────────────────
export interface ToolRequest {
id: string;
taskId: string | null;
jobId: string | null;
spaceId: string | null;
pieceName: string;
movementName: string;
toolName: string;
reason: string | null;
category: 'requested' | 'blocked' | 'unknown';
status: 'pending' | 'approved' | 'denied' | 'auto_denied';
grantScope: 'task' | 'piece' | null;
decidedBy: string | null;
createdAt: string;
decidedAt: string | null;
}
export async function fetchToolRequests(taskId: number): Promise<ToolRequest[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-requests`);
if (!res.ok) throw new Error('failed to fetch tool requests');
const data = await res.json();
return (data.toolRequests ?? []) as ToolRequest[];
}
export async function decideToolRequest(
taskId: number,
reqId: string,
decision: 'approve' | 'deny',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-requests/${reqId}/decide`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
// Package-request mechanism (PR3): agent-declared Python package requests.
export interface PackageRequest {
id: string;
taskId: string | null;
jobId: string | null;
spaceId: string | null;
pieceName: string | null;
movementName: string | null;
spec: string;
normalizedName: string;
reason: string | null;
status: 'pending' | 'approved' | 'denied' | 'auto_denied';
decidedBy: string | null;
createdAt: string;
decidedAt: string | null;
}
export async function fetchPackageRequests(taskId: number): Promise<PackageRequest[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/package-requests`);
if (!res.ok) throw new Error('failed to fetch package requests');
const data = await res.json();
return (data.packageRequests ?? []) as PackageRequest[];
}
export async function decidePackageRequest(
taskId: number,
reqId: string,
decision: 'approve' | 'deny',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/package-requests/${reqId}/decide`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection';
export interface LocalTaskComment {
id: number;
taskId: number;
author: string;
kind: CommentKind;
body: string;
/** Filenames attached to this comment, saved under the task's input/ dir. */
attachments?: string[];
createdAt: string;
injectedAt: string | null;
}
export interface CreateLocalTaskInput {
title?: string;
body: string;
piece: PieceName;
profile: ProfileName;
outputFormat: OutputFormat;
askPolicy: AskPolicy;
priority: Priority;
attachments?: Array<{ name: string; contentBase64: string }>;
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
browserSessionProfileId?: number | null;
/**
* Spaces foundation: 'persistent'(既定)はスペースのワークスペースに蓄積、
* 'ephemeral' は使い捨て。未指定時はバックエンドが 'persistent' に解決する。
*/
workspaceMode?: 'persistent' | 'ephemeral';
/** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */
spaceId?: string;
options?: {
mcpDisabled?: boolean;
skillsDisabled?: boolean;
};
}
export async function fetchLocalTasks(): Promise<LocalTask[]> {
const res = await fetch(`${BASE}/local/tasks`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local tasks');
return data.tasks ?? [];
}
export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ task: LocalTask; jobId: string }> {
const res = await fetch(`${BASE}/local/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create local task');
return data;
}
export interface PromptCoachAxis {
name: string;
score: number;
comment: string;
}
export interface PromptCoachResult {
overall: number;
axes: PromptCoachAxis[];
rewrite: string;
predicted_piece: { name: string; reason: string } | null;
maestro_tips: Array<{ feature: string; suggestion: string }>;
personalized: string[];
}
/**
* On-demand prompt coach: evaluates a draft task prompt before it is submitted.
* Stateless — nothing is persisted. Returns 503 when the coach is unconfigured.
*/
export async function evaluatePrompt(input: { instruction: string; piece?: string }): Promise<PromptCoachResult> {
const res = await fetch(`${BASE}/local/tasks/evaluate-prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to evaluate prompt');
return data as PromptCoachResult;
}
export async function fetchLocalTask(taskId: number): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task');
return data.task;
}
export async function fetchLocalTaskComments(taskId: number): Promise<LocalTaskComment[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task comments');
return data.comments ?? [];
}
export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise<void> {
const payload: Record<string, unknown> = { body, author };
if (attachments && attachments.length > 0) payload.attachments = attachments;
const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to post local task comment');
}
export async function updateLocalTask(
taskId: number,
updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update local task');
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 },
): Promise<{ jobId: string }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/continue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to continue task');
return data;
}
export async function deleteLocalTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete local task');
}
export async function cancelLocalTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/cancel`, {
method: 'POST',
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to cancel task');
}
export async function putFeedback(
taskId: number,
feedback: { rating: 'good' | 'bad'; tags: string[]; comment?: string },
): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/feedback`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedback),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update feedback');
return data.task;
}
+44
View File
@@ -0,0 +1,44 @@
// api.ts から分割(挙動不変): ツールカタログ。
import { BASE } from './client';
// --- Tools ---
/**
* Runtime tool catalog entry. Mirrors `ToolCatalogEntry` exported by
* `src/bridge/tools-api.ts` (server side). See design doc step 4:
* docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md
*/
export interface ToolCatalogEntry {
name: string;
source: 'builtin' | 'meta' | 'mcp';
/**
* Coarse grouping for UI. For builtin/meta tools this is a module name
* (e.g. 'core', 'web'). For MCP tools the server uses `mcp:<serverId>`.
*/
category: string;
/** MCP server id (only set when source === 'mcp'). */
serverId?: string;
/** Whether the tool can be invoked right now. */
available: boolean;
/** Human-readable explanation when `available` is false. */
reason?: string;
/**
* - 'global' → meta tools auto-injected by the agent loop (always available)
* - 'piece' → builtin tool gated by the workspace tool policy (Settings → Tools)
* - 'user' → per-user resource (MCP / SSH)
*/
scope: 'global' | 'piece' | 'user';
}
export async function fetchTools(): Promise<ToolCatalogEntry[]> {
const res = await fetch(`${BASE}/tools`);
if (!res.ok) throw new Error('Failed to fetch tools');
const data = (await res.json()) as { tools?: unknown };
if (!Array.isArray(data.tools)) return [];
// Server may still occasionally serve the legacy flat-string shape (e.g.
// during a transient mismatch / proxy / cache). Filter to only well-formed
// catalog entries so the UI never crashes; legacy strings are dropped.
return data.tools.filter(
(t): t is ToolCatalogEntry =>
typeof t === 'object' && t !== null && typeof (t as { name?: unknown }).name === 'string',
);
}
+57
View File
@@ -0,0 +1,57 @@
// api.ts から分割(挙動不変): LLM usage ダッシュボード v2。
import { BASE } from './client';
// ============================================================
// LLM usage dashboard v2 (per-user, multi-axis group-by + local timezone).
// Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
export interface UsageCounters {
tokensIn: number;
tokensOut: number;
requests: number;
}
export type UsageGroupBy = 'source' | 'model' | 'route' | 'user' | 'org';
/** One time bucket: a per-series-key map of counters. Keys come from `keys`. */
export interface UsageBucket {
bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM'
segments: Record<string, 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: 'hour' | 'day' | 'week' | 'month';
groupBy: UsageGroupBy;
tzOffset: number;
scope: 'all' | 'self';
/** Ordered series keys (legend/palette order). */
keys: string[];
/** Human labels for keys (only populated for groupBy=user; else key=label). */
labels: Record<string, string>;
series: UsageBucket[];
totals: Record<string, UsageCounters>;
byUser?: UsageByUser[]; // admin / local mode only
}
export async function getUsageDaily(params: {
from?: string;
to?: string;
granularity?: 'hour' | 'day' | 'week' | 'month';
groupBy?: UsageGroupBy;
tzOffset?: number;
}): 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);
if (params.groupBy) qs.set('groupBy', params.groupBy);
if (params.tzOffset !== undefined) qs.set('tzOffset', String(params.tzOffset));
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();
}
+28
View File
@@ -0,0 +1,28 @@
// api.ts から分割(挙動不変): ユーザー・組織。
import { BASE } from './client';
export interface UserOrg {
orgId: string;
orgName: string;
fetchedAt: string;
}
export async function fetchMyOrgs(): Promise<UserOrg[]> {
const res = await fetch('/api/users/me/orgs');
if (!res.ok) return [];
const { orgs } = (await res.json()) as { orgs: Array<{ orgId: string; orgName: string; fetchedAt: string }> };
return orgs;
}
export interface PickableUser {
id: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
}
export async function fetchPickableUsers(): Promise<PickableUser[]> {
const res = await fetch(`${BASE}/users/pickable`);
if (!res.ok) return [];
return (await res.json()) as PickableUser[];
}
+118
View File
@@ -0,0 +1,118 @@
// api.ts から分割(挙動不変): ワーカー・バックエンド・ノード状態(Side Info Panel)。
export interface WorkerInfo {
id: string;
endpoint: string | null;
model: string | null;
roles: string[];
enabled: boolean;
/** True if this worker fronts an LLM gateway / proxy (Phase A). */
proxy?: boolean;
/** Proxy implementation; only 'litellm' is currently shipped. */
proxyType?: 'litellm';
}
export async function fetchWorkers(): Promise<WorkerInfo[]> {
const res = await fetch('/api/workers', { credentials: 'include' });
if (!res.ok) return [];
const data = await res.json() as { workers?: WorkerInfo[] };
return data.workers ?? [];
}
export interface BackendInfo {
id: string;
model: string | null;
online: boolean;
}
export interface WorkerBackendsResponse {
source: 'direct' | 'proxy';
proxyType?: 'litellm';
backends: BackendInfo[];
/** Set when the proxy probe failed (network error, 5xx). UI renders degraded. */
error?: string;
}
export async function fetchWorkerBackends(workerId: string): Promise<WorkerBackendsResponse> {
const res = await fetch(`/api/workers/${encodeURIComponent(workerId)}/backends`, {
credentials: 'include',
});
if (!res.ok && res.status !== 502) {
// 502 still carries a typed payload from the server.
return { source: 'direct', backends: [], error: `HTTP ${res.status}` };
}
return await res.json() as WorkerBackendsResponse;
}
// ── Side Info Panel ────────────────────────────────────────────────────────
export interface NodeStatus {
nodeId: string;
workerId: string;
source: 'direct' | 'proxy';
online: boolean;
busy: boolean;
busySlots: number;
totalSlots: number;
loadedModel: string | null;
throughputTps: number | null;
lastSeen: string;
lastProbeError?: string;
}
export interface WorkerStatusBackendRow {
id: string;
state: 'idle' | 'running';
busySlots: number;
totalSlots: number;
online: boolean | null;
}
export interface WorkerStatusRow {
id: string;
name: string;
roles: string[];
state: 'idle' | 'running';
/** True when this row represents a `proxy: true` worker. */
proxy: boolean;
/** Slot pressure from BackendStatusRegistry. Populated for direct workers with a registry probe row. */
busySlots?: number;
totalSlots?: number;
online?: boolean;
/** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */
backends?: WorkerStatusBackendRow[];
/**
* Occupants of this worker — users whose running jobs use it, each with
* the job kind (`task_kind`: 'agent' = normal, 'reflection' = learning).
* Admin-only — the server only populates this for admins, so non-admin
* clients always receive undefined.
*/
occupants?: Array<{ user: string; kind: string }>;
}
export async function fetchWorkerStatuses(): Promise<WorkerStatusRow[]> {
const res = await fetch('/api/local/dashboard/workers');
if (!res.ok) throw new Error(`Failed to list worker statuses: ${res.status}`);
return (await res.json()).workers;
}
/** Thrown by fetchNodeStatus when the registry is not configured (HTTP 503). */
export class NodeStatusUnavailableError extends Error {
constructor() {
super('node-status registry not configured');
this.name = 'NodeStatusUnavailableError';
}
}
export async function fetchNodeStatus(): Promise<NodeStatus[]> {
const res = await fetch('/api/local/dashboard/node-status');
if (!res.ok) {
// 503 = registry not configured (e.g. legacy install). Surface as an
// error so the React Query hook can back off polling instead of
// hammering the server every 5s indefinitely. The hook turns this
// into an empty list for rendering.
if (res.status === 503) throw new NodeStatusUnavailableError();
throw new Error(`Failed to list node status: ${res.status}`);
}
return (await res.json()).nodes;
}
+2 -2
View File
@@ -301,7 +301,7 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
// movement-complete arrives (live tool calls during running movement).
const toolCall = parseToolCallComment(comment.body);
if (toolCall) {
return <ToolCallsSection toolCalls={[toolCall]} />;
return <ToolCallsSection toolCalls={[{ ...toolCall, ts: comment.createdAt }]} />;
}
// Checklist progress → dedicated card (center, retained as per decision)
@@ -435,7 +435,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hi
{author} · {new Date(createdAt).toLocaleString()}
</div>
<div className="text-sm leading-relaxed prose prose-sm prose-slate dark:prose-invert max-w-none">
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} />
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} escapeRawHtml />
</div>
</div>
</div>
+105 -91
View File
@@ -9,9 +9,11 @@ import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup'
import { SubtaskInlineCard } from './SubtaskInlineCard';
import RotatingTips from './RotatingTips';
import { ToolRequestApproval } from './ToolRequestApproval';
import { PackageRequestApproval } from './PackageRequestApproval';
import { DelegateLiveConsole } from './DelegateLiveConsole';
import { useJobStream } from '../../hooks/useJobStream';
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
async function toBase64(file: File): Promise<string> {
@@ -48,6 +50,14 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
const [sendError, setSendError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Firefox など field-sizing 未対応環境だけ JS で高さ追従(判定は初回のみ)。
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
useEffect(() => {
if (!needsAutosizeFallback) return;
const el = composerRef.current;
if (el) autosizeTextarea(el, 192); // 192px ≈ 8行 (max-h-48 と一致させる)
}, [draft, needsAutosizeFallback]);
// Snapshot of comments.length at submit start. We hold "submitting" until
// (a) the new user comment is reflected in the list AND (b) the job is
// visibly busy (= picked up by a worker). Without this, the gap between the
@@ -272,13 +282,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
lastBackendId={task.latestJob?.lastBackendId ?? null}
/>
{/* Header */}
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-slate-900 truncate">{task.title}</h2>
<div className="text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 py-2">
<div className="flex items-center gap-2">
<h2 className="min-w-0 truncate text-sm font-semibold text-slate-900">{task.title}</h2>
<span className="shrink-0 text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</span>
<div className="ml-auto flex items-center gap-1.5 flex-shrink-0">
{isBusy && (
<div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${
isWaitingSubtasks
@@ -444,8 +452,10 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
)}
</div>
{/* Composer */}
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-3" style={{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }}>
{/* Composer — カード+ツールバー型。上段 textarea(自動拡張)、下段に
添付・コンテキスト残量・送信系を集約。旧: 独立ゲージ行+2行 textarea で
約114px → 待機時約85px。 */}
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-2.5" style={{ paddingBottom: 'calc(10px + env(safe-area-inset-bottom, 0px))' }}>
{hasActiveJob && (
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
canInterject
@@ -462,6 +472,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
</div>
)}
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
<PackageRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
{sendError && !isBusy && (
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
<span className="truncate"> {sendError}</span>
@@ -475,108 +486,111 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
</button>
</div>
)}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{attachments.map(a => (
<span key={a.name} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-surface-2 border border-hairline rounded text-[10px] text-slate-700 font-mono">
{a.name}
<button onClick={() => removeAttachment(a.name)} className="text-slate-400 hover:text-slate-700 ml-0.5">&times;</button>
</span>
))}
</div>
)}
{/* コンテキスト残量を入力欄の直上に常時表示。概要タブまでスクロールせずに、
入力しながら「あとどれくらい書けるか」を把握できる(issue #009)。 */}
<div className="mb-2">
<ContextUsageGauge
compact
promptTokens={task.latestJob?.contextPromptTokens}
limitTokens={task.latestJob?.contextLimitTokens}
jobStatus={task.latestJob?.status}
/>
</div>
<div className="flex gap-1.5 items-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { void handleFiles(e.target.files); e.target.value = ''; }}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={composerLocked || submitting}
className="flex-shrink-0 w-9 h-9 flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-surface rounded-md transition-colors disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={t('pane.attachFile')}
aria-label={t('pane.attachFile')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
</svg>
</button>
<div className={`rounded-xl border transition-shadow ${composerLocked ? 'border-hairline bg-surface' : 'border-hairline bg-canvas focus-within:border-accent focus-within:ring-2 focus-within:ring-accent-ring'}`}>
<textarea
ref={composerRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={e => void handlePaste(e)}
rows={2}
rows={1}
disabled={composerLocked}
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
className="block w-full resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 text-sm leading-6 text-slate-900 outline-none [field-sizing:content] min-h-6 max-h-48 overflow-y-auto disabled:text-slate-400 disabled:cursor-not-allowed"
/>
{isBusy && onCancel ? (
<div className="flex gap-1.5">
{canInterject && (
<div className="flex flex-wrap items-center gap-1.5 px-2 pb-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { void handleFiles(e.target.files); e.target.value = ''; }}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={composerLocked || submitting}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
title={t('pane.attachFile')}
aria-label={t('pane.attachFile')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
</svg>
</button>
{attachments.length > 0 && (
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
{attachments.map(a => (
<span key={a.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
{a.name}
<button onClick={() => removeAttachment(a.name)} className="ml-0.5 text-slate-400 hover:text-slate-700">&times;</button>
</span>
))}
</div>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
{/* コンテキスト残量: 入力しながら「あとどれくらい書けるか」を把握
できる位置に常時表示(issue #009 の趣旨を維持)。 */}
<ContextUsageGauge
inline
promptTokens={task.latestJob?.contextPromptTokens}
limitTokens={task.latestJob?.contextLimitTokens}
jobStatus={task.latestJob?.status}
/>
{isBusy && onCancel ? (
<div className="flex gap-1.5">
{canInterject && (
<button
disabled={submitting || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t('pane.interject')}
</button>
)}
<button
disabled={cancelling}
onClick={() => void handleCancel()}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
title={t('pane.stopAgent')}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
</svg>
{cancelling ? t('pane.stopping') : t('pane.stop')}
</button>
</div>
) : isPending ? (
<button
disabled={submitting || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
title={t('pane.addToQueuedHint')}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t('pane.interject')}
{t('pane.addToQueued')}
</button>
) : (
<button
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M22 2 11 13" />
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
</svg>
{t('pane.send')}
</button>
)}
<button
disabled={cancelling}
onClick={() => void handleCancel()}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
title={t('pane.stopAgent')}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
</svg>
{cancelling ? t('pane.stopping') : t('pane.stop')}
</button>
</div>
) : isPending ? (
<button
disabled={submitting || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
title={t('pane.addToQueuedHint')}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t('pane.addToQueued')}
</button>
) : (
<button
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M22 2 11 13" />
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
</svg>
{t('pane.send')}
</button>
)}
</div>
</div>
</div>
</div>
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
/**
* The movement group header in the conversation shows the movement's execution
* time (e.g. "1m 30s"). Users also want to see WHEN the movement finished, so
* the header renders the completion date/time next to the duration.
*/
import '../../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { MovementGroupExpanded, type ChatItem } from './MovementGroup';
import type { LocalTaskComment } from '../../api';
function completionComment(createdAt: string): LocalTaskComment {
return {
id: 1,
taskId: 1,
author: 'agent',
kind: 'progress',
body: JSON.stringify({ movement: 'execute', tools: { Read: 2 }, durationMs: 90000 }),
createdAt,
injectedAt: null,
};
}
function movementItem(createdAt: string): ChatItem & { type: 'movement' } {
const c = completionComment(createdAt);
return {
type: 'movement',
movementName: 'execute',
summary: { movement: 'execute', tools: { Read: 2 }, durationMs: 90000 },
inner: [],
completionComment: c,
};
}
describe('MovementGroupExpanded header', () => {
it('shows the movement completion date/time alongside the duration', () => {
const iso = '2026-07-06T01:23:45.000Z';
const { container } = render(
<MovementGroupExpanded
item={movementItem(iso)}
taskId={1}
isLast={false}
isRunning={false}
animatingIdx={-1}
startIdx={0}
/>,
);
// Duration still present.
expect(container.textContent).toContain('1m 30s');
// The completion timestamp, formatted for the local locale, is shown too.
const expected = new Date(iso).toLocaleString();
expect(container.textContent).toContain(expected);
});
});
+5 -2
View File
@@ -136,9 +136,12 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, im
<path d="M6 4l4 4-4 4" />
</svg>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-semibold text-slate-700">{movementName}</span>
<span className="text-[10px] text-slate-400 font-mono tabular-nums">{formatDuration(summary.durationMs)}</span>
<span className="text-[10px] text-slate-400 tabular-nums" title={new Date(item.completionComment.createdAt).toLocaleString()}>
{new Date(item.completionComment.createdAt).toLocaleString()}
</span>
</div>
</div>
</button>
@@ -173,7 +176,7 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, im
const tc = isToolCallComment(c) ? parseToolCallComment(c.body) : null;
if (tc) {
if (toolBuf.length === 0) toolFirstId = c.id;
toolBuf.push(tc);
toolBuf.push({ ...tc, ts: c.createdAt });
} else {
flushTools();
blocks.push({ kind: 'comment', comment: c, origIdx: i });
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
/**
* Component tests for PackageRequestApproval — the inline Approve/Deny card
* shown in chat when the agent paused on a RequestPackage. Network
* (fetchPackageRequests / decidePackageRequest) is fully mocked; i18n uses the
* real instance so labels resolve.
*/
import '../../test/dom-setup';
import '../../i18n';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import type { PackageRequest } from '../../api';
import * as api from '../../api';
import { PackageRequestApproval } from './PackageRequestApproval';
vi.mock('../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api')>();
return {
...actual,
fetchPackageRequests: vi.fn(),
decidePackageRequest: vi.fn(),
};
});
const mockedFetch = vi.mocked(api.fetchPackageRequests);
const mockedDecide = vi.mocked(api.decidePackageRequest);
function req(overrides: Partial<PackageRequest> = {}): PackageRequest {
return {
id: 'req-1',
taskId: '7',
jobId: 'job-1',
spaceId: 'space-1',
pieceName: 'chat',
movementName: 'execute',
spec: 'requests==2.32.3',
normalizedName: 'requests',
reason: 'need http',
status: 'pending',
decidedBy: null,
createdAt: '2026-07-06T00:00:00Z',
decidedAt: null,
...overrides,
};
}
describe('PackageRequestApproval', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedDecide.mockReset();
});
it('renders nothing when there are no pending requests', async () => {
mockedFetch.mockResolvedValue([req({ status: 'approved' })]);
const { container } = renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith(7));
expect(container.querySelector('[data-testid="package-request-approval"]')).toBeNull();
});
it('renders a card per pending request with spec + reason', async () => {
mockedFetch.mockResolvedValue([
req({ id: 'r1', spec: 'requests==2.32.3', normalizedName: 'requests', reason: 'http calls' }),
req({ id: 'r2', spec: 'pandas', normalizedName: 'pandas', reason: null }),
]);
renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
expect(await screen.findByTestId('package-request-requests')).toBeInTheDocument();
expect(screen.getByTestId('package-request-pandas')).toBeInTheDocument();
expect(screen.getByText(/requests==2.32.3/)).toBeInTheDocument();
expect(screen.getByText(/http calls/)).toBeInTheDocument();
});
it('Approve calls decidePackageRequest with approve + id', async () => {
mockedFetch.mockResolvedValue([req({ id: 'r1', normalizedName: 'requests' })]);
mockedDecide.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
await user.click(await screen.findByTestId('package-request-approve-requests'));
await waitFor(() => expect(mockedDecide).toHaveBeenCalledWith(7, 'r1', 'approve'));
});
it('shows an error row when the decide mutation rejects', async () => {
mockedFetch.mockResolvedValue([req({ id: 'r1', normalizedName: 'requests' })]);
mockedDecide.mockRejectedValue(new Error('install failed'));
const user = userEvent.setup();
renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
await user.click(await screen.findByTestId('package-request-approve-requests'));
expect(await screen.findByTestId('package-request-error')).toHaveTextContent('install failed');
});
});
@@ -0,0 +1,80 @@
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchPackageRequests, decidePackageRequest } from '../../api';
/**
* Inline approval card shown in the chat when the agent is paused waiting for a
* user to approve/deny a Python package it requested (RequestPackage). Approving
* installs the wheel into this workspace's overlay and resumes the paused job,
* so the chat continues on its own. Mirrors ToolRequestApproval.
*/
export function PackageRequestApproval({ taskId, poll }: { taskId: number; poll: boolean }) {
const { t } = useTranslation('chat');
const qc = useQueryClient();
const { data: requests = [] } = useQuery({
queryKey: ['package-requests', taskId],
queryFn: () => fetchPackageRequests(taskId),
refetchInterval: poll ? 3000 : false,
});
const decide = useMutation({
mutationFn: ({ reqId, decision }: { reqId: string; decision: 'approve' | 'deny' }) =>
decidePackageRequest(taskId, reqId, decision),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['package-requests', taskId] });
qc.invalidateQueries({ queryKey: ['localTask', taskId] });
qc.invalidateQueries({ queryKey: ['localTasks'] });
},
});
const pending = requests.filter((r) => r.status === 'pending');
if (pending.length === 0) return null;
return (
<div className="mb-3 space-y-2" data-testid="package-request-approval">
{pending.map((r) => (
<div
key={r.id}
data-testid={`package-request-${r.normalizedName}`}
className="rounded-lg border border-sky-300 bg-sky-50 p-3 text-sm dark:border-sky-700/60 dark:bg-sky-900/20"
>
<div className="font-medium text-sky-900 dark:text-sky-200">
{t('packageRequest.title', { spec: r.spec })}
</div>
{r.reason && (
<div className="mt-1 text-sky-800/90 dark:text-sky-200/80">
{t('packageRequest.reason')}: {r.reason}
</div>
)}
<div className="mt-1 text-xs text-sky-700/70 dark:text-sky-300/60">{t('packageRequest.note')}</div>
{decide.isError && (
<div className="mt-1 text-xs text-red-700 dark:text-red-300" data-testid="package-request-error">
{t('packageRequest.failed')}: {(decide.error as Error)?.message ?? ''}
</div>
)}
<div className="mt-2 flex gap-2">
<button
type="button"
data-testid={`package-request-approve-${r.normalizedName}`}
disabled={decide.isPending}
onClick={() => decide.mutate({ reqId: r.id, decision: 'approve' })}
className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
>
{t('packageRequest.approve')}
</button>
<button
type="button"
data-testid={`package-request-deny-${r.normalizedName}`}
disabled={decide.isPending}
onClick={() => decide.mutate({ reqId: r.id, decision: 'deny' })}
className="rounded-md border border-stone-300 bg-white px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:bg-stone-700"
>
{t('packageRequest.deny')}
</button>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
/**
* Test timestamp display in tool call rows.
*/
import '../../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ToolCallsSection, type ToolCallData } from './ToolCallsSection';
const mockToolCall = (ts?: string): ToolCallData => ({
callId: 'call-001',
movement: 'execute',
name: 'Read',
args: '{"file_path": "/tmp/test.txt"}',
result: 'Success',
isError: false,
durationMs: 1500,
cacheHit: false,
ts,
});
describe('ToolCallsSection timestamp display', () => {
it('displays execution time when ts is provided', () => {
const iso = '2026-07-07T14:30:45.000Z';
const tc = mockToolCall(iso);
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
// Time should be displayed (formatted as local time)
const timeString = new Date(iso).toLocaleTimeString();
expect(container.textContent).toContain(timeString);
});
it('does not display time span when ts is undefined', () => {
const tc = mockToolCall();
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
// Duration should still be present
expect(container.textContent).toContain('1.5s');
});
it('displays full datetime in title attribute when ts is provided', () => {
const iso = '2026-07-07T14:30:45.000Z';
const tc = mockToolCall(iso);
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
const timeSpan = container.querySelector('[title]');
expect(timeSpan).toBeTruthy();
expect(timeSpan?.getAttribute('title')).toBe(new Date(iso).toLocaleString());
});
it('renders time and duration both when ts is provided', () => {
const iso = '2026-07-07T14:30:45.123Z';
const tc = mockToolCall(iso);
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
// Both time and duration should be present
const timeStr = new Date(iso).toLocaleTimeString();
expect(container.textContent).toContain(timeStr);
expect(container.textContent).toContain('1.5s');
});
it('renders timestamps for all tool calls when present', () => {
// Single tool call with ts displays the timestamp
const iso = '2026-07-07T14:30:00.000Z';
const tc = mockToolCall(iso);
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
const timeStr = new Date(iso).toLocaleTimeString();
expect(container.textContent).toContain(timeStr);
});
it('displays cache hit label instead of duration', () => {
const iso = '2026-07-07T14:30:45.000Z';
const tc = { ...mockToolCall(iso), cacheHit: true };
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
expect(container.textContent).toContain('cache');
// Duration should not be shown for cache hit
expect(container.textContent).not.toContain('1s');
});
});
+7 -1
View File
@@ -9,6 +9,7 @@ export interface ToolCallData {
isError: boolean;
durationMs: number;
cacheHit: boolean;
ts?: string;
}
export function parseToolCallComment(body: string): ToolCallData | null {
@@ -116,7 +117,12 @@ function ToolCallRow({ tc }: { tc: ToolCallData }) {
)}
<span className="font-mono font-medium text-slate-700 flex-shrink-0">{display.name}</span>
{summary && <span className="font-mono text-slate-500 truncate min-w-0">{summary}</span>}
<span className="text-slate-400 tabular-nums ml-auto flex-shrink-0">
{tc.ts && (
<span className="text-slate-400 tabular-nums text-[10px] flex-shrink-0 ml-auto" title={new Date(tc.ts).toLocaleString()}>
{new Date(tc.ts).toLocaleTimeString()}
</span>
)}
<span className={`text-slate-400 tabular-nums flex-shrink-0 ${!tc.ts ? 'ml-auto' : ''}`}>
{tc.cacheHit ? 'cache' : formatDuration(tc.durationMs)}
</span>
</button>
@@ -0,0 +1,41 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ContextUsageGauge, pickColorClass } from './ContextUsageGauge';
describe('pickColorClass', () => {
it('しきい値 70/85/95% で色が変わる', () => {
expect(pickColorClass(0.5)).toBe('bg-emerald-500');
expect(pickColorClass(0.7)).toBe('bg-amber-500');
expect(pickColorClass(0.85)).toBe('bg-orange-500');
expect(pickColorClass(0.95)).toBe('bg-red-500');
});
});
describe('ContextUsageGauge inline', () => {
it('limitTokens が無ければ何も描画しない', () => {
const { container } = render(<ContextUsageGauge inline promptTokens={100} limitTokens={0} />);
expect(container.firstChild).toBeNull();
});
it('70%未満はバーのみ(%テキストなし)', () => {
render(<ContextUsageGauge inline promptTokens={30_000} limitTokens={100_000} />);
const el = screen.getByTestId('context-gauge-inline');
expect(el).toBeInTheDocument();
expect(el.textContent).not.toContain('%');
});
it('70%以上は%テキストを常時表示する(警告の役割)', () => {
render(<ContextUsageGauge inline promptTokens={80_000} limitTokens={100_000} />);
expect(screen.getByTestId('context-gauge-inline').textContent).toContain('80%');
});
it('タッチ/SR 向けに aria-label と title でフル数値を提供する', () => {
render(<ContextUsageGauge inline promptTokens={80_000} limitTokens={100_000} />);
const el = screen.getByTestId('context-gauge-inline');
expect(el.getAttribute('title')).toContain('80,000');
expect(el.getAttribute('title')).toContain('100,000');
expect(el.getAttribute('aria-label')).toBeTruthy();
});
});
+12 -11
View File
@@ -5,17 +5,17 @@ interface ContextUsageGaugeProps {
limitTokens?: number | null;
jobStatus?: string;
/**
* compact: 入力欄の直上に常時表示する低プロファイルのバー。概要タブのカード型
* (既定)と同じ色・比率ロジックを共有しつつ、薄い 1 行表示にする。
* inline: コンポーザーのツールバー内に置く最小表示。バー+70%以上でのみ%テキスト。
* 詳細は title / aria-label で提供される。
*/
compact?: boolean;
inline?: boolean;
}
function formatNumber(n: number): string {
return n.toLocaleString('en-US');
}
function pickColorClass(ratio: number): string {
export function pickColorClass(ratio: number): string {
if (ratio >= 0.95) return 'bg-red-500';
if (ratio >= 0.85) return 'bg-orange-500';
if (ratio >= 0.70) return 'bg-amber-500';
@@ -36,7 +36,7 @@ function pickLabel(jobStatus: string | undefined): string {
}
}
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, compact }: ContextUsageGaugeProps) {
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, inline }: ContextUsageGaugeProps) {
const { t } = useTranslation('detail');
if (!limitTokens || limitTokens <= 0) return null;
@@ -48,22 +48,23 @@ export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, compac
const colorClass = pickColorClass(ratio);
const label = pickLabel(jobStatus);
if (compact) {
if (inline) {
return (
<div
className="flex items-center gap-2 text-2xs text-slate-500 tabular-nums"
data-testid="context-gauge-inline"
className="flex min-w-0 items-center gap-1.5"
title={`${formatNumber(tokens)} / ${formatNumber(limitTokens)} tokens`}
aria-label={t('context.ariaLabel', { remaining: formatNumber(remaining), percent })}
>
<div className="h-1.5 flex-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-700">
<div
className={`h-full ${colorClass} transition-[width] duration-300 ease-out`}
style={{ width: `${percent}%` }}
/>
</div>
<span className="shrink-0">
{awaiting ? t('context.awaiting') : t('context.remaining', { remaining: formatNumber(remaining), percent })}
</span>
{ratio >= 0.7 && (
<span className="shrink-0 text-2xs tabular-nums text-slate-500">{percent}%</span>
)}
</div>
);
}
@@ -19,9 +19,21 @@ beforeAll(async () => {
'delegateRuns.eventsEmpty': 'No events',
'delegateRuns.subtaskGroupTitle': 'Subtask #{{n}}',
'delegateRuns.subtaskSectionHeading': 'Delegate runs in subtasks',
'subtasks.delegateSection': 'Delegate (serial)',
'delegateRuns.result': 'Result',
'delegateRuns.abortReason': 'Abort reason',
'delegateRuns.noDescription': '(no description)',
'delegateRuns.toolCount': '{{count}} tools',
'delegateRuns.childCount': '{{total}} children',
'delegateRuns.childCountFailed': '{{total}} children, {{failed}} failed',
'delegateRuns.moreEvents': '{{count}} more events (see the Trace tab for the full list)',
'delegateRuns.toolSummary': 'Tools used',
'delegateRuns.filesChanged': 'Changed files',
'delegateRuns.eventsToggle': 'Detailed events ({{count}})',
'subtasks.delegateSection': 'Delegated runs',
'subtasks.delegateStatus.success': 'Done',
'subtasks.delegateStatus.aborted': 'Aborted',
'subtasks.delegateStatus.running': 'Running',
'subtasks.delegateRunningTool': '{{tool}} running',
},
common: { loading: 'Loading...' },
},
@@ -45,6 +57,101 @@ vi.mock('../../../api', () => ({
endTs: '2026-01-01T00:01:00Z',
eventCount: 2,
toolCalls: 1,
resultPreview: 'Success result preview',
totalTokens: 1234,
toolSummary: [{ tool: 'Write', count: 2 }, { tool: 'Read', count: 1 }],
filesChanged: ['output/result.md', 'output/summary.txt'],
},
{
delegateRunId: 'c1',
parentRunId: 'p1',
description: '子委譲1',
depth: 2,
status: 'success',
startTs: '2026-01-01T00:00:30Z',
endTs: '2026-01-01T00:00:45Z',
eventCount: 1,
toolCalls: 0,
resultPreview: null,
totalTokens: 0,
},
{
delegateRunId: 'c2',
parentRunId: 'p1',
description: '子委譲2',
depth: 2,
status: 'success',
startTs: '2026-01-01T00:00:46Z',
endTs: '2026-01-01T00:01:00Z',
eventCount: 1,
toolCalls: 0,
resultPreview: null,
totalTokens: 567,
},
{
delegateRunId: 'p2',
parentRunId: null,
description: '失敗した親委譲',
depth: 1,
status: 'success',
startTs: '2026-01-01T00:02:00Z',
endTs: '2026-01-01T00:03:00Z',
eventCount: 1,
toolCalls: 0,
resultPreview: 'Operation completed with errors',
totalTokens: 0,
},
{
delegateRunId: 'c3',
parentRunId: 'p2',
description: '失敗した子委譲',
depth: 2,
status: 'aborted',
startTs: '2026-01-01T00:02:30Z',
endTs: '2026-01-01T00:03:00Z',
eventCount: 1,
toolCalls: 0,
resultPreview: 'Failed',
totalTokens: 100,
},
{
delegateRunId: 'p3',
parentRunId: null,
description: '結果なし委譲',
depth: 1,
status: 'success',
startTs: '2026-01-01T00:04:00Z',
endTs: '2026-01-01T00:05:00Z',
eventCount: 1,
toolCalls: 0,
resultPreview: null,
totalTokens: 0,
},
{
delegateRunId: 'p4',
parentRunId: null,
description: '実行中の親委譲',
depth: 1,
status: 'running',
startTs: '2026-01-01T00:06:00Z',
endTs: null,
eventCount: 1,
toolCalls: 0,
resultPreview: null,
totalTokens: 2000,
},
{
delegateRunId: 'c4',
parentRunId: 'p4',
description: '実行中の子委譲',
depth: 2,
status: 'running',
startTs: '2026-01-01T00:06:30Z',
endTs: null,
eventCount: 1,
toolCalls: 0,
resultPreview: null,
totalTokens: 500,
},
],
subtasks: [
@@ -64,15 +171,19 @@ vi.mock('../../../api', () => ({
endTs: '2026-01-01T00:01:00Z',
eventCount: 1,
toolCalls: 0,
resultPreview: null,
totalTokens: 3456,
},
],
},
],
}),
fetchDelegateRunTimeline: vi.fn().mockResolvedValue([]),
fetchDelegateRunTimeline: vi.fn(),
}));
import { DelegateRunsSection } from './DelegateRunsSection';
import * as api from '../../../api';
import userEvent from '@testing-library/user-event';
describe('DelegateRunsSection', () => {
it('親委譲とサブタスクグループの両方を描画する', async () => {
@@ -82,13 +193,308 @@ describe('DelegateRunsSection', () => {
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// すべてのカードが描画される
const buttons = await screen.findAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
// 親 run の description が表示される
expect(await screen.findByText(/親委譲/)).toBeInTheDocument();
expect(screen.getByText('親委譲')).toBeInTheDocument();
// サブタスク run の description が表示される
expect(await screen.findByText(/サブ委譲/)).toBeInTheDocument();
expect(screen.getByText('サブ委譲')).toBeInTheDocument();
// サブタスクグループ見出しが表示される(en or ja)
expect(
await screen.findByText(/Subtask #1|サブタスク #1/),
screen.getByText(/Subtask #1|サブタスク #1/),
).toBeInTheDocument();
});
it('子の件数バッジが閉じた親カードに表示される(2 子ケース)', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親委譲のカードが表示される(子が 2 つ)
await screen.findByText('親委譲');
// テキスト マッチングで「2 children」が見つかる
expect(screen.getByText('2 children')).toBeInTheDocument();
});
it('子に失敗がある場合、赤いバッジで失敗数を表示', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 失敗した親委譲のバッジが赤く、失敗数を含むテキストが表示される
await screen.findByText('失敗した親委譲');
// バッジに「1 children, 1 failed」が表示される
expect(screen.getByText('1 children, 1 failed')).toBeInTheDocument();
// そのバッジが赤系背景を持つ
const badge = screen.getByText('1 children, 1 failed');
expect(badge).toHaveClass('bg-red-100', 'text-red-800');
});
it('子が実行中の場合、青いバッジで件数を表示', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 実行中の親委譲のカードが表示される
await screen.findByText('実行中の親委譲');
// 青いバッジに「1 children」が表示される
const badge = Array.from(screen.getAllByText('1 children')).find((el) =>
el.className.includes('bg-blue-100')
);
expect(badge).toBeDefined();
expect(badge).toHaveClass('bg-blue-100', 'text-blue-800');
});
it('子がない親カードはバッジが表示されない', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 結果なし委譲(子なし)のカードが表示される
await screen.findByText('結果なし委譲');
// 「children」というテキストは結果なし委譲のカード周辺には無いはず
// ※ 他の親が children を表示しているので、全体ではある
const resultCard = screen.getByText('結果なし委譲').closest('button');
expect(resultCard?.textContent).not.toMatch(/children/);
});
it('すべての親カードが見える', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 複数の親カードが表示される
await screen.findByText('親委譲');
expect(screen.getByText('親委譲')).toBeInTheDocument();
expect(screen.getByText('失敗した親委譲')).toBeInTheDocument();
expect(screen.getByText('結果なし委譲')).toBeInTheDocument();
expect(screen.getByText('実行中の親委譲')).toBeInTheDocument();
});
it('totalTokens > 0 のカードに「1.2k tok」形式のトークン数が表示される', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// totalTokens = 1234 の「親委譲」カードを探す(フォーマットされて「1.2k tok」になるはず)
await screen.findByText('親委譲');
// そのカード内にトークン表示が含まれるかを確認
const parentCard = screen.getByText('親委譲').closest('button');
expect(parentCard?.textContent).toContain('1.2k tok');
});
it('totalTokens = 0 のカードにトークン数は表示されない', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// totalTokens = 0 の「結果なし委譲」カードを探す
await screen.findByText('結果なし委譲');
const card = screen.getByText('結果なし委譲').closest('button');
// そのカード内に「tok」という文字列は無いはず
expect(card?.textContent).not.toMatch(/\d+.*tok|tok.*\d+/);
});
it('カードを開くと aria-expanded=true がボタンに付く', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
const buttons = await screen.findAllByRole('button');
const parentButton = buttons[0];
// 初期状態(閉じた)で aria-expanded=false
expect(parentButton).toHaveAttribute('aria-expanded', 'false');
// クリックして開く
await userEvent.click(parentButton);
expect(parentButton).toHaveAttribute('aria-expanded', 'true');
});
it('25 件のイベントを返すモックでカードを開きイベントトグルを押すと EventLine が 20 件+「他 5 件」案内が出る', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const events = Array.from({ length: 25 }, (_, i) => ({
eventId: `event${i}`,
ts: new Date(2026, 0, 1, 0, 0, i).toISOString(),
seq: i,
kind: 'tool_call',
payload: { tool: 'Read' },
}));
vi.mocked(api.fetchDelegateRunTimeline).mockResolvedValueOnce(events);
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親委譲のカードを開く
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
// イベントトグルを押す
const eventsToggle = await screen.findByText(/Detailed events/i);
await userEvent.click(eventsToggle);
// 「他 5 件」というテキストが表示される
expect(screen.getByText(/5 more events/i)).toBeInTheDocument();
});
it('20 件以下のイベントなら「他 N 件」案内は出ない', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const events = Array.from({ length: 10 }, (_, i) => ({
eventId: `event${i}`,
ts: new Date(2026, 0, 1, 0, 0, i).toISOString(),
seq: i,
kind: 'tool_call',
payload: { tool: 'Read' },
}));
vi.mocked(api.fetchDelegateRunTimeline).mockResolvedValueOnce(events);
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親委譲のカードを開く
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
// イベントトグルを押す
const eventsToggle = await screen.findByText(/Detailed events/i);
await userEvent.click(eventsToggle);
// 「more events」というテキストは表示されない
expect(screen.queryByText(/more events/i)).not.toBeInTheDocument();
});
it('toolSummary ありの run でチップが表示される', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親委譲のカードを開く(toolSummary が設定されている)
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
// ツール名とカウントが表示される
expect(screen.getByText('Write ×2')).toBeInTheDocument();
expect(screen.getByText('Read ×1')).toBeInTheDocument();
});
it('toolSummary 空または undefined なら見出しごと出ない', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// toolSummary がない「結果なし委譲」のカードを開く
await screen.findByText('結果なし委譲');
const card = screen.getByText('結果なし委譲').closest('button') as HTMLButtonElement;
await userEvent.click(card);
// 「Tools used」テキストは見つからない
expect(screen.queryByText(/Tools used/i)).not.toBeInTheDocument();
});
it('filesChanged ありの run でパスが表示される', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親委譲のカードを開く(filesChanged が設定されている)
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
// ファイルパスが表示される
expect(screen.getByText('output/result.md')).toBeInTheDocument();
expect(screen.getByText('output/summary.txt')).toBeInTheDocument();
});
it('filesChanged 空または undefined なら見出しごと出ない', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// filesChanged がない「結果なし委譲」のカードを開く
await screen.findByText('結果なし委譲');
const card = screen.getByText('結果なし委譲').closest('button') as HTMLButtonElement;
await userEvent.click(card);
// 「Changed files」テキストは見つからない
expect(screen.queryByText(/Changed files/i)).not.toBeInTheDocument();
});
it('カードを開いた直後はイベント行が描画されず、トグルを押すと表示される', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const events = Array.from({ length: 3 }, (_, i) => ({
eventId: `event${i}`,
ts: new Date(2026, 0, 1, 0, 0, i).toISOString(),
seq: i,
kind: 'tool_call',
payload: { tool: 'Read' },
}));
vi.mocked(api.fetchDelegateRunTimeline).mockResolvedValueOnce(events);
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親委譲のカードを開く
await screen.findByText('親委譲');
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
await userEvent.click(parentButton);
// イベントトグルを確認(デフォルトは閉じた状態)
const eventsToggle = await screen.findByText(/Detailed events/i);
expect(eventsToggle).toHaveAttribute('aria-expanded', 'false');
// トグルを押す
await userEvent.click(eventsToggle);
// トグルが開いた状態になる
expect(eventsToggle).toHaveAttribute('aria-expanded', 'true');
});
});
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { POLLING } from '../../../lib/constants.js';
import { fetchDelegateRuns, fetchDelegateRunTimeline, type TraceEventLite } from '../../../api';
import { buildDelegateRunTree, delegateStatusBadge, formatElapsed, type DelegateRunNode, type DelegateRun, type SubtaskDelegateGroup } from '../../../lib/delegateRuns';
import { buildDelegateRunTree, currentRunningTool, delegateStatusBadge, formatElapsed, formatTokens, summarizeDescendants, type DelegateRunNode, type DelegateRun, type SubtaskDelegateGroup } from '../../../lib/delegateRuns';
import { useNow } from '../../../hooks/useNow';
import { summarizeTraceEvent } from '../../../lib/traceEvent';
@@ -21,6 +21,7 @@ function EventLine({ event }: { event: TraceEventLite }) {
function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: DelegateRunNode; indent?: number; jobId?: string }) {
const { t } = useTranslation('detail');
const [open, setOpen] = useState(false);
const [eventsOpen, setEventsOpen] = useState(false);
const badge = delegateStatusBadge(node.status);
const running = node.status === 'running';
// 実行中カードのみ 1 秒刻みで経過を更新(ネットワーク不要のローカルタイマー)。
@@ -34,6 +35,24 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
refetchInterval: open && running ? POLLING.FAST : false,
});
const runningTool = running ? currentRunningTool(events ?? []) : null;
const descendantSummary = summarizeDescendants(node);
let childBadgeContent = null;
let childBadgeClass = '';
if (descendantSummary.total > 0) {
if (descendantSummary.failed > 0) {
childBadgeContent = t('delegateRuns.childCountFailed', { total: descendantSummary.total, failed: descendantSummary.failed });
childBadgeClass = 'bg-red-100 text-red-800';
} else if (descendantSummary.running > 0) {
childBadgeContent = t('delegateRuns.childCount', { total: descendantSummary.total });
childBadgeClass = 'bg-blue-100 text-blue-800';
} else {
childBadgeContent = t('delegateRuns.childCount', { total: descendantSummary.total });
childBadgeClass = 'bg-slate-100 text-slate-600';
}
}
return (
<div
className="border border-slate-200 rounded-md mb-1.5 overflow-hidden"
@@ -42,30 +61,95 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-slate-50 transition-colors"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className={`shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${badge.cls}`}>
{t(badge.labelKey)}
</span>
{childBadgeContent && (
<span className={`shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${childBadgeClass}`}>
{childBadgeContent}
</span>
)}
<span className="text-[13px] text-slate-800 font-medium truncate flex-1">
{node.description || '(no description)'}
{node.description || t('delegateRuns.noDescription')}
</span>
<span className="shrink-0 text-xs text-slate-400">
depth {node.depth} · {node.toolCalls} tools · {formatElapsed(node.startTs, node.endTs, now)}
{t('delegateRuns.toolCount', { count: node.toolCalls })} · {formatElapsed(node.startTs, node.endTs, now)}{node.totalTokens && node.totalTokens > 0 ? ` · ${formatTokens(node.totalTokens)}` : ''}
</span>
<span className="shrink-0 text-slate-400 text-xs ml-1">{open ? '▲' : '▼'}</span>
<span className="shrink-0 text-slate-400 text-xs ml-1" aria-hidden="true">{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="border-t border-slate-100 px-3 pb-2">
{events && events.length > 0 ? (
<div className="mt-1">
{events.map((e) => <EventLine key={e.eventId} event={e} />)}
{runningTool && (
<div className="mt-2 px-2 py-1 text-[12px] text-blue-700 bg-blue-50 rounded">
{t('subtasks.delegateRunningTool', { tool: runningTool })}
</div>
) : (
<div className="mt-1 text-[11px] text-slate-400">
{events ? t('delegateRuns.eventsEmpty') : t('common:loading')}
)}
{(node.status === 'success' || node.status === 'needs_user_input' || node.status === 'aborted') && node.resultPreview && (
<div className={`mt-2 p-2 rounded text-[12px] ${
node.status === 'aborted'
? 'bg-red-50 text-red-700'
: 'bg-slate-50 text-slate-700'
}`}>
<div className="font-semibold mb-1">
{t(node.status === 'aborted' ? 'delegateRuns.abortReason' : 'delegateRuns.result')}
</div>
<div className="whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
{node.resultPreview}
</div>
</div>
)}
{node.toolSummary && node.toolSummary.length > 0 && (
<div className="mt-2">
<div className="text-[10px] text-slate-500 mb-1">{t('delegateRuns.toolSummary')}</div>
<div className="flex flex-wrap gap-1">
{node.toolSummary.map((item) => (
<div key={item.tool} className="bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 text-[11px] font-mono">
{item.tool} ×{item.count}
</div>
))}
</div>
</div>
)}
{node.filesChanged && node.filesChanged.length > 0 && (
<div className="mt-2">
<div className="text-[10px] text-slate-500 mb-1">{t('delegateRuns.filesChanged')}</div>
<div>
{node.filesChanged.map((path, idx) => (
<div key={idx} className="font-mono text-[11px] text-slate-600 truncate" title={path}>
{path}
</div>
))}
</div>
</div>
)}
<button
className="mt-2 text-[11px] text-slate-500 hover:text-slate-700 transition-colors"
onClick={() => setEventsOpen((v) => !v)}
aria-expanded={eventsOpen}
>
<span aria-hidden="true">{eventsOpen ? '▾' : '▸'}</span> {t('delegateRuns.eventsToggle', { count: node.eventCount })}
</button>
{eventsOpen && (
<>
{events && events.length > 0 ? (
<div className="mt-1">
{events.length > 20 && (
<div className="text-[11px] text-slate-400 mb-1">
{t('delegateRuns.moreEvents', { count: events.length - 20 })}
</div>
)}
{events.slice(-20).map((e) => <EventLine key={e.eventId} event={e} />)}
</div>
) : (
<div className="mt-1 text-[11px] text-slate-400">
{events ? t('delegateRuns.eventsEmpty') : t('common:loading')}
</div>
)}
</>
)}
{node.children.map((c) => (
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} jobId={jobId} />
))}
+30 -1
View File
@@ -56,11 +56,40 @@ vi.mock('../../api', async (importOriginal) => {
};
});
import { FilePreview } from './FilePreview';
import { FilePreview, MarkdownPreview } from './FilePreview';
import { fetchOfficePreview } from '../../api';
const noop = () => {};
describe('MarkdownPreview raw-HTML handling', () => {
it('file preview (default) renders embedded raw HTML as live elements', () => {
// Opening a .md file that intentionally embeds HTML should keep rendering
// it — this is the existing behavior we must NOT regress.
const { container } = render(
<MarkdownPreview content={'text\n\n<div class="hi">boxed</div>'} />,
);
expect(container.querySelector('div.hi')?.textContent).toBe('boxed');
});
it('escapeRawHtml renders raw HTML as escaped source, not a live element', () => {
// The chat result bubble passes escapeRawHtml so an HTML final answer does
// not inject its own tags and break the bubble layout.
const { container } = render(
<MarkdownPreview escapeRawHtml content={'text\n\n<div class="hi">boxed</div>'} />,
);
expect(container.querySelector('div.hi')).toBeNull();
expect(container.textContent).toContain('<div class="hi">boxed</div>');
});
it('escapeRawHtml keeps normal Markdown (headings, fenced code) working', () => {
const { container } = render(
<MarkdownPreview escapeRawHtml content={'# Hi\n\n```js\nconst a = 1;\n```'} />,
);
expect(container.querySelector('h1')?.textContent).toContain('Hi');
expect(container.querySelector('pre code')).not.toBeNull();
});
});
describe('FilePreview body branches', () => {
it('markdown: renders HTML and rewrites a relative image href to the raw endpoint', () => {
const base = '/api/local/tasks/42/files/raw?path=';
+24 -5
View File
@@ -276,8 +276,8 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string }): Renderer {
const { imageBaseUrl, slugger } = opts;
function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string; escapeRawHtml?: boolean }): Renderer {
const { imageBaseUrl, slugger, escapeRawHtml } = opts;
const renderer = new Renderer();
renderer.link = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
const titleAttr = title ? ` title="${title}"` : '';
@@ -351,6 +351,19 @@ function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string)
return `<img src="${resolvedHref}" alt="${text}"${titleAttr} style="max-width:100%" />`;
};
}
// Chat contexts (the agent result bubble) opt into escaping raw HTML: an LLM
// final answer that IS an HTML document must not inject its own tags and
// break the bubble layout. Block-level raw HTML is reused through
// `renderer.code` so it lands as a syntax-highlighted, copyable code block;
// inline raw HTML becomes escaped inline text. File preview (.md) leaves this
// off so intentionally-embedded HTML keeps rendering.
if (escapeRawHtml) {
renderer.html = function ({ text, block }: { text: string; block: boolean }) {
if (!block) return escapeHtml(text);
const code = text.replace(/\n$/, '');
return renderer.code({ type: 'code', raw: text, lang: 'html', text: code });
};
}
return renderer;
}
@@ -405,9 +418,15 @@ interface MarkdownPreviewProps {
taskId?: number;
/** true で目次サイドバー + リーダースタイル (MDXG) を有効化。チャット吹き出し等では false 推奨。 */
showOutline?: boolean;
/**
* true で本文中の生 HTML タグを描画せずエスケープ表示する。チャットの結果吹き出し
* 用: HTML 文書がそのまま最終出力に含まれても、タグが実要素として差し込まれて
* レイアウトが壊れるのを防ぐ。.md ファイルプレビューでは false(現状の HTML 描画を維持)。
*/
escapeRawHtml?: boolean;
}
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false }: MarkdownPreviewProps): JSX.Element {
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false, escapeRawHtml = false }: MarkdownPreviewProps): JSX.Element {
const { t } = useTranslation('files');
const truncated = content.slice(0, 100000);
const containerRef = useRef<HTMLDivElement>(null);
@@ -428,7 +447,7 @@ export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = f
const segments = useMemo(() => {
EMBED_SPLIT_RE.lastIndex = 0;
const slugger = showOutline ? buildSlugger() : undefined;
const renderer = buildMdRenderer({ imageBaseUrl, slugger });
const renderer = buildMdRenderer({ imageBaseUrl, slugger, escapeRawHtml });
const parser = new Marked({ gfm: true, renderer });
const hasEmbed = taskId != null && EMBED_SPLIT_RE.test(truncated);
@@ -442,7 +461,7 @@ export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = f
const html = DOMPurify.sanitize(parser.parse(seg.value, { async: false }) as string, DOMPURIFY_CONFIG);
return { type: 'markdown' as const, html };
});
}, [truncated, imageBaseUrl, taskId, showOutline]);
}, [truncated, imageBaseUrl, taskId, showOutline, escapeRawHtml]);
// コピーボタン + アンカーリンクのイベント delegation
useEffect(() => {
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
vi.mock('../../App', () => ({
useAuthState: () => ({ mode: 'authenticated', user: { id: 'u1', role: 'admin' } }),
}));
vi.mock('../../api', () => ({
fetchSkills: vi.fn(async () => [
{ name: 'sys-a', description: 'a system skill', triggers: [], source: 'system', hasDir: true },
]),
fetchSkillDetail: vi.fn(async () => ({
name: 'sys-a',
description: 'a system skill',
triggers: [],
source: 'system',
hasDir: true,
content: 'body',
raw: '---\nname: sys-a\n---\n\nbody',
files: [],
findings: [],
maxSeverity: 'none',
})),
createSkill: vi.fn(),
updateSkill: vi.fn(),
deleteSkill: vi.fn(),
installSkillFromUrl: vi.fn(),
}));
import { SkillsForm } from './SkillsForm';
import { deleteSkill } from '../../api';
function renderForm() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<SkillsForm />
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('confirm', vi.fn(() => true));
});
describe('SkillsForm delete errors', () => {
it('shows a delete failure next to the action buttons instead of only at the top', async () => {
vi.mocked(deleteSkill).mockRejectedValueOnce(new Error('Skill not found'));
renderForm();
await userEvent.click(await screen.findByText('sys-a'));
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
expect(deleteSkill).toHaveBeenCalledWith('sys-a', 'system', undefined);
const err = await screen.findByTestId('skill-action-error');
expect(err).toHaveTextContent('Skill not found');
// The skill stays selected so the user sees the failure in place.
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
});
it('clears the inline error when selecting another skill', async () => {
vi.mocked(deleteSkill).mockRejectedValueOnce(new Error('Skill not found'));
renderForm();
await userEvent.click(await screen.findByText('sys-a'));
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
await screen.findByTestId('skill-action-error');
// Re-select from the list (the detail heading also renders the name, so
// pick the list entry via its description line).
await userEvent.click(screen.getByText('a system skill', { selector: 'div' }));
await waitFor(() => {
expect(screen.queryByTestId('skill-action-error')).not.toBeInTheDocument();
});
});
it('does not keep showing a stale delete error after a later successful edit', async () => {
const { updateSkill } = await import('../../api');
vi.mocked(deleteSkill).mockRejectedValueOnce(new Error('Skill not found'));
vi.mocked(updateSkill).mockResolvedValueOnce({ ok: true });
renderForm();
await userEvent.click(await screen.findByText('sys-a'));
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
await screen.findByTestId('skill-action-error');
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
await userEvent.click(await screen.findByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(screen.queryByTestId('skill-action-error')).not.toBeInTheDocument();
});
});
it('deletes and clears the selection on success', async () => {
vi.mocked(deleteSkill).mockResolvedValueOnce(undefined);
renderForm();
await userEvent.click(await screen.findByText('sys-a'));
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
});
expect(screen.queryByTestId('skill-action-error')).not.toBeInTheDocument();
});
});
+25 -2
View File
@@ -99,6 +99,10 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
onError: (e: Error) => setError(e.message),
});
// Update/delete failures render inline next to the action buttons (via the
// mutations' own error state) — the top banner is easy to miss when the
// detail pane is scrolled down. The top `error` banner stays for
// create/install, whose forms sit near it.
const updateMut = useMutation({
mutationFn: ({ name, content, scope }: { name: string; content: string; scope: string }) =>
updateSkill(name, content, scope, spaceId),
@@ -108,7 +112,6 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
setEditMode(false);
setError(null);
},
onError: (e: Error) => setError(e.message),
});
const deleteMut = useMutation({
@@ -119,9 +122,10 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
setEditMode(false);
setError(null);
},
onError: (e: Error) => setError(e.message),
});
const actionError = (deleteMut.error as Error | null)?.message ?? (updateMut.error as Error | null)?.message ?? null;
const installMut = useMutation({
mutationFn: () => installSkillFromUrl(installUrl.trim(), 'user', undefined, spaceId),
onSuccess: () => {
@@ -141,6 +145,8 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
setNewMode(false);
setEditMode(false);
setError(null);
updateMut.reset();
deleteMut.reset();
};
const handleNew = () => {
@@ -151,6 +157,8 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
setNewContent('');
setNewScope('user');
setError(null);
updateMut.reset();
deleteMut.reset();
};
const handleStartEdit = () => {
@@ -160,17 +168,22 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
setEditContent(detailQuery.data.raw);
setEditMode(true);
setError(null);
updateMut.reset();
deleteMut.reset();
}
};
const handleSaveEdit = () => {
if (!selected || !detailQuery.data) return;
// A new action supersedes any stale error from the other mutation.
deleteMut.reset();
updateMut.mutate({ name: selected, content: editContent, scope: detailQuery.data.source });
};
const handleDelete = () => {
if (!selected || !detailQuery.data) return;
if (!confirm(`Delete skill "${selected}"?`)) return;
updateMut.reset();
deleteMut.mutate({ name: selected, scope: detailQuery.data.source });
};
@@ -366,6 +379,11 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
rows={18}
className="block w-full px-2 py-1.5 text-xs font-mono border border-hairline rounded bg-canvas text-slate-700 resize-y"
/>
{actionError && (
<div data-testid="skill-action-error" className="px-3 py-2 rounded bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 text-xs text-red-700 dark:text-red-300">
{actionError}
</div>
)}
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
@@ -399,6 +417,11 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
)}
{/* Action buttons */}
{actionError && !editMode && (
<div data-testid="skill-action-error" className="px-3 py-2 rounded bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 text-xs text-red-700 dark:text-red-300">
{actionError}
</div>
)}
{canEdit(detailQuery.data) && !editMode && (
<div className="flex gap-2 mt-1">
<button
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SpaceChatTabBar } from './SpaceChatTabBar';
const TABS = [
{ id: 'chat', labelKey: 'tabs.chat' },
{ id: 'overview', labelKey: 'tabs.overview' },
{ id: 'files', labelKey: 'tabs.files' },
];
function renderBar(overrides: Partial<Parameters<typeof SpaceChatTabBar>[0]> = {}) {
const onSelect = vi.fn();
const utils = render(
<SpaceChatTabBar
tabs={TABS}
activeTab="chat"
onSelect={onSelect}
ariaLabel="チャットタブ"
renderLabel={(tb) => tb.labelKey}
appearClass={() => ''}
actions={<button data-testid="space-chat-delete">del</button>}
{...overrides}
/>
);
return { onSelect, ...utils };
}
describe('SpaceChatTabBar', () => {
it('タブと actions を描画し、actions はスクロールする tablist の外にある', () => {
renderBar();
const tablist = screen.getByRole('tablist');
const del = screen.getByTestId('space-chat-delete');
// actions がタブのスクロールコンテナ(tablist)の子孫だと、タブが溢れたとき
// 一緒にスクロールして画面外に流れる。兄弟であることを構造で保証する。
expect(tablist.contains(del)).toBe(false);
expect(screen.getByTestId('space-chat-actions').contains(del)).toBe(true);
expect(tablist.className).toContain('overflow-x-auto');
});
it('ArrowRight で次のタブを選択しフォーカスを移す(末尾で wrap)', async () => {
const user = userEvent.setup();
const { onSelect } = renderBar();
screen.getByRole('tab', { name: 'tabs.chat' }).focus();
await user.keyboard('{ArrowRight}');
expect(onSelect).toHaveBeenCalledWith('overview');
});
it('End で末尾タブ、Home で先頭タブを選択する', async () => {
const user = userEvent.setup();
const { onSelect } = renderBar();
screen.getByRole('tab', { name: 'tabs.chat' }).focus();
await user.keyboard('{End}');
expect(onSelect).toHaveBeenCalledWith('files');
await user.keyboard('{Home}');
expect(onSelect).toHaveBeenCalledWith('chat');
});
it('aria-selected が activeTab にだけ付く', () => {
renderBar({ activeTab: 'overview' });
expect(screen.getByRole('tab', { name: 'tabs.overview' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'tabs.chat' })).toHaveAttribute('aria-selected', 'false');
});
it('actions なしのときアクションゾーンを描画しない', () => {
renderBar({ actions: undefined });
expect(screen.queryByTestId('space-chat-actions')).toBeNull();
});
});
@@ -0,0 +1,96 @@
/**
* SpaceChatTabBar — 会話画面の単一タブバー(2ゾーン構造)。
*
* 左: タブ群(overflow-x-auto のスクロールゾーン)
* 右: actionsshrink-0 の固定ゾーン。継続 / 共有 / 削除ボタンが入る)
*
* タブが溢れて横スクロールになっても、actions は右端に固定されたまま
* スクロールで流れない。これが2ゾーンに分ける理由(旧: アクション専用行
* space-chat-actions を廃止して縦約40pxを回収した)。
*/
import { useRef } from 'react';
export interface SpaceChatTabDef {
id: string;
labelKey: string;
}
interface SpaceChatTabBarProps {
tabs: SpaceChatTabDef[];
activeTab: string;
onSelect: (id: string) => void;
ariaLabel: string;
renderLabel: (tab: SpaceChatTabDef) => string;
/** browser/ssh タブの出現アニメ用クラス(detailTabs.tabAppearClass を渡す) */
appearClass: (id: string) => string;
/** 右端固定ゾーンに置くアクション群。無ければゾーンごと描画しない。 */
actions?: React.ReactNode;
}
export function SpaceChatTabBar({
tabs,
activeTab,
onSelect,
ariaLabel,
renderLabel,
appearClass,
actions,
}: SpaceChatTabBarProps) {
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
// 矢印キーでタブ間移動(端で wrap)+ Home/End。選択とフォーカスを同時に動かす
// role=tablist の標準操作)。SpaceDetail から移設。
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault();
onSelect(tabs[next].id);
tabRefs.current[next]?.focus();
};
return (
<div className="flex items-center border-b border-hairline pl-3 pr-2">
<div
role="tablist"
aria-label={ariaLabel}
data-testid="space-chat-tabs"
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
>
{tabs.map((tb, i) => {
const active = tb.id === activeTab;
return (
<button
key={tb.id}
ref={(el) => { tabRefs.current[i] = el; }}
type="button"
role="tab"
id={`space-chat-tab-${tb.id}`}
data-testid={`space-chat-tab-${tb.id}`}
aria-selected={active}
aria-controls="space-chat-tabpanel"
tabIndex={active ? 0 : -1}
onClick={() => onSelect(tb.id)}
onKeyDown={(e) => handleKeyDown(e, i)}
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${appearClass(tb.id)} ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{renderLabel(tb)}
</button>
);
})}
</div>
{actions && (
<div data-testid="space-chat-actions" className="ml-2 flex shrink-0 items-center gap-1.5">
{actions}
</div>
)}
</div>
);
}
+75 -137
View File
@@ -47,6 +47,7 @@ import { AppRunner } from './AppRunner';
import { createSpaceGateway } from './app-file-gateway';
import { detectAppEntry } from './app-bridge';
import { ChatDetailSplit } from './ChatDetailSplit';
import { SpaceChatTabBar } from './SpaceChatTabBar';
import { OutputPreviewProvider } from '../../lib/output-preview-context';
import { stripOutputPrefix } from '../../lib/output-path-detect';
import {
@@ -134,7 +135,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
const dot = space.brandColor ?? 'var(--brand-primary)';
// 狭幅でチャットを開いているときは、スペースタイトルと「チャット|ファイル」タブ行を
// 狭幅でチャットを開いているときは、ドット/タイトル/タブ群を1本化した統合ヘッダー行を
// 隠してヘッダーの段数を減らす(会話画面が複数バーに埋もれないように)。広幅(md+)では
// 常時表示、チャット未選択(一覧表示)の狭幅でも表示する。
const chatOpen = tab === 'chat' && spaceTaskId != null;
@@ -142,33 +143,32 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
return (
<div ref={containerRef} data-testid="space-detail" className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
{/* Header + Tabs 統合行: 左からドット/タイトル/タブ群(スクロール)/右端固定
(アバター・削除)。2行(約86px)→1行(約40px)。タイトルは max-w-[28ch] で
truncate し、タブ群が flex-1 を持つ。 */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface pl-4 pr-2`}>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
<SpaceHeaderTitle
space={space}
canManage={canManage}
/>
{space.kind === 'case' && (
<SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
)}
{space.kind === 'case' && canManage && (
<SpaceDeleteButton
spaceId={spaceId}
title={space.title}
onDeleted={() => onSelectSpace?.(undefined)}
/>
)}
</div>
{/* Tabs */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-1 border-b border-hairline px-3`}>
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
<SpaceHeaderTitle space={space} canManage={canManage} />
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
</div>
<div className="flex shrink-0 items-center gap-2">
{space.kind === 'case' && (
<SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
)}
{space.kind === 'case' && canManage && (
<SpaceDeleteButton
spaceId={spaceId}
title={space.title}
onDeleted={() => onSelectSpace?.(undefined)}
/>
)}
</div>
</div>
{/* Body */}
@@ -210,8 +210,9 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
}
/**
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
* ヘッダーのワークスペース名。説明文はタイトルの title 属性(ツールチップ)に退避し、
* 行の幅は max-w-[28ch] で制約してタブ群に flex-1 を譲る。管理権限があれば鉛筆ボタンで
* 編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
*/
function SpaceHeaderTitle({
space,
@@ -224,17 +225,13 @@ function SpaceHeaderTitle({
const [editing, setEditing] = useState(false);
return (
<div className="flex min-w-0 flex-1 items-center gap-2">
<h1 className="shrink truncate text-[15px] font-bold text-slate-800">{space.title}</h1>
{space.description && (
<span
data-testid="space-description"
title={space.description}
className="hidden min-w-0 shrink truncate text-xs text-slate-400 sm:inline"
>
{space.description}
</span>
)}
<div className="flex min-w-0 max-w-[28ch] shrink items-center gap-1.5">
<h1
title={space.description || undefined}
className="min-w-0 truncate text-[15px] font-bold text-slate-800"
>
{space.title}
</h1>
{canManage && (
<button
type="button"
@@ -394,7 +391,7 @@ function TabButton({
type="button"
data-testid={testid}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
@@ -667,7 +664,6 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
const detailTabs = useVisibleDetailTabs(taskId);
const [activeTab, setActiveTab] = useState<DetailTabId | 'chat'>('chat');
const tabs = [{ id: 'chat' as const, labelKey: 'tabs.chat' }, ...detailTabs];
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const fileBrowser = useFileBrowser(taskId);
// モバイル(<md)だけスワイプ UI を「単一 DOM 枝」としてマウントする。両枝を CSS で
// 隠して二重に描画すると ChatPane の textarea 等が複製され strict locator が壊れる
@@ -682,20 +678,6 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
if (!tabs.some(tb => tb.id === activeTab)) setActiveTab('chat');
}, [tabs, activeTab]);
// 矢印キーでタブ間移動(端で wrap)+ Home/End で先頭・末尾へ。選択とフォーカスを
// 同時に動かす(role=tablist の標準操作)。
const handleTabKeyDown = (e: React.KeyboardEvent, index: number) => {
let next: number;
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
else return;
e.preventDefault();
setActiveTab(tabs[next].id);
tabRefs.current[next]?.focus();
};
// タスクのファイルにアップロード/削除できるか。スペースメンバーでもタスク所有者で
// なければサーバ側 checkTaskOwnership が 403 を返すため、UI も owner/admin/no-auth に限る。
const auth = useAuthState();
@@ -801,87 +783,43 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
{ts('conversation.backToList')}
</button>
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
同じ実体(ShareButton・ContinueButton・ContinueWithPieceDialog・
updateLocalTask)を再利用し、機能パリティを保つ。タブバーとは別行に置き、
狭幅でも横並びのまま収まるアイコン主体の密度にする。 */}
{chatReady && task && (
<div
data-testid="space-chat-actions"
className="flex items-center gap-1.5 border-b border-hairline px-3 py-1.5"
>
{/* 公開範囲は選択不可: スペース内のチャットは常にそのスペースの
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
<span
data-testid="space-chat-visibility-note"
title={ts('conversation.visibilityTitle')}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
>
{ts('conversation.visibilityNote')}
</span>
<div className="ml-auto flex items-center gap-1">
<ContinueButton
testid="space-chat-continue"
latestJobStatus={task.latestJob?.status ?? null}
onClick={() => setContinueOpen(true)}
/>
<ShareButton
testid="space-chat-share"
taskId={taskId}
shareToken={task.shareToken ?? null}
onShareChange={refetchTask}
/>
<button
type="button"
data-testid="space-chat-delete"
onClick={() => void confirmAndDelete()}
title={ts('common:delete')}
aria-label={ts('common:delete')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
</div>
</div>
)}
{/* 単一インプレース・タブバー。会話+詳細タブ(ファイルを除く)を1本に集約し、
戻る導線を「会話」タブのみに統一する(オーバーレイ・✕ を廃止)。 */}
<div
role="tablist"
aria-label={t('chatTabsLabel')}
data-testid="space-chat-tabs"
className="flex items-center gap-1 overflow-x-auto border-b border-hairline px-3"
>
{tabs.map((tb, i) => {
const active = tb.id === activeTab;
return (
<button
key={tb.id}
ref={(el) => { tabRefs.current[i] = el; }}
type="button"
role="tab"
id={`space-chat-tab-${tb.id}`}
data-testid={`space-chat-tab-${tb.id}`}
aria-selected={active}
aria-controls="space-chat-tabpanel"
tabIndex={active ? 0 : -1}
onClick={() => setActiveTab(tb.id)}
onKeyDown={(e) => handleTabKeyDown(e, i)}
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${tabAppearClass(tb.id)} ${
active
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{t(tb.labelKey)}
</button>
);
})}
</div>
<SpaceChatTabBar
tabs={tabs}
activeTab={activeTab}
onSelect={(id) => setActiveTab(id as DetailTabId | 'chat')}
ariaLabel={t('chatTabsLabel')}
renderLabel={(tb) => t(tb.labelKey)}
appearClass={(id) => tabAppearClass(id as DetailTabId | 'chat')}
actions={
chatReady && task ? (
<>
<ContinueButton
testid="space-chat-continue"
latestJobStatus={task.latestJob?.status ?? null}
onClick={() => setContinueOpen(true)}
/>
<ShareButton
testid="space-chat-share"
taskId={taskId}
shareToken={task.shareToken ?? null}
onShareChange={refetchTask}
/>
<button
type="button"
data-testid="space-chat-delete"
onClick={() => void confirmAndDelete()}
title={ts('common:delete')}
aria-label={ts('common:delete')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
</>
) : undefined
}
/>
<div
id="space-chat-tabpanel"
+79
View File
@@ -12,6 +12,81 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 2026-07-08 — 名前が食い違ったスキルを削除・編集できない問題を修正
スキルの表示名(SKILL.md の frontmatter `name:`)と保存フォルダ名が食い違っていると、一覧には表示されるのに削除・編集だけが「Skill not found」で失敗し続ける問題を修正しました。表示名から実体を探すようになったため、既存の食い違いスキルもそのまま削除・編集できます。あわせて、InstallSkill やスキル作成時に frontmatter の `name:` と指定名の一致を必須にして、新たな食い違いが生まれないようにしました。削除・保存に失敗したときのエラーはボタンのすぐ近くに表示されます。
## 2026-07-08 — タスクが開始直後に「200回超過」で突然中断される問題を修正
会話がコンテキスト上限のごく近くまで膨らんだとき、まれにエージェントが「同じリクエストの送信 → 送信前ブロック」を1回あたり十数ミリ秒で延々と繰り返し、実際には何も作業しないまま数分で「max iterations (200) 超過」として中断されることがありました。空回りはツールを使わないため会話タブには何も表示されず、タスクが始まった直後に突然打ち切られたように見えていました。原因はコンテキストサイズの見積もりが送信前チェックとガードで 128 トークンだけずれていたことで、見積もりを統一し、さらに「何も削れないのに再送する」経路自体を塞ぎました。今後この状況では空回りせず、コンテキスト超過として即座に次のステップへの強制遷移(または中断)になります。あわせて、これまで無制限だった Grep の結果出力にも Read や Bash と同じ自動切り詰めを入れ、巨大な検索結果が一撃で会話を埋める事故を防ぎます。
## 2026-07-08 — 会話画面を縦に広く、入力欄をカード型に刷新
会話画面の縦方向の使える範囲を広げました。ヘッダー部分の行数を減らすため、公開範囲の表示(ワークスペース内のチャットは常にメンバーに公開されるという前提)は撤去し、継続・共有・削除ボタンはタブ行の右端にまとめています。入力欄はカード型のコンポーザーに刷新し、書いた内容に応じて高さが自動で伸びます(1〜8 行程度)。コンテキストの残量ゲージはツールバー右下に常時表示し、使用率が 70% を超えるとパーセント表示も出るようになりました。
## 2026-07-08 — 会話検索が複数キーワードの AND 検索に対応
エージェントがタスク内の過去のやり取りを振り返る会話検索(`SearchTaskConversation`)で、スペース区切りの複数キーワードが AND 検索として働くようになりました。これまでは入力全体を 1 つの文字列として照合していたため、「予算 制約」のように単語を並べるとほぼ必ず 0 件になっていました。今後はすべての語を含む発言がヒットし、抜粋も最初にヒットした語の周辺を表示します。全角スペース区切りにも対応しています。ワークスペース横断検索(`SearchWorkspaceTasks`)も、複数キーワード時に抜粋が本文の先頭に落ちてしまう同種の問題を修正しました(ヒット判定はもともと AND です)。
## 2026-07-07 — 委譲カードの展開部を「何をしたか」中心に再構成
概要タブの「委譲実行」セクションで、カードを開いたときの中身を整理しました。使用したツール(名前と回数のチップ)と変更したファイルの一覧が最終結果の下に並び、その委譲が何をしたのかがひと目で分かります。これまで主役だったイベントの生ログは「詳細イベント」という折りたたみに移し、ふだんは閉じています。最終結果プレビューの上限も 500 字から 2000 字に拡大しました。
## 2026-07-07 — 会話タブのツール実行行に実行時刻を表示
会話タブでツール実行の履歴を見るときに、各ツール行の右側に実行時刻(時間:分:秒)が表示されるようになりました。これまでは実行に要した時間だけ表示されていましたが、「どのツールをいつ実行したか」を追える必要があったため、時刻を追加しました。ホバーすると日付を含む完全な日時が表示されます。
## 2026-07-07 — X の長文記事(X Articles)の本文を XPostDetail で取得可能に
X(旧 Twitter)の長文記事(`x.com/ユーザー名/article/…` 形式で共有されるプレミアム機能の記事)を、エージェントが読めるようになりました。これまで記事ポストを XPostDetail で取得すると本文の代わりに短縮リンクが 1 本返るだけで、リンク先もログイン壁で読めず行き止まりでした。今回から、記事ポストの取得結果に `article` として記事タイトル・プレビュー文・本文(プレーンテキスト)・公開日時・カバー画像 URL が含まれます。本文は既定で 12,000 文字まで(超過分は切り詰めて明示)、`full_text: true` で全文を取得できます。記事 URL`…/article/…`)をそのまま渡しても動きます。記事の中に埋め込まれた画像・動画(先頭 20 件まで)も通常の投稿メディアと同じ扱いで取得され、自動的にワークスペースへダウンロードされるので、エージェントがそのまま画像の中身を読めます。
## 2026-07-07 — スキルのフォルダ登録(InstallSkill)のパス指定が寛容に
エージェントがスキルをフォルダごと登録する際、workspace のフルパス風の指定(例: `data/space/{id}/files/output/xxx`)を渡すと「存在しない」と拒否されていました。パス中に workspace の実配置が含まれていれば自動で workspace 相対に読み替えて解決するようにし、解決できない場合のエラーメッセージにも workspace 相対パスで指定し直す案内を追加しました。テンプレート一式を含むスキルフォルダの登録が一発で通りやすくなります。
## 2026-07-07 — 更新されたスキルを ReadSkill が自動で読み直すように
スクリプト付きスキル(ディレクトリ型)を `ReadSkill` すると workspace の `skills/{名前}/` にファイル一式がコピーされますが、これまでは一度コピーされると、スキルが別のタスクで更新されても古いコピーを使い続けていました(永続ワークスペースで顕在化)。今回から、コピー時にスキル元の署名を記録し、元が変わっていれば次の `ReadSkill` でコピーを丸ごと最新版に置き換えます。元が変わっていなければ、workspace 内のコピー(ローカルの手直し含む)はそのまま維持されます。
## 2026-07-07 — 委譲実行の細部改善(イベント上限・自動ソート・見出し)
概要タブの「委譲実行」セクション(委譲実行)のカード表示を整理しました。①カードを開いたときに表示されるイベント一覧は、デフォルトでは**最新 20 件のみ**を表示し、それ以上ある場合は一覧の上に「他 {{件数}} 件(トレースタブで全件表示)」と案内を出すようにしました。長時間の実行でも無駄にスクロールさせません。②delegate run の並び順が API 返却順に依存していたのを、開始時刻(startTs)で昇順にソートするようにしました(同時刻の場合は ID で安定化)。roots と children 両方がソート対象になります。③セクション見出しのジャーゴンを脱却し、ja は「委譲実行」、en は「Delegated runs」に統一しました(内部用語「delegate(直列)」から変更)。④カード開閉ボタンにアクセシビリティ属性(`aria-expanded`)を追加し、スクリーンリーダー対応を強化しました。
## 2026-07-07 — delegate カードに LLM トークン数(プロンプト+返り値)を表示
概要タブの「delegate(直列)」セクションのカード右側に、そのエージェント実行が消費した LLM トークン数が追加表示されるようになりました。形式は「1.2k tok」のように 1 文字で表現され、ツール数・経過時間に並べて表示されます。トークン数がゼロの場合(古い記録やツール専用の実行)は表示されません。
## 2026-07-07 — 親の delegate カードに子 run の件数と失敗状況をロールアップ表示
概要タブの「delegate(直列)」セクションで、ネストした delegate run の親カード(子が複数あるカード)を開かないでも、子どもの全体の状況が一目で分かるようになりました。親カードのステータスバッジの横に「子 {{件数}} 件」という小さいバッジが出て、その色で状態を表示します。子に失敗(中断)したものがあれば「子 {{件数}} 件・失敗 {{失敗数}}」と赤く表示され、親は完了していても問題が埋もれることがありません。実行中の子がある場合は青く「子 {{件数}} 件」と表示し、全部完了している場合はグレーで表示します。
## 2026-07-07 — delegate カードの表示を整理(depth を廃止・ツール数表示の多言語化)
概要タブの「delegate(直列)」セクションのカード右側に出ていた「depth」という内部値の表示をやめました(インデント(左詰め)で入れ子の深さは既に伝わるため)。あわせて、英語表示の「tools」や「(no description)」など、UI 文言を多言語対応に切り替え、日本語では「ツール {{ 数 }} 回」「(説明なし)」と表示するようになりました。
## 2026-07-07 — 実行中の delegate カードに現在実行中のツール名を表示
概要タブの「delegate(直列)」セクションで実行中のカードを開いたとき、イベント一覧の上に「{{ ツール名 }} 実行中」という表示が出るようになりました。エージェントが何を実行しているのかがカードを開いたら一目瞭然です。この情報は既に会話タブのライブコンソールに出ていましたが、概要タブの方でも同等の情報が得られるようになりました。
## 2026-07-07 — delegate カードに最終結果プレビューと中断理由を表示
概要タブの「delegate(直列)」セクションのカードを開いたとき、中身のイベント一覧だけでなく、その委譲が何を返したか(成功時の最終結果)や、なぜ止まったか(中断時の理由)が先頭に表示されるようになりました。500 文字までのプレビューで、結果が長い場合でもカード内をスクロールして見られます。
## 2026-07-06 — エージェントが Python パッケージを申請 → 承認でその場で使えるように
作業中にエージェントが「このライブラリが要る」と気づいたとき、チャットに承認カードが出るようになりました。パッケージ名と理由を確認して「許可」を押すと、そのライブラリがこのワークスペースに追加され、止まっていた処理が自動で再開します。追加されるのは設定 → Python から足すものと同じ扱い(wheel のみ・このワークスペース限定・安全なサンドボックスでインストール)で、承認できるのはタスクを編集できる人だけです。人がいない実行(サブタスク・定期実行)では申請は記録だけされ、処理はパッケージ無しで続きます。
## 2026-07-06 — サブタスクが実行中に自分の会話履歴を振り返れるように
サブタスクが実行中に自分の会話履歴を振り返れるようになりました(`SearchTaskConversation` / `ReadTaskConversation`)。長時間動くサブタスクが序盤の判断を思い出せます。親・他タスクの会話には引き続きアクセスしません。
## 2026-07-06 — 会話のステップ表示に実行時刻を追加
会話タブでは、エージェントの各ステップ(movement)がまとまりとして表示され、ヘッダーに実行時間(例 `1m 30s`)が出ます。これに加えて、そのステップが**いつ終わったか(日時)**も並べて表示するようにしました。あとから会話を見返したときに、どのくらい時間がかかったかだけでなく、実際に何時ごろの処理だったのかが分かります(→[実行中のタスクを見る](./03-running.md))。
## 2026-07-06 — 会話に HTML タグが含まれると表示が崩れる不具合を修正
エージェントに HTML を書かせたとき、その HTML が最終出力や途中のメッセージにそのまま含まれると、タグが実際の要素として画面に差し込まれ、吹き出しやレイアウトごと崩れることがありました。今後は会話タブに出てきた生の HTML タグを「実行」せず、そのままソースとして見せます。まとまった HTML はコードブロックとして表示され、中身を読んだりコピーしたりできます。ファイルタブで `.md` ファイルを開いたときの表示は従来どおりで、意図して埋め込んだ HTML はこれまで通り描画されます(→[結果を見る](./04-results.md))。
## 2026-07-06 — PDF を「途中のページから」読めない不具合を修正
エージェントが PDF を Read で読むとき、`offset` / `limit`(テキスト用の行指定)でページをずらそうとしても効かず、常に先頭ページから返っていました。PDF・Excel・Word ではこれらのパラメータが元々無視される仕様だったのに、その区別がエージェントに伝わっていなかったのが原因です。PDF のページ指定用パラメータ `page_range`(例 `"5-10"`)を Read の入力候補として明示し、`offset` / `limit` は「テキスト専用」と分かるようにしました。あわせて、PDF/Office にうっかり `offset` / `limit` を渡した場合は黙って無視せず、「PDF は `page_range` を使ってください」という案内を出力に添えて自己修正できるようにしています(→[ツール](./16-tools.md))。
@@ -24,6 +99,10 @@ Windows の WSL2 上の Docker で `docker compose up --build` を実行した
`docker compose up --build` でイメージを一からビルドしたとき、ブラウザ操作系の機能(Browser タブ・`BrowseWeb`・InteractiveBrowse など)で使う Chromium の導入に失敗し、ビルドが `playwright: not found` で止まる、あるいは起動してもブラウザ操作ができないことがありました。ビルド手順の内部でブラウザ導入コマンドの呼び出し方に依存関係の競合があったのが原因です。呼び出し方を競合しない方式に変え、クリーンビルドでも確実に Chromium が入るようにしました。自分でビルドして自己ホストしている場合が対象で、設定の変更は不要です。
## 2026-07-03 — A2A 外部エージェントに委任ごとのリソース制限を追加
A2A 連携を通じて接続する外部エージェントに、委任ごとのレート・同時実行数・ペイロードサイズ・ストリーム時間・スキル実行予算の制限が適用されるようになりました。サーバーへの過負荷を防ぎ、複数の委任が公平にリソースを利用できます。デフォルト値のまま動作しますが、管理者は `config.yaml``a2a.limits` セクションで各制限を調整できます(→[外部エージェント連携](./23-a2a.md))。
## 2026-07-03 — 縦長ページのスクリーンショットを1画面ぶんずつ自動分割
エージェントがブラウザ操作でページのスクリーンショットを撮るとき、縦に長いページだと画像が極端に縦長になり、細部が潰れて内容を読み取りづらくなっていました。今後は縦長ページを **1 画面ぶんごとに区切って複数枚**`report-001.png`, `report-002.png` …)に自動分割して保存します。1 画面に収まるページは従来どおり 1 枚のままです。無限スクロール対策として既定で最大 10 枚まで。分割せずフルページ 1 枚で撮りたい場合は指定で切り替えられます。ブラウザ操作ツール(`BrowseWeb`・手動ログイン後に引き継ぐ `BrowseWithSession`)のどちらでも同じ挙動になり、ツールによってスクリーンショットの撮られ方が変わることはありません。
+2
View File
@@ -92,6 +92,8 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
ワークスペースの中で始めたチャットは、**常にそのワークスペースのメンバー全員に公開**され、メンバー以外には見えません。チャットごとに公開範囲を選ぶ設定はありません(上の「公開範囲」は個人のタスクにだけ適用されます)。誰に見せるかはワークスペースのメンバー構成で決まる、と考えてください。
これとは別に、ログイン不要でリンクを知っている人だけに読み取り専用で公開する **共有リンク** という機能もあります。ワークスペースのメンバー限定公開とは独立した仕組みで、メンバー以外の相手に結果だけを見せたいときに使います。詳しくは [結果を受け取る](./04-results.md) を参照してください。
## ワークスペースの名前変更・削除
ワークスペースのヘッダー(タイトルの並び)に、名前変更と削除のボタンがあります。どちらもオーナーと管理者だけに表示されます。
+6 -2
View File
@@ -16,6 +16,8 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
- **ツール呼び出し**: ツールを使うと、行としてインラインに表示されます。各行をクリックすると **args(引数)と result(結果)が展開** され、成功は ✓、失敗は ✕ で色分けされます。所要時間やキャッシュヒットも表示されます
- **思考(thinking)ブロック**: エージェントの内部的な考えも、まとまりとして表示されます
ステップ(movement)が終わると、そのステップはひとつの見出しにまとまります。見出しにはステップ名・かかった実行時間(例 `1m 30s`)・終わった日時が並び、クリックすると中のツール呼び出しや思考を展開できます。
実行中はヘッダーに `running`(サブタスク待ちのときは `subtasks`)のバッジが点滅します。
## 進捗タブで振り返る
@@ -24,9 +26,11 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
チャットが「会話の見え方」だとすれば、進捗タブは「作業ログの見え方」です。
### コンテキスト残量ゲージ
### 入力欄とコンテキスト残量ゲージ
概要タブと入力欄の上に、いまの会話が使っているコンテキスト量を示す **残量ゲージ** が出ます。上限に近づくと色が変わり、限界が近いことが分かります。ゲージが詰まってくると、エージェントは古いやり取りを自動で要約して空きを作りながら作業を続けます(→[結果を受け取る](./04-results.md)・[トラブルシューティング](./08-troubleshooting.md)
入力欄(コンポーザー)はカード型で、書いた内容に合わせて高さが自動で伸びます(1〜8 行程度)。長めの指示を書いても窮屈にならず、送る前に見直しやすくなっています
ツールバー右下には、いまの会話が使っているコンテキスト量を示す **残量ゲージ** が常時表示されます(概要タブにも同じ情報のカード版があります)。使用率が 70% を超えるとバーの横にパーセント表示が出て、残量が少ないことがひと目で分かります。マウスを乗せると実際のトークン数を確認できます。上限に近づくと色も変わります。ゲージが詰まってくると、エージェントは古いやり取りを自動で要約して空きを作りながら作業を続けます(→[結果を受け取る](./04-results.md)・[トラブルシューティング](./08-troubleshooting.md))。
## 実行中に指示を追加する(割り込み / interjection
+2
View File
@@ -8,6 +8,8 @@ keywords: [ファイル, output, プレビュー, PDF, 印刷, ダウンロー
タスクが完了すると、最終回答に加えて、エージェントが作ったファイルを受け取れます。
最終回答はチャットに Markdown で整形して表示されます。見出し・箇条書き・コードブロックはそのまま整形され、`output/` 配下へのリンクはクリックでプレビューが開きます。回答の中に生の HTML タグが混じっていても、タグを画面に埋め込んで表示を崩すことはありません。まとまった HTML はコードブロックとしてソースのまま表示され、中身を読んだりコピーしたりできます(HTML そのものを見た目つきで確認したいときは、ファイルとして保存させて `.md``.html` のプレビューで開いてください)。
## ファイルタブ
詳細パネルの **ファイルタブ** で、ワークスペース内のファイルを閲覧できます。上部のセクション切り替えで 3 つの領域を行き来します。
+6 -2
View File
@@ -25,7 +25,7 @@ delegate は標準で有効なので、特別な設定は不要です。実行
## SpawnSubTask(並列の別ジョブ・要有効化)
`SpawnSubTask` はデフォルト無効のツールです。使うには、ワークスペースの **設定 → ツール** タブで明示的にオンにします(Bash などと同じ「センシティブ/任意有効化」の扱い)。有効にしていないワークスペースでは、エージェントに SpawnSubTask が提示されず、分解は delegate(直列)で行われます。
`SpawnSubTask` はデフォルト無効のツールです。使うには、ワークスペースの **設定 → ツール** タブで明示的にオンにします(Bash などと同じ「センシティブ/任意有効化」の扱い)。有効にしていないワークスペースでは、エージェントに SpawnSubTask が提示されず、分解は委譲実行で行われます。
有効にしたうえでエージェントが `SpawnSubTask` を呼ぶと、サブタスクがキューに追加されます。複数回呼べば複数のサブタスクが並列にスケジュールされます。各サブタスクには独立した専用ワークスペースが割り当てられ、別ジョブとして実行されます。
@@ -66,6 +66,10 @@ SpawnSubTask で起動したサブタスクの**中で** delegate が動いた
各子は独立ワークスペースを持つため、成果物(`output/`)も子ごとに分かれて配信されます。
## サブタスク内での会話振り返り
サブタスクは実行中、自分自身の会話履歴(transcript)を `SearchTaskConversation` / `ReadTaskConversation` で振り返れます。長く回るサブタスクが序盤の指示や制約を思い出すのに使えます。ただし振り返れるのは自分の履歴だけで、親タスクや他タスクの会話(`SearchWorkspaceTasks`)には引き続きアクセスできません。
## 待機中の入力(割り込み)
親が waiting_subtasks の間でも、Chat 画面から親へメッセージを送れます(割り込み = interjection)。送ったメッセージは、エージェントが次のイテレーションで取り込みます。
@@ -90,7 +94,7 @@ subtasks:
## TIP
> ふだんの分解は delegate(直列)で十分です。SpawnSubTask の並列化は、独立したテーマが複数あって壁時計時間を縮めたいときだけ、ツール設定で有効にして使います。
> ふだんの分解は委譲実行で十分です。SpawnSubTask の並列化は、独立したテーマが複数あって壁時計時間を縮めたいときだけ、ツール設定で有効にして使います。
> 前の結果に依存する逐次処理は、1 つのタスク内の movement 遷移で扱う方が確実です(→「[ピースの仕組み](05-pieces.md)」)。
+5 -1
View File
@@ -26,6 +26,8 @@ keywords: [Skills, スキル, インストール, Git URL, ReadSkill, per-task,
利用可能なスキルは、movement 開始時に system prompt の **Skills Index** として一覧注入されます。エージェントは概要を見て「これは使える」と判断したら `ReadSkill({ name })` で全文を読み込みます。`ListSkills` で一覧、`InstallSkill` でタスク中に新規インストールもできます。
スクリプト付きスキルは `ReadSkill` 時に workspace の `skills/{名前}/` へコピーされます。スキルが後から(別のタスクなどで)更新された場合も、次の `ReadSkill` でコピーが自動的に最新版へ置き換わるので、読み直すだけで更新が反映されます。
## スキルの追加(ワークスペース → 設定 → スキル)
ワークスペースを開いて **設定 → スキル** で管理します(旧「ユーザーフォルダ → skills」タブは廃止され、ワークスペース設定に集約されました)。2 カラムの list + detail 構成です。
@@ -38,13 +40,15 @@ keywords: [Skills, スキル, インストール, Git URL, ReadSkill, per-task,
4. **Scope**: Personal(個人)/ System(全ユーザー共有、admin のみ)
5. **Create**
Name は Content の frontmatter `name:` と一致している必要があります(一覧上の表示名と保存先の名前がズレて管理できなくなるのを防ぐため、不一致は作成時に拒否されます)。エージェントの InstallSkill も同じ規則です。
### URL からインストール
左パネル上部の「Install from URL...」に Git URL を入力 → **Install**。個人スコープ(user)でインストールされます。
### 編集・削除
一覧から選ぶと右に詳細(説明・トリガー・本文・セキュリティ検査結果)が出ます。**Edit** / **Delete** で更新できます。system スコープのスキルは admin のみ編集可能です。
一覧から選ぶと右に詳細(説明・トリガー・本文・セキュリティ検査結果)が出ます。**Edit** / **Delete** で更新できます。system スコープのスキルは admin のみ編集可能です。削除や保存に失敗した場合、エラーはボタンのすぐ近くに表示されます。
## 共有ワークスペースでの可視性
+4
View File
@@ -49,6 +49,10 @@ Read と Grep は、UTF-8 以外で保存されたテキストも読めます。
画像・ZIP・実行ファイルなどの本物のバイナリは、これまでどおり Read が拒否します。Grep も対象フォルダに画像などのバイナリが混じっていた場合は自動でスキップするので、検索結果にバイナリの断片が紛れ込みません。画像を内容まで読みたいときは `ReadImage` を使ってください。
### 大きすぎる出力の自動切り詰め
Read と Bash に加えて、**Grep の検索結果も残コンテキスト予算に収まるよう自動で切り詰められる**ようになりました。緩いパターンでマッチが数万行に及ぶような場合、先頭部分だけが返り、冒頭に「自動切り詰め」の注記(元の件数と、pattern を具体的にする・`glob``path` で範囲を絞るといった対処の案内)が付きます。結果が途中で終わっているのは不具合ではなく、この保護が働いた状態です。
### PDF・Excel を途中から読むときの範囲指定
Read の `offset``limit`(行指定)と `byte_offset``byte_length` は**テキストファイル専用**です。PDF・Excel・Word ではこれらは無視されます。PDF を特定のページだけ読むときは `page_range`(例 `"5-10"`)、Excel は `sheet``range`(例 `"A1:D50"`)を使います。誤って PDF に `offset` を渡した場合は、無視して先頭に戻る代わりに、正しいパラメータへの案内が出力に付きます。
+10
View File
@@ -137,6 +137,16 @@ token・API key・SSH 秘密鍵などの機密値は、そのワークスペー
- ダウンロードはサーバー側で安全に隔離して実行されます。エージェント自身は引き続きネットワークから遮断されたまま、追加されたライブラリだけを読み込めます。
- この機能は既定でオフです。管理者が `config.yaml``python_packages.enabled` を有効にすると、タブに入力フォームが現れます。無効のとき、またはサーバー側の準備(サンドボックスや `pip`)が整っていないときは、その旨が画面に表示されます(黙って失敗しません)。インストールは安全なサンドボックスが使える環境でのみ実行できます。
### エージェントからのパッケージ申請
作業の途中でエージェント自身が「このライブラリが要る」と気づいたときは、チャットに **承認カード** が出ます。パッケージ名と理由が示されるので、内容を見て「許可」か「拒否」を選んでください。
- 「許可」を押すと、そのパッケージがこのワークスペースに追加され、止まっていた処理が自動で再開します。以後 `import` できます。
- 「拒否」を押すと、エージェントはそのパッケージ無しで先に進みます。
- 承認できるのは、そのタスクを編集できる人(オーナー / 管理者 / スペースの編集者)だけです。エージェントが勝手に入れることはありません。
- 追加されるのは、上のフォームで足したものと同じ扱い(wheel のみ・このワークスペース限定)です。設定 → Python の一覧にも並び、あとから削除できます。
- サブタスクや定期実行など、その場に人がいない実行では、申請は記録だけされて処理はパッケージ無しで続きます(あとから一覧で確認できます)。
## 関連
- 各設定(AGENTS.md / メモリ / Pieces / スキル / MCP / SSH / ブラウザ / ツール / メンバー / 招待リンク)の詳しい場所と操作 → [個人の資産(ワークスペース設定)](./09-userfolder.md)
+4 -1
View File
@@ -116,8 +116,11 @@ a2a:
> `tasks/resubscribe` は現在、**その時点の最新状態を返すだけ**です。切断後に進捗ストリームを途中から再開することはできません(進捗を追う場合は `tasks/get` でポーリングしてください)。
## 委任ごとのリソース制限
A2A 経由で接続した外部エージェントには、委任ごとにリクエストレート・同時実行数・ペイロードサイズ・ストリーム時間・スキル実行予算の制限が適用されます。サーバーへの過負荷を防ぎ、複数の委任が公平にリソースを使えるようにするための仕組みです。デフォルト値でも問題なく動作しますが、環境に合わせて調整したい場合は管理者が `config.yaml``a2a.limits` セクションを編集してください。
## 注意事項
- 外部エージェントに付与するスコープは必要最小限にしてください。
- クライアントシークレットは安全に管理し、外部に漏らさないでください。
- push 通知(webhook)やリソース上限(同時実行数・ペイロードサイズ)は後続のアップデートで対応予定です。
+8
View File
@@ -7,6 +7,14 @@
"failed": "Action failed (you may not have permission)",
"composerLocked": "Waiting for tool approval — approve or deny in the card above"
},
"packageRequest": {
"title": "The agent is requesting the Python package \"{{spec}}\"",
"reason": "Reason",
"note": "Approving adds it to this workspace only (wheels only, fixed index)",
"approve": "Approve",
"deny": "Deny",
"failed": "Action failed (you may not have permission, or the install failed)"
},
"pane": {
"empty": "No messages yet",
"newMessages": "{{count}} new",
+14 -5
View File
@@ -2,7 +2,18 @@
"delegateRuns": {
"eventsEmpty": "No events",
"subtaskGroupTitle": "Subtask #{{n}}",
"subtaskSectionHeading": "Delegate runs in subtasks"
"subtaskSectionHeading": "Delegate runs in subtasks",
"result": "Result",
"abortReason": "Abort reason",
"noDescription": "(no description)",
"toolCount_one": "{{count}} tool",
"toolCount_other": "{{count}} tools",
"childCount": "{{total}} children",
"childCountFailed": "{{total}} children, {{failed}} failed",
"moreEvents": "{{count}} more events (see the Trace tab for the full list)",
"toolSummary": "Tools used",
"filesChanged": "Changed files",
"eventsToggle": "Detailed events ({{count}})"
},
"tabs": { "chat": "Chat", "overview": "Overview", "activity": "Progress", "files": "Files", "trace": "Trace", "browser": "Browser", "ssh": "SSH" },
"chatTabsLabel": "Chat and details",
@@ -49,9 +60,7 @@
},
"timeline": { "empty": "No comments" },
"context": {
"ariaLabel": "Context: {{remaining}} tokens remaining, {{percent}}% used",
"awaiting": "Waiting for the first LLM call",
"remaining": "{{remaining}} tokens left · {{percent}}%"
"ariaLabel": "Context: {{remaining}} tokens remaining, {{percent}}% used"
},
"sharingPreview": { "notShared": "— not shared" },
"trace": { "loading": "Loading...", "error": "Error", "empty": "events.jsonl does not exist yet. Once the task runs at least once, a trace of the engine internals appears here.", "refreshTooltip": "Manual refresh (also auto-refreshes every 5s)", "totalTime": "Total time (excludes cache hits; max is the largest single call)" },
@@ -59,7 +68,7 @@
"subtasks": {
"activityTitle": "Subtask progress", "done": "done", "filesLoading": "Loading files...", "files": "Files",
"childTasks": "Child tasks ({{done}}/{{total}} done)", "title": "Sub-runs",
"delegateSection": "Delegate (serial)",
"delegateSection": "Delegated runs",
"delegateStatus": {
"success": "Done",
"aborted": "Aborted",
+1 -3
View File
@@ -86,9 +86,7 @@
},
"conversation": {
"deleteConfirm": "Delete this chat? This action can't be undone.",
"backToList": "Back to list",
"visibilityTitle": "Only members of this workspace can view it",
"visibilityNote": "🔒 Visible to workspace members"
"backToList": "Back to list"
},
"chatDetailSplit": {
"resizeLabel": "Resize chat and detail panes"
+8
View File
@@ -7,6 +7,14 @@
"failed": "操作に失敗しました(権限が無い可能性があります)",
"composerLocked": "ツールの承認待ちです。上のカードで「許可/拒否」を選んでください"
},
"packageRequest": {
"title": "エージェントが Python パッケージ「{{spec}}」の追加を求めています",
"reason": "理由",
"note": "承認するとこのワークスペース限定で追加されます(wheel のみ・固定インデックス)",
"approve": "許可",
"deny": "拒否",
"failed": "操作に失敗しました(権限が無い、またはインストールに失敗した可能性があります)"
},
"pane": {
"empty": "メッセージはまだありません",
"newMessages": "{{count}} 件の新着",
+13 -5
View File
@@ -2,7 +2,17 @@
"delegateRuns": {
"eventsEmpty": "イベントなし",
"subtaskGroupTitle": "サブタスク #{{n}}",
"subtaskSectionHeading": "サブタスク内の委譲"
"subtaskSectionHeading": "サブタスク内の委譲",
"result": "結果",
"abortReason": "中断理由",
"noDescription": "(説明なし)",
"toolCount": "ツール {{count}} 回",
"childCount": "子 {{total}} 件",
"childCountFailed": "子 {{total}} 件・失敗 {{failed}}",
"moreEvents": "他 {{count}} 件(トレースタブで全件表示できます)",
"toolSummary": "使用ツール",
"filesChanged": "変更ファイル",
"eventsToggle": "詳細イベント({{count}} 件)"
},
"tabs": { "chat": "会話", "overview": "概要", "activity": "進捗", "files": "ファイル", "trace": "トレース", "browser": "ブラウザ", "ssh": "SSH" },
"chatTabsLabel": "チャットと詳細",
@@ -49,9 +59,7 @@
},
"timeline": { "empty": "コメントなし" },
"context": {
"ariaLabel": "コンテキスト残り {{remaining}} tokens、{{percent}}% 使用",
"awaiting": "最初の LLM 呼び出しを待機中",
"remaining": "残り {{remaining}} tokens · {{percent}}%"
"ariaLabel": "コンテキスト残り {{remaining}} tokens、{{percent}}% 使用"
},
"sharingPreview": { "notShared": "— 共有されません" },
"trace": { "loading": "読み込み中...", "error": "エラー", "empty": "events.jsonl がまだ存在しません。タスクが少なくとも一度実行されると、engine 内部動作のトレースがここに表示されます。", "refreshTooltip": "手動更新(自動 5 秒ごとにも更新されます)", "totalTime": "総時間 (cache hit 除外、max は個別呼び出しの最大値)" },
@@ -59,7 +67,7 @@
"subtasks": {
"activityTitle": "サブタスク進捗", "done": "完了", "filesLoading": "ファイル読み込み中...", "files": "ファイル",
"childTasks": "子タスク ({{done}}/{{total}} 完了)", "title": "サブ実行",
"delegateSection": "delegate(直列)",
"delegateSection": "委譲実行",
"delegateStatus": {
"success": "完了",
"aborted": "中断",
+1 -3
View File
@@ -86,9 +86,7 @@
},
"conversation": {
"deleteConfirm": "このチャットを削除しますか?この操作は取り消せません。",
"backToList": "一覧へ",
"visibilityTitle": "このワークスペースのメンバーだけが閲覧できます",
"visibilityNote": "🔒 このワークスペースのメンバーに公開"
"backToList": "一覧へ"
},
"chatDetailSplit": {
"resizeLabel": "チャットと詳細の幅を調整"
+74
View File
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { supportsFieldSizing, autosizeTextarea } from './composerAutosize';
/**
* scrollHeight を style.height に依存するゲッターで返す。
* 実ブラウザと同じく、height が明示設定されていたら見えている箱の高さ以上の値を返す。
* height が auto または未設定なら、コンテンツ実寸(contentPx)を返す。
* これにより「height を auto に戻さない実装」を検出できる。
*/
function textareaWithContentHeight(contentPx: number): HTMLTextAreaElement {
const el = document.createElement('textarea');
Object.defineProperty(el, 'scrollHeight', {
configurable: true,
get() {
const h = parseInt(this.style.height, 10);
// height:auto または未設定なら、実コンテンツ高さを返す
if (this.style.height === 'auto' || Number.isNaN(h)) return contentPx;
// 明示 height が付いたままなら、その値以上を返す
return Math.max(h, contentPx);
},
});
return el;
}
/**
* レガシーヘルパー。静的 scrollHeight を使う拡張テストで利用。
*/
function textareaWithScrollHeight(h: number): HTMLTextAreaElement {
const el = document.createElement('textarea');
Object.defineProperty(el, 'scrollHeight', { value: h, configurable: true });
return el;
}
describe('supportsFieldSizing', () => {
it('jsdom (CSS.supports 非対応環境) では false', () => {
// jsdom の CSS.supports は field-sizing を知らない → フォールバック経路に入る
expect(supportsFieldSizing()).toBe(false);
});
it('CSS グローバルが無い環境でも throw せず false', () => {
vi.stubGlobal('CSS', undefined);
try {
expect(supportsFieldSizing()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
describe('autosizeTextarea', () => {
it('scrollHeight まで高さを伸ばす(max 以内)', () => {
const el = textareaWithScrollHeight(120);
autosizeTextarea(el, 192);
expect(el.style.height).toBe('120px');
expect(el.style.overflowY).toBe('hidden');
});
it('max を超えたら max で止めて縦スクロールにする', () => {
const el = textareaWithScrollHeight(500);
autosizeTextarea(el, 192);
expect(el.style.height).toBe('192px');
expect(el.style.overflowY).toBe('auto');
});
it('縮小方向にも効く(height を一度 auto に戻してから測る)', () => {
const el = textareaWithContentHeight(60);
el.style.height = '192px';
autosizeTextarea(el, 192);
// height を auto に戻して初めてコンテンツ実寸 60px を測れる。
// もし height を auto に戻さない実装なら、scrollHeight は 192px 以上を返して 192px で止まる。
expect(el.style.height).toBe('60px');
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* コンポーザー textarea の自動拡張。
*
* 第一選択は CSS `field-sizing: content`Chrome/Edge 123+, Safari 26.2+)。
* Firefox は未対応のため、supportsFieldSizing() が false の環境でだけ
* autosizeTextarea() を onInput で呼ぶ JS フォールバックを使う。
* 「height を一度 auto に戻してから scrollHeight を読む」のは縮小方向
* (行削除時)に追従させるため。
*/
export function supportsFieldSizing(): boolean {
return (
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('field-sizing', 'content')
);
}
export function autosizeTextarea(el: HTMLTextAreaElement, maxPx: number): void {
el.style.height = 'auto';
const next = Math.min(el.scrollHeight, maxPx);
el.style.height = `${next}px`;
el.style.overflowY = el.scrollHeight > maxPx ? 'auto' : 'hidden';
}
+135 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildDelegateRunTree, delegateStatusBadge, formatElapsed, type DelegateRun } from './delegateRuns';
import { buildDelegateRunTree, currentRunningTool, delegateStatusBadge, formatElapsed, formatTokens, summarizeDescendants, type DelegateRun } from './delegateRuns';
const run = (id: string, parentRunId: string | null, depth = 1): DelegateRun => ({
delegateRunId: id, parentRunId, description: id, depth,
@@ -37,6 +37,39 @@ describe('buildDelegateRunTree', () => {
expect(tree).toHaveLength(1); // a が root、b はその子
expect(tree[0].children).toHaveLength(1);
});
it('startTs 逆順の runs を渡すと roots と children が startTs 昇順にソートされる', () => {
const runs = [
{ ...run('C', 'P', 2), startTs: '2026-06-25T00:00:30Z' },
{ ...run('B', 'P', 2), startTs: '2026-06-25T00:00:20Z' },
{ ...run('P', null, 1), startTs: '2026-06-25T00:00:00Z' },
{ ...run('A', 'P', 2), startTs: '2026-06-25T00:00:10Z' },
];
const tree = buildDelegateRunTree(runs);
// root が 1 つ(P
expect(tree).toHaveLength(1);
expect(tree[0].delegateRunId).toBe('P');
// children が startTs 昇順(A, B, C
expect(tree[0].children).toHaveLength(3);
expect(tree[0].children[0].delegateRunId).toBe('A');
expect(tree[0].children[1].delegateRunId).toBe('B');
expect(tree[0].children[2].delegateRunId).toBe('C');
});
it('startTs が同じ場合は delegateRunId で安定化', () => {
const runs = [
{ ...run('C', 'P', 2), startTs: '2026-06-25T00:00:00Z' },
{ ...run('B', 'P', 2), startTs: '2026-06-25T00:00:00Z' },
{ ...run('P', null, 1), startTs: '2026-06-25T00:00:00Z' },
{ ...run('A', 'P', 2), startTs: '2026-06-25T00:00:00Z' },
];
const tree = buildDelegateRunTree(runs);
// children が ID の localeCompare 昇順(A, B, C
expect(tree[0].children).toHaveLength(3);
expect(tree[0].children[0].delegateRunId).toBe('A');
expect(tree[0].children[1].delegateRunId).toBe('B');
expect(tree[0].children[2].delegateRunId).toBe('C');
});
});
describe('delegateStatusBadge', () => {
@@ -61,3 +94,104 @@ describe('formatElapsed', () => {
expect(formatElapsed('2026-06-25T00:00:10.000Z', '2026-06-25T00:00:00.000Z', 0)).toBe('0ms');
});
});
describe('formatTokens', () => {
it('999 → "999 tok"3桁以下は整数)', () => {
expect(formatTokens(999)).toBe('999 tok');
});
it('1000 → "1.0k tok"k単位、1桁小数)', () => {
expect(formatTokens(1000)).toBe('1.0k tok');
});
it('1234 → "1.2k tok"(丸める)', () => {
expect(formatTokens(1234)).toBe('1.2k tok');
});
it('1_000_000 → "1.0M tok"M単位)', () => {
expect(formatTokens(1_000_000)).toBe('1.0M tok');
});
it('0 → "0 tok"', () => {
expect(formatTokens(0)).toBe('0 tok');
});
it('999_999 → "1000.0k tok"k単位上限)', () => {
expect(formatTokens(999_999)).toBe('1000.0k tok');
});
});
describe('currentRunningTool', () => {
it('tool_call のみで pending になり、ツール名を返す', () => {
const events = [
{ kind: 'tool_call', payload: { tool: 'Read' } },
];
expect(currentRunningTool(events)).toBe('Read');
});
it('tool_call + tool_result で完結すると null になる', () => {
const events = [
{ kind: 'tool_call', payload: { tool: 'Read' } },
{ kind: 'tool_result', payload: { tool: 'Read' } },
];
expect(currentRunningTool(events)).toBeNull();
});
it('複数ツールが交互に来て、最後の call が未完なら最後のツール名を返す', () => {
const events = [
{ kind: 'tool_call', payload: { tool: 'Read' } },
{ kind: 'tool_result', payload: { tool: 'Read' } },
{ kind: 'tool_call', payload: { tool: 'Write' } },
{ kind: 'tool_result', payload: { tool: 'Write' } },
{ kind: 'tool_call', payload: { tool: 'Bash' } },
];
expect(currentRunningTool(events)).toBe('Bash');
});
});
describe('summarizeDescendants', () => {
it('子がなければ total=0', () => {
const tree = buildDelegateRunTree([run('P', null, 1)]);
const summary = summarizeDescendants(tree[0]);
expect(summary).toEqual({ total: 0, failed: 0, running: 0 });
});
it('子が 1 つで status=success の場合', () => {
const runs = [
run('P', null, 1),
run('C', 'P', 2),
];
const tree = buildDelegateRunTree(runs);
const summary = summarizeDescendants(tree[0]);
expect(summary).toEqual({ total: 1, failed: 0, running: 0 });
});
it('孫に aborted がある場合、failed にカウント', () => {
const runs = [
run('P', null, 1),
{ ...run('C', 'P', 2), status: 'running' as const },
{ ...run('GC', 'C', 3), status: 'aborted' as const },
];
const tree = buildDelegateRunTree(runs);
const summary = summarizeDescendants(tree[0]);
expect(summary.total).toBe(2);
expect(summary.failed).toBe(1);
expect(summary.running).toBe(1);
});
it('複数の running と failed を正しく集計', () => {
const runs = [
run('P', null, 1),
{ ...run('C1', 'P', 2), status: 'running' as const },
{ ...run('C2', 'P', 2), status: 'aborted' as const },
{ ...run('C3', 'P', 2), status: 'success' as const },
{ ...run('GC1', 'C1', 3), status: 'running' as const },
{ ...run('GC2', 'C2', 3), status: 'aborted' as const },
];
const tree = buildDelegateRunTree(runs);
const summary = summarizeDescendants(tree[0]);
expect(summary.total).toBe(5);
expect(summary.failed).toBe(2);
expect(summary.running).toBe(2);
});
});
+74
View File
@@ -8,6 +8,10 @@ export interface DelegateRun {
endTs: string | null;
eventCount: number;
toolCalls: number;
resultPreview?: string | null;
totalTokens?: number;
toolSummary?: Array<{ tool: string; count: number }>;
filesChanged?: string[];
}
export interface DelegateRunNode extends DelegateRun {
@@ -50,10 +54,18 @@ export function formatElapsed(startTs: string, endTs: string | null, now: number
return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
}
/** トークン数を人間が読みやすい形式にフォーマット。例: 999 → "999 tok", 1000 → "1.0k tok"。 */
export function formatTokens(n: number): string {
if (n < 1000) return `${n} tok`;
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k tok`;
return `${(n / 1_000_000).toFixed(1)}M tok`;
}
/**
* Build a tree from a flat list of DelegateRun records.
* Runs whose parentRunId is null, or whose parent is not in the set,
* are treated as roots (orphan fallback — no data loss).
* Sorts roots and children by startTs (ascending), with delegateRunId as tiebreaker.
*/
export function buildDelegateRunTree(runs: DelegateRun[]): DelegateRunNode[] {
const nodes = new Map<string, DelegateRunNode>();
@@ -69,5 +81,67 @@ export function buildDelegateRunTree(runs: DelegateRun[]): DelegateRunNode[] {
roots.push(node);
}
}
// Sort roots and children by startTs (ascending), tiebreak by delegateRunId
const sortFn = (a: DelegateRunNode, b: DelegateRunNode) => {
const cmp = a.startTs.localeCompare(b.startTs);
return cmp !== 0 ? cmp : a.delegateRunId.localeCompare(b.delegateRunId);
};
roots.sort(sortFn);
for (const node of nodes.values()) {
node.children.sort(sortFn);
}
return roots;
}
/**
* Determine the currently running tool from a sequence of trace events.
* Scans events in order: tool_call marks a tool as pending,
* tool_result for the same tool clears the pending state.
* Returns the name of the tool still pending (not yet completed),
* or null if no tool is currently running.
*/
export function currentRunningTool(
events: Array<{ kind: string; payload: unknown }>
): string | null {
let pending: string | null = null;
for (const event of events) {
if (event.kind === 'tool_call') {
const payload = event.payload as Record<string, unknown> | null;
const tool = payload?.tool;
if (typeof tool === 'string') {
pending = tool;
}
} else if (event.kind === 'tool_result') {
const payload = event.payload as Record<string, unknown> | null;
const tool = payload?.tool;
if (typeof tool === 'string' && tool === pending) {
pending = null;
}
}
}
return pending;
}
/**
* Summarize descendants of a node (children recursively, excluding self).
* Returns { total, failed, running } counts.
* - failed = runs with status === 'aborted'
* - running = runs with status === 'running'
*/
export function summarizeDescendants(node: DelegateRunNode): { total: number; failed: number; running: number } {
let total = 0;
let failed = 0;
let running = 0;
const traverse = (n: DelegateRunNode) => {
for (const child of n.children) {
total++;
if (child.status === 'aborted') failed++;
if (child.status === 'running') running++;
traverse(child);
}
};
traverse(node);
return { total, failed, running };
}
+46
View File
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
/**
* MarkdownText is used for chat/activity/timeline short LLM output (thinking,
* user messages, agent asks, movement previews). Raw HTML embedded in that
* output must NOT be injected as live DOM — otherwise a final answer that
* contains an HTML document (which the agent is often asked to write) renders
* its own tags and shatters the chat layout. Raw HTML must show as escaped
* source text instead.
*/
import '../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { MarkdownText } from './markdown-text';
describe('MarkdownText raw-HTML handling', () => {
it('does not render a raw block HTML tag as a live element', () => {
const { container } = render(
<MarkdownText text={'ここが出力です。\n\n<div class="boom">hello</div>'} />,
);
// The <div class="boom"> must not exist as a real element.
expect(container.querySelector('div.boom')).toBeNull();
// Its source must be visible as escaped text.
expect(container.textContent).toContain('<div class="boom">hello</div>');
});
it('shows a full HTML document as escaped source, not a rendered page', () => {
const doc = '<!DOCTYPE html>\n<html>\n<body><h1>Title</h1></body>\n</html>';
const { container } = render(<MarkdownText text={doc} />);
// No live <body>/<h1> smuggled into the chat DOM.
expect(container.querySelector('h1')).toBeNull();
expect(container.textContent).toContain('<!DOCTYPE html>');
expect(container.textContent).toContain('<h1>Title</h1>');
});
it('escapes inline raw HTML instead of rendering it', () => {
const { container } = render(<MarkdownText text={'a <b>bold</b> c'} />);
expect(container.querySelector('b')).toBeNull();
expect(container.textContent).toContain('<b>bold</b>');
});
it('still renders normal Markdown constructs', () => {
const { container } = render(<MarkdownText text={'**strong** and `code`'} />);
expect(container.querySelector('strong')?.textContent).toBe('strong');
expect(container.querySelector('code')?.textContent).toBe('code');
});
});
+13
View File
@@ -45,6 +45,19 @@ renderer.text = function ({ tokens, text }: { tokens?: unknown[]; text: string }
renderer.codespan = function ({ text }: { text: string }) {
return `<code>${linkifyOutputPathsInEscapedHtml(text)}</code>`;
};
// Raw HTML embedded in the source (e.g. an LLM final answer that IS an HTML
// document) must NOT be injected as live DOM — that lets the answer's own
// tags render and shatter the chat bubble layout. Escape it so the tags show
// as readable source instead. Block-level raw HTML becomes a code block;
// inline raw HTML (`<br>`, `<span>`…) becomes escaped inline text. This is the
// same intent as `renderer.code` in FilePreview's chat path.
function escapeRawHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
renderer.html = function ({ text, block }: { text: string; block: boolean }) {
const escaped = escapeRawHtml(text.replace(/\n$/, ''));
return block ? `<pre><code>${escaped}</code></pre>` : escaped;
};
const md = new Marked({ gfm: true, breaks: true, renderer });