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

This commit is contained in:
oss-sync
2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { fetchFileProvenance } from './api';
afterEach(() => { vi.restoreAllMocks(); });
describe('fetchFileProvenance', () => {
it('requests the provenance endpoint with section + path and returns the record', async () => {
const record = { relPath: 'output/a.md', sourceKind: 'agent_output', createdByTaskId: 7 };
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ provenance: record }), { status: 200 }),
);
const out = await fetchFileProvenance(1, 'output', 'a.md');
expect(out).toEqual(record);
const url = String(spy.mock.calls[0]![0]);
expect(url).toContain('/api/local/tasks/1/files/provenance?');
expect(url).toContain('section=output');
expect(url).toContain('path=a.md');
});
it('returns null on a non-ok response instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 500 }));
await expect(fetchFileProvenance(1, 'output', 'a.md')).resolves.toBeNull();
});
it('returns null when the record is absent', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ provenance: null }), { status: 200 }),
);
await expect(fetchFileProvenance(1, 'output', 'ghost.md')).resolves.toBeNull();
});
});
+94 -12
View File
@@ -158,6 +158,9 @@ export interface MissionBrief {
done: string;
open: string;
clarifications: string;
user_constraints?: string;
decisions?: string;
current_focus?: string;
}
export async function updateMissionBrief(
@@ -218,6 +221,33 @@ export async function decideToolRequest(
}
}
// 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 type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection';
export interface LocalTaskComment {
@@ -499,6 +529,52 @@ export async function updateSpaceToolPolicy(
}
}
// ─── 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;
@@ -672,7 +748,7 @@ export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'i
}
// ── Office プレビュー (Excel / PowerPoint) ───────────────────────────────
// サーバが Excel→シートのセル配列、PPTX→スライド画像(PNG data URL)に変換して返す。
// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。
export interface OfficeSpreadsheetSheet {
name: string;
@@ -692,7 +768,13 @@ export interface OfficePresentationPreview {
slideCount: number;
truncated: boolean;
}
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview;
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 {
@@ -1195,8 +1277,8 @@ export interface ToolCatalogEntry {
/** Human-readable explanation when `available` is false. */
reason?: string;
/**
* - 'global' → meta tools auto-injected by the agent loop
* - 'piece' → must be listed in a piece's `allowed_tools`
* - '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';
@@ -1209,9 +1291,7 @@ export async function fetchTools(): Promise<ToolCatalogEntry[]> {
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, the
// piece editor will then surface them as "unknown" entries (visible+disabled
// with a warning) once they appear in an existing piece's allowed_tools.
// 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',
@@ -2181,14 +2261,16 @@ export interface TraceEventLite {
payload: unknown;
}
export async function fetchDelegateRuns(taskId: number): Promise<import('./lib/delegateRuns').DelegateRun[]> {
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}`);
return (await res.json()).runs;
const body = await res.json();
return { runs: body.runs ?? [], subtasks: body.subtasks ?? [] };
}
export async function fetchDelegateRunTimeline(taskId: number, delegateRunId: string): Promise<TraceEventLite[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs/${encodeURIComponent(delegateRunId)}/timeline`);
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;
return (await res.json()).events ?? [];
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo, useCallback, type CSSProperties } from 'react';
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTask, LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
@@ -81,6 +81,17 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
return () => el.removeEventListener('scroll', handler);
}, [checkIfAtBottom]);
// タスクを開いたとき(ChatPane はチャット切替で remount される)は、最新の
// メッセージが見える位置で開けるよう、初回描画前に最下部へジャンプする。
// useLayoutEffect にすることで一番上→最下部のちらつきを避ける。以降の追従は
// 上の comments.length 監視エフェクトが担う。
useLayoutEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const delta = comments.length - prevCommentCountRef.current;
prevCommentCountRef.current = comments.length;
@@ -54,4 +54,56 @@ describe('DelegateLiveConsole', () => {
expect(screen.getByText('親')).toBeTruthy();
expect(screen.getByText('子')).toBeTruthy();
});
// --- Task 8: originJobId によるパーティション ---
it('originJobId=null の親エントリと originJobId あり のサブタスクエントリを両方描画し、見出しを表示する', () => {
const streams: Record<string, DelegateStreamEntry> = {
'run-parent': entry({
delegateRunId: 'run-parent',
description: 'Parent delegate run',
text: 'parent text output',
originJobId: null,
}),
'run-sub': entry({
delegateRunId: 'run-sub',
description: 'Subtask delegate run',
text: 'subtask text output',
originJobId: 'sub1',
}),
};
renderWithProviders(<DelegateLiveConsole streams={streams} />);
expect(screen.getByText('parent text output')).toBeTruthy();
expect(screen.getByText('subtask text output')).toBeTruthy();
// 'delegateRuns.subtaskSectionHeading' = 'Delegate runs in subtasks'
expect(screen.getByText('Delegate runs in subtasks')).toBeTruthy();
});
it('サブタスクエントリが無ければ subtaskSectionHeading を描画しない', () => {
const streams: Record<string, DelegateStreamEntry> = {
'run-parent': entry({
delegateRunId: 'run-parent',
description: 'Only parent run',
text: 'parent only',
originJobId: null,
}),
};
renderWithProviders(<DelegateLiveConsole streams={streams} />);
expect(screen.getByText('parent only')).toBeTruthy();
expect(screen.queryByText('Delegate runs in subtasks')).toBeNull();
});
it('サブタスクのみの場合も subtaskSectionHeading を表示する', () => {
const streams: Record<string, DelegateStreamEntry> = {
'run-sub-only': entry({
delegateRunId: 'run-sub-only',
description: 'Sub only run',
text: 'sub only text',
originJobId: 'job42',
}),
};
renderWithProviders(<DelegateLiveConsole streams={streams} />);
expect(screen.getByText('sub only text')).toBeTruthy();
expect(screen.getByText('Delegate runs in subtasks')).toBeTruthy();
});
});
+31 -3
View File
@@ -69,16 +69,44 @@ function ConsoleCard({ node }: { node: ConsoleNode }) {
* 走っているのは1本+その入れ子のみ)。完了した run はチャットに溜めず、
* 履歴は「概要>サブ実行」が担う(ライブ=チャット / 履歴=概要 の役割分担)。
* これで 50 件連続スイープでもカードが積み上がらない。
*
* originJobId == null の run → 親パーティション(上部)
* originJobId が truthy の run → サブタスクパーティション(見出し配下)
*/
export function DelegateLiveConsole({ streams }: { streams: Record<string, DelegateStreamEntry> }) {
const { t } = useTranslation('detail');
const running = Object.fromEntries(
Object.entries(streams).filter(([, e]) => e.status === 'running'),
);
const tree = buildTree(running);
if (tree.length === 0) return null;
// originJobId で親 / サブタスクに振り分け
const parentEntries: Record<string, DelegateStreamEntry> = {};
const subtaskEntries: Record<string, DelegateStreamEntry> = {};
for (const [id, e] of Object.entries(running)) {
if (e.originJobId == null) {
parentEntries[id] = e;
} else {
subtaskEntries[id] = e;
}
}
const parentTree = buildTree(parentEntries);
const subtaskTree = buildTree(subtaskEntries);
if (parentTree.length === 0 && subtaskTree.length === 0) return null;
return (
<div className="max-w-[85%] w-full space-y-1.5">
{tree.map((n) => <ConsoleCard key={n.delegateRunId} node={n} />)}
{parentTree.map((n) => <ConsoleCard key={n.delegateRunId} node={n} />)}
{subtaskTree.length > 0 && (
<>
<div className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide px-1 pt-1">
{t('delegateRuns.subtaskSectionHeading')}
</div>
{subtaskTree.map((n) => <ConsoleCard key={n.delegateRunId} node={n} />)}
</>
)}
</div>
);
}
@@ -0,0 +1,267 @@
// @vitest-environment jsdom
/**
* Component tests for ConnectionPicker (exported from ConsoleTab.tsx) — the
* "add a session" panel. Verifies: the connection list loads and renders,
* selecting a connection and submitting POSTs { connection_id } (no
* force_replace — that flow is dead now that the session endpoint adds
* sessions instead of replacing them) and calls onStarted with that same
* connection_id, a 429 task_session_cap/user_session_cap response surfaces a
* distinct cap message via the new i18n keys, and the old
* "replace the current session" control from the pre-add-session design is
* gone entirely.
*/
import '../../../test/dom-setup';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../test/render-helpers';
import '../../../i18n';
import type { SshConnection } from '../../../lib/ssh-types';
import type { ConsoleSessionSummary } from '../../../lib/ssh-console-types';
// ConsoleTab (the outer tab-strip + selection component, tested below in
// "ConsoleTab — new session selection") pulls in the real WS hook and
// xterm.js terminal. Neither is exercised by these tests — they only need
// the tab strip / selection logic — and both require browser APIs (real
// WebSocket, ResizeObserver, canvas) that jsdom doesn't provide, so they're
// replaced with inert stubs.
vi.mock('../../../hooks/useConsoleSession', () => ({
useConsoleSession: () => ({
state: { kind: 'no_session' },
onOutput: () => () => {},
onNotice: () => () => {},
send: () => {},
sendResize: () => {},
close: () => {},
reconnectNow: vi.fn(),
}),
}));
vi.mock('./console/TerminalView', () => ({
TerminalView: () => null,
}));
import { ConnectionPicker, ConsoleTab } from './ConsoleTab';
function makeConnection(overrides: Partial<SshConnection> = {}): SshConnection {
return {
id: 'conn-a',
ownerId: null,
label: 'Prod DB',
host: 'db.example.com',
port: 22,
username: 'root',
keyVersion: 1,
keyFingerprint: null,
hostKeyType: null,
hostKeyFingerprint: null,
hostKeyRecordedAt: null,
hostKeyVerifiedAt: null,
hostKeyPending: false,
hostKeyPendingFingerprint: null,
hostKeyPendingSource: null,
commandDenyPatterns: null,
commandAllowPatterns: null,
remotePathPrefix: '',
allowRemoteUnrestricted: false,
allowPrivateAddresses: false,
enabled: true,
disabledByAdmin: false,
disabledByAdminReason: null,
disabledByAdminAt: null,
disabledByAdminUserId: null,
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
...overrides,
} as SshConnection;
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** Wires fetchMock: GET /api/ssh/connections returns `connections`, POST
* .../console/session is handled by `postSession`. */
function stubFetch(
connections: SshConnection[],
postSession: (body: any) => { status: number; json: () => Promise<any> },
) {
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (url === '/api/ssh/connections') {
return { ok: true, status: 200, json: async () => ({ connections }) };
}
if (typeof url === 'string' && url.includes('/console/session') && init?.method === 'POST') {
const body = JSON.parse(String(init.body));
const { status, json } = postSession(body);
return { ok: status >= 200 && status < 300, status, json };
}
throw new Error(`unexpected fetch: ${url}`);
});
}
describe('ConnectionPicker', () => {
it('lists connections and, on submit, POSTs { connection_id } (no force_replace) and calls onStarted with that id', async () => {
const user = userEvent.setup();
const onStarted = vi.fn();
const connA = makeConnection({ id: 'conn-a', label: 'Prod DB' });
const connB = makeConnection({ id: 'conn-b', label: 'Staging' });
stubFetch([connA, connB], () => ({ status: 200, json: async () => ({}) }));
renderWithProviders(<ConnectionPicker taskId={1} onStarted={onStarted} />);
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2));
expect(screen.getByRole('option', { name: /Prod DB/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Staging/ })).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox'), 'conn-b');
await user.click(screen.getByRole('button', { name: /start session/i }));
await waitFor(() => expect(onStarted).toHaveBeenCalledWith('conn-b'));
const postCall = fetchMock.mock.calls.find(
([url]: [string]) => typeof url === 'string' && url.includes('/console/session'),
);
expect(postCall).toBeTruthy();
const [url, init] = postCall!;
expect(url).toBe('/api/local/tasks/1/console/session');
const body = JSON.parse(init.body);
expect(body).toEqual({ connection_id: 'conn-b' });
expect(body.force_replace).toBeUndefined();
});
it('surfaces a distinct message for a 429 task_session_cap response', async () => {
const user = userEvent.setup();
const onStarted = vi.fn();
stubFetch([makeConnection({ id: 'conn-a' })], () => ({
status: 429,
json: async () => ({ error: 'task_session_cap' }),
}));
renderWithProviders(<ConnectionPicker taskId={1} onStarted={onStarted} />);
await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0));
await user.click(screen.getByRole('button', { name: /start session/i }));
await waitFor(() => expect(screen.getByText(/console session limit/i)).toBeInTheDocument());
// Distinguish from the generic "startFailed: {{code}}" fallback message.
expect(screen.queryByText(/Could not start the session/i)).not.toBeInTheDocument();
expect(onStarted).not.toHaveBeenCalled();
});
it('surfaces a distinct message for a 429 user_session_cap response', async () => {
const user = userEvent.setup();
stubFetch([makeConnection({ id: 'conn-a' })], () => ({
status: 429,
json: async () => ({ error: 'user_session_cap' }),
}));
renderWithProviders(<ConnectionPicker taskId={1} onStarted={vi.fn()} />);
await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0));
await user.click(screen.getByRole('button', { name: /start session/i }));
await waitFor(() => expect(screen.getByText(/account-wide console session limit/i)).toBeInTheDocument());
});
it('has no "replace the current session" control (dead now that sessions add rather than replace)', async () => {
const user = userEvent.setup();
stubFetch([makeConnection({ id: 'conn-a' }), makeConnection({ id: 'conn-b', label: 'Staging' })], () => ({
status: 409,
json: async () => ({ error: 'connection_change_requires_force' }),
}));
renderWithProviders(<ConnectionPicker taskId={1} onStarted={vi.fn()} />);
await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0));
await user.click(screen.getByRole('button', { name: /start session/i }));
// Even a server response using the old conflict code must not resurrect
// the old replace-and-start button — that branch is deleted.
await waitFor(() => expect(screen.queryByText(/^Loading/)).not.toBeInTheDocument());
expect(screen.queryByRole('button', { name: /replace/i })).not.toBeInTheDocument();
expect(screen.queryByText(/replace the current session/i)).not.toBeInTheDocument();
});
});
function makeSummary(overrides: Partial<ConsoleSessionSummary> = {}): ConsoleSessionSummary {
return {
connection_id: 'conn-a',
connection_label: 'Prod DB',
started_at: '2026-07-01T00:00:00Z',
last_activity_at: '2026-07-01T00:00:00Z',
status: 'connected',
can_write: true,
can_close: true,
agent_active: false,
cols: 80,
rows: 24,
...overrides,
};
}
/**
* Regression test for Finding 3 (final-review, feat/ssh-console-multi-session):
* after the add-connection flow calls `onStarted(newConnectionId)`, ConsoleTab
* immediately selected the new id AND invalidated the sessions query — but the
* selection-reconciliation effect ran against the still-stale `sessions` list
* (the new session isn't in it until the refetch resolves), decided the new id
* "doesn't exist", and reverted the selection to the previous tab. The fix
* gates that effect on the sessions query's `isFetching` so a just-set
* selection survives the in-flight refetch instead of being clobbered by
* stale data.
*/
describe('ConsoleTab — new session selection (Finding 3 regression)', () => {
it('selects the newly-added connection once the sessions refetch resolves, and does not revert to the previous tab', async () => {
const user = userEvent.setup();
const sessionA = makeSummary({ connection_id: 'conn-a', connection_label: 'Prod DB' });
const sessionB = makeSummary({
connection_id: 'conn-b',
connection_label: 'Staging',
started_at: '2026-07-02T00:00:00Z',
last_activity_at: '2026-07-02T00:00:00Z',
});
const connA = makeConnection({ id: 'conn-a', label: 'Prod DB' });
const connB = makeConnection({ id: 'conn-b', label: 'Staging' });
let sessionAdded = false;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (url === '/api/local/tasks/1/console/sessions') {
return {
ok: true,
json: async () => ({ sessions: sessionAdded ? [sessionA, sessionB] : [sessionA] }),
};
}
if (url === '/api/ssh/connections') {
return { ok: true, status: 200, json: async () => ({ connections: [connA, connB] }) };
}
if (typeof url === 'string' && url.includes('/console/session') && init?.method === 'POST') {
sessionAdded = true;
return { ok: true, status: 200, json: async () => ({}) };
}
throw new Error(`unexpected fetch: ${url}`);
});
renderWithProviders(<ConsoleTab taskId={1} />);
// Initial state: only the conn-a tab exists.
await waitFor(() => expect(screen.getByText('Prod DB')).toBeInTheDocument());
expect(screen.queryByText('Staging')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /add connection/i }));
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2));
await user.selectOptions(screen.getByRole('combobox'), 'conn-b');
await user.click(screen.getByRole('button', { name: /start session/i }));
// Once the invalidated sessions query refetches (now including conn-b),
// its tab appears — and it must be the SELECTED tab, not reverted to
// conn-a.
await waitFor(() => expect(screen.getByText('Staging')).toBeInTheDocument());
const stagingTab = screen.getByText('Staging').closest('[role="button"]');
const prodTab = screen.getByText('Prod DB').closest('[role="button"]');
expect(stagingTab).toHaveAttribute('aria-selected', 'true');
expect(prodTab).toHaveAttribute('aria-selected', 'false');
});
});
+127 -53
View File
@@ -1,12 +1,13 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '../../../i18n';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useConsoleSession } from '../../../hooks/useConsoleSession';
import type { ConsoleStatus } from '../../../lib/ssh-console-types';
import type { ConsoleSessionSummary } from '../../../lib/ssh-console-types';
import type { SshConnection } from '../../../lib/ssh-types';
import { TerminalView, type TerminalViewHandle } from './console/TerminalView';
import { ConsoleHeader } from './console/ConsoleHeader';
import { ConsoleTabStrip } from './console/ConsoleTabStrip';
import { MobileKeyboardBar } from './console/MobileKeyboardBar';
import { ScrollToBottomButton } from './console/ScrollToBottomButton';
import { useViewportNarrow } from '../../layout/TopBar';
@@ -43,6 +44,10 @@ function describeSessionError(code: string): { msg: string; hardStop: boolean }
return { msg: i18n.t('detail:console.errors.abuseLocked'), hardStop: false };
case 'connection_not_found':
return { msg: i18n.t('detail:console.errors.notFound'), hardStop: false };
case 'task_session_cap':
return { msg: i18n.t('detail:console.errors.taskSessionCap'), hardStop: false };
case 'user_session_cap':
return { msg: i18n.t('detail:console.errors.userSessionCap'), hardStop: false };
default:
return { msg: i18n.t('detail:console.errors.startFailed', { code }), hardStop: false };
}
@@ -50,35 +55,110 @@ function describeSessionError(code: string): { msg: string; hardStop: boolean }
export function ConsoleTab({ taskId }: { taskId: number }) {
const qc = useQueryClient();
const { data: status } = useQuery<ConsoleStatus>({
queryKey: ['console-status', taskId],
const sessionsQueryKey = ['console-sessions', taskId] as const;
const { data: sessionsData, isFetching: sessionsFetching } = useQuery<{ sessions: ConsoleSessionSummary[] }>({
queryKey: sessionsQueryKey,
queryFn: async () => {
const r = await fetch(`/api/local/tasks/${taskId}/console/status`);
return r.ok ? r.json() : { active: false };
const r = await fetch(`/api/local/tasks/${taskId}/console/sessions`);
return r.ok ? r.json() : { sessions: [] };
},
refetchInterval: 5000,
});
const session = useConsoleSession(taskId);
const sessions = sessionsData?.sessions ?? [];
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
// Whether the add-connection panel is showing on top of an existing
// terminal. When there are zero sessions the empty-state picker always
// shows regardless of this flag.
const [showAddPanel, setShowAddPanel] = useState(false);
// Keep the selection valid: default to the most-recent session when none
// is selected yet, and fall back to another session (or empty state) if
// the selected one disappears (closed elsewhere, evicted, etc).
//
// Gated on `!sessionsFetching`: right after the add-connection flow calls
// `onStarted(newConnectionId)` (which sets selectedConnectionId AND
// invalidates this query), a refetch is in flight but `sessions` is still
// the STALE pre-add list — it doesn't contain the new id yet. Without the
// gate, this effect would see "selected id doesn't exist" on that stale
// list and immediately revert the selection back to the old tab, so the
// just-added tab never shows until the NEXT unrelated refetch (5s poll)
// happens to include it. Skipping reconciliation while a fetch is
// in-flight and re-running once it resolves (sessions + isFetching both
// change) lets the just-set selection survive until fresh data confirms
// whether it's actually still missing.
useEffect(() => {
if (sessionsFetching) return;
if (sessions.length === 0) {
if (selectedConnectionId !== null) setSelectedConnectionId(null);
return;
}
const stillExists = sessions.some((s) => s.connection_id === selectedConnectionId);
if (!stillExists) {
const mostRecent = [...sessions].sort(
(a, b) => new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime(),
)[0];
setSelectedConnectionId(mostRecent.connection_id);
}
}, [sessions, selectedConnectionId, sessionsFetching]);
const selectedSession = sessions.find((s) => s.connection_id === selectedConnectionId) ?? null;
// Only the selected tab gets a live WS — switching tabs tears down the old
// socket and connects to the new one (see useConsoleSession).
const session = useConsoleSession(taskId, selectedConnectionId);
const terminalRef = useRef<TerminalViewHandle>(null);
// 768px = Tailwind md breakpoint. Below this we consider the user to be on
// a phone/tablet without a physical keyboard, so the on-screen keyboard bar
// and scroll-to-bottom FAB become useful.
const compactMode = useViewportNarrow(768);
const showPicker = !status?.active;
const showEmptyState = sessions.length === 0;
const showPicker = showEmptyState || showAddPanel;
async function handleClose(connectionId: string) {
try {
await fetch(`/api/local/tasks/${taskId}/console/session/close`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
});
} finally {
// The selection-fixing effect above picks another tab (or the empty
// state) once the refreshed list no longer contains this connection.
qc.invalidateQueries({ queryKey: sessionsQueryKey });
}
}
return (
<div className="flex flex-col flex-1 min-h-0">
<ConsoleHeader state={session.state} status={status ?? null} />
{sessions.length > 0 && (
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId={selectedConnectionId}
onSelect={setSelectedConnectionId}
onClose={handleClose}
onAddConnection={() => setShowAddPanel(true)}
/>
)}
<ConsoleHeader state={session.state} session={selectedSession} />
<div className="flex-1 min-h-0 relative">
{showPicker ? (
<ConnectionPicker
taskId={taskId}
onStarted={() => {
// The session now exists server-side; refresh status and force
// an immediate WS attach instead of waiting for the 5s poll.
qc.invalidateQueries({ queryKey: ['console-status', taskId] });
session.reconnectNow();
onCancel={showEmptyState ? undefined : () => setShowAddPanel(false)}
onStarted={(connectionId) => {
// The session now exists server-side; refresh the list and
// immediately select the newly-added connection's tab instead
// of waiting for the "most-recent" selection-fixing effect (or
// the 5s sessions poll) to pick it up. Changing
// selectedConnectionId re-points useConsoleSession, which opens
// the WS for the new connection on its own — no explicit
// reconnect needed here.
setShowAddPanel(false);
setSelectedConnectionId(connectionId);
qc.invalidateQueries({ queryKey: sessionsQueryKey });
}}
/>
) : (
@@ -93,12 +173,20 @@ export function ConsoleTab({ taskId }: { taskId: number }) {
);
}
function ConnectionPicker({
export function ConnectionPicker({
taskId,
onStarted,
onCancel,
}: {
taskId: number;
onStarted: () => void;
/** Called once the server confirms the new session exists, with the
* connection_id that was just added — the caller uses this to select the
* new tab immediately instead of waiting for a list refresh. */
onStarted: (connectionId: string) => void;
/** Present only when this picker is shown as an add-connection panel on
* top of an existing terminal (not the full-screen empty state) — lets
* the user back out without starting a session. */
onCancel?: () => void;
}) {
const { t } = useTranslation('detail');
const { data, isLoading, error } = useQuery({
@@ -115,30 +203,23 @@ function ConnectionPicker({
const [selectedId, setSelectedId] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [errMsg, setErrMsg] = useState<{ msg: string; hardStop: boolean } | null>(null);
// Set when the server reports an existing session on a different connection;
// lets the user re-POST with force_replace to take over the session.
const [replaceCandidate, setReplaceCandidate] = useState<string | null>(null);
// Default the select to the first connection once loaded.
const effectiveId = selectedId || connections[0]?.id || '';
async function start(forceReplace: boolean) {
async function start() {
if (!effectiveId) return;
setSubmitting(true);
setErrMsg(null);
setReplaceCandidate(null);
try {
const res = await fetch(`/api/local/tasks/${taskId}/console/session`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connection_id: effectiveId,
...(forceReplace ? { force_replace: true } : {}),
}),
body: JSON.stringify({ connection_id: effectiveId }),
});
if (res.ok) {
onStarted();
onStarted(effectiveId);
return;
}
let code = `HTTP ${res.status}`;
@@ -148,14 +229,6 @@ function ConnectionPicker({
} catch {
// non-JSON body; keep HTTP status as the code
}
if (code === 'connection_change_requires_force') {
setReplaceCandidate(effectiveId);
setErrMsg({
msg: i18n.t('detail:console.errors.sessionExists'),
hardStop: false,
});
return;
}
setErrMsg(describeSessionError(code));
} catch (e) {
setErrMsg({ msg: e instanceof Error ? e.message : String(e), hardStop: false });
@@ -177,14 +250,26 @@ function ConnectionPicker({
return (
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
<div className="w-full max-w-md space-y-3">
<div>
<h3 className="text-sm font-semibold text-slate-100">{t('console.startTitle')}</h3>
<p className="text-2xs text-slate-400 mt-0.5">
{t('console.startDesc')}
</p>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-slate-100">{t('console.startTitle')}</h3>
<p className="text-2xs text-slate-400 mt-0.5">
{t('console.startDesc')}
</p>
</div>
{onCancel && (
<button
type="button"
onClick={onCancel}
aria-label={t('console.cancelAddConnection')}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded text-slate-400 hover:text-slate-100 hover:bg-surface"
>
</button>
)}
</div>
{isLoading && <div className="text-xs text-slate-400">Loading</div>}
{isLoading && <div className="text-xs text-slate-400">{t('console.loadingConnections')}</div>}
{error && <div className="text-xs text-red-400">{t('console.loadFailed')}: {String(error)}</div>}
{!isLoading && connections.length === 0 ? (
@@ -195,7 +280,7 @@ function ConnectionPicker({
<>
<select
value={effectiveId}
onChange={(e) => { setSelectedId(e.target.value); setErrMsg(null); setReplaceCandidate(null); }}
onChange={(e) => { setSelectedId(e.target.value); setErrMsg(null); }}
disabled={submitting}
className="w-full text-xs px-2 py-1.5 bg-surface border border-hairline rounded text-slate-100 disabled:opacity-50"
>
@@ -208,23 +293,12 @@ function ConnectionPicker({
<button
type="button"
onClick={() => start(false)}
onClick={() => start()}
disabled={submitting || !effectiveId}
className="w-full px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
>
{submitting ? t('console.starting') : t('console.startSession')}
</button>
{replaceCandidate && (
<button
type="button"
onClick={() => start(true)}
disabled={submitting}
className="w-full px-3 h-8 text-xs font-semibold border border-amber-400/50 text-amber-300 rounded-md hover:bg-amber-500/15 disabled:opacity-50"
>
{t('console.replaceStart')}
</button>
)}
</>
)}
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import '../../../test/dom-setup';
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
// Initialize i18next with minimal translations for this test.
// The component uses the 'detail' namespace.
beforeAll(async () => {
if (!i18n.isInitialized) {
await i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
resources: {
en: {
detail: {
'delegateRuns.eventsEmpty': 'No events',
'delegateRuns.subtaskGroupTitle': 'Subtask #{{n}}',
'delegateRuns.subtaskSectionHeading': 'Delegate runs in subtasks',
'subtasks.delegateSection': 'Delegate (serial)',
'subtasks.delegateStatus.success': 'Done',
'subtasks.delegateStatus.running': 'Running',
},
common: { loading: 'Loading...' },
},
},
defaultNS: 'common',
interpolation: { escapeValue: false },
});
}
});
vi.mock('../../../api', () => ({
fetchDelegateRuns: vi.fn().mockResolvedValue({
runs: [
{
delegateRunId: 'p1',
parentRunId: null,
description: '親委譲',
depth: 1,
status: 'success',
startTs: '2026-01-01T00:00:00Z',
endTs: '2026-01-01T00:01:00Z',
eventCount: 2,
toolCalls: 1,
},
],
subtasks: [
{
jobId: 'sub1',
issueNumber: 1,
depth: 1,
status: 'succeeded',
runs: [
{
delegateRunId: 's1',
parentRunId: null,
description: 'サブ委譲',
depth: 1,
status: 'success',
startTs: '2026-01-01T00:00:00Z',
endTs: '2026-01-01T00:01:00Z',
eventCount: 1,
toolCalls: 0,
},
],
},
],
}),
fetchDelegateRunTimeline: vi.fn().mockResolvedValue([]),
}));
import { DelegateRunsSection } from './DelegateRunsSection';
describe('DelegateRunsSection', () => {
it('親委譲とサブタスクグループの両方を描画する', async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<DelegateRunsSection taskId={1} />
</QueryClientProvider>,
);
// 親 run の description が表示される
expect(await screen.findByText(/親委譲/)).toBeInTheDocument();
// サブタスク run の description が表示される
expect(await screen.findByText(/サブ委譲/)).toBeInTheDocument();
// サブタスクグループ見出しが表示される(en or ja)
expect(
await screen.findByText(/Subtask #1|サブタスク #1/),
).toBeInTheDocument();
});
});
@@ -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 } from '../../../lib/delegateRuns';
import { buildDelegateRunTree, delegateStatusBadge, formatElapsed, type DelegateRunNode, type DelegateRun, type SubtaskDelegateGroup } from '../../../lib/delegateRuns';
import { useNow } from '../../../hooks/useNow';
import { summarizeTraceEvent } from '../../../lib/traceEvent';
@@ -18,7 +18,7 @@ function EventLine({ event }: { event: TraceEventLite }) {
);
}
function RunCard({ taskId, node, indent = 0 }: { taskId: number; node: DelegateRunNode; indent?: number }) {
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 badge = delegateStatusBadge(node.status);
@@ -27,8 +27,8 @@ function RunCard({ taskId, node, indent = 0 }: { taskId: number; node: DelegateR
const now = useNow(running);
const { data: events } = useQuery({
queryKey: ['delegate-run-timeline', taskId, node.delegateRunId],
queryFn: () => fetchDelegateRunTimeline(taskId, node.delegateRunId),
queryKey: ['delegate-run-timeline', taskId, node.delegateRunId, jobId],
queryFn: () => fetchDelegateRunTimeline(taskId, node.delegateRunId, jobId),
enabled: open,
// 開いていて実行中の間だけ自動更新。完了したら停止(done な run のイベントは不変)。
refetchInterval: open && running ? POLLING.FAST : false,
@@ -67,7 +67,7 @@ function RunCard({ taskId, node, indent = 0 }: { taskId: number; node: DelegateR
</div>
)}
{node.children.map((c) => (
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} />
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} jobId={jobId} />
))}
</div>
)}
@@ -77,19 +77,22 @@ function RunCard({ taskId, node, indent = 0 }: { taskId: number; node: DelegateR
export function DelegateRunsSection({ taskId }: { taskId: number }) {
const { t } = useTranslation('detail');
const { data: runs } = useQuery({
const { data } = useQuery({
queryKey: ['delegate-runs', taskId],
queryFn: () => fetchDelegateRuns(taskId),
// 実行中の run がある間は速く(FAST=5s)、無ければ通常(MEDIUM=10s)。
// refetchIntervalInBackground は既定 false なのでタブ非表示時は自動停止。
refetchInterval: (query) => {
const data = query.state.data as DelegateRun[] | undefined;
return data?.some((r) => r.status === 'running') ? POLLING.FAST : POLLING.MEDIUM;
const result = query.state.data;
const runs = result?.runs;
return runs?.some((r: DelegateRun) => r.status === 'running') ? POLLING.FAST : POLLING.MEDIUM;
},
});
const tree = buildDelegateRunTree(runs ?? []);
if (tree.length === 0) return null;
const tree = buildDelegateRunTree(data?.runs ?? []);
const subtasks: SubtaskDelegateGroup[] = data?.subtasks ?? [];
if (tree.length === 0 && subtasks.length === 0) return null;
return (
<div className="mb-4">
@@ -97,6 +100,19 @@ export function DelegateRunsSection({ taskId }: { taskId: number }) {
{tree.map((n) => (
<RunCard key={n.delegateRunId} taskId={taskId} node={n} />
))}
{subtasks.map((group) => {
const groupTree = buildDelegateRunTree(group.runs);
return (
<div key={group.jobId} className="mt-3">
<div className="text-xs font-semibold text-slate-500 mb-1 px-1">
{t('delegateRuns.subtaskGroupTitle', { n: group.issueNumber })}
</div>
{groupTree.map((n) => (
<RunCard key={n.delegateRunId} taskId={taskId} node={n} jobId={group.jobId} />
))}
</div>
);
})}
</div>
);
}
@@ -22,7 +22,7 @@ vi.mock('../../../api', async (importOriginal) => {
return {
...actual,
getLatestReflectionForTask: vi.fn().mockResolvedValue(null),
fetchDelegateRuns: vi.fn().mockResolvedValue([]),
fetchDelegateRuns: vi.fn().mockResolvedValue({ runs: [], subtasks: [] }),
putFeedback: vi.fn(),
};
});
@@ -205,12 +205,18 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
*/
const MISSION_FIELDS: Array<{ key: keyof MissionBrief }> = [
{ key: 'goal' },
{ key: 'user_constraints' },
{ key: 'decisions' },
{ key: 'done' },
{ key: 'open' },
{ key: 'current_focus' },
{ key: 'clarifications' },
];
const EMPTY_MISSION: MissionBrief = { goal: '', done: '', open: '', clarifications: '' };
const EMPTY_MISSION: MissionBrief = {
goal: '', done: '', open: '', clarifications: '',
user_constraints: '', decisions: '', current_focus: '',
};
function MissionCard({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
const { t } = useTranslation('detail');
@@ -239,7 +245,7 @@ function MissionCard({ task, readonly = false }: { task: LocalTask; readonly?: b
},
});
const isEmpty = !current.goal && !current.done && !current.open && !current.clarifications;
const isEmpty = MISSION_FIELDS.every(({ key }) => !current[key]);
// read-only(共有): 空の Mission Brief は編集導線が主目的なので丸ごと隠す。
if (readonly && isEmpty) return null;
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
/**
* Component tests for ConsoleHeader — the detail line above the terminal
* showing connected/connecting/replaying/disconnected status. Verifies the
* connected state resolves every label through i18n (namespace `detail`,
* keys under `console.*`) in both `en` and `ja`, and that no raw hardcoded
* English literal (e.g. "Connected", "uptime") leaks through once the
* language is switched to `ja`.
*/
import '../../../../test/dom-setup';
import { afterEach, describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../../../test/render-helpers';
import i18n from '../../../../i18n';
import type { ConsoleSessionSummary } from '../../../../lib/ssh-console-types';
import { ConsoleHeader } from './ConsoleHeader';
afterEach(async () => {
await i18n.changeLanguage('en');
});
function makeSession(overrides: Partial<ConsoleSessionSummary> = {}): ConsoleSessionSummary {
return {
connection_id: 'conn-a',
connection_label: 'Prod DB',
started_at: new Date(Date.now() - 65_000).toISOString(),
last_activity_at: new Date(Date.now() - 5_000).toISOString(),
status: 'connected',
can_write: true,
can_close: true,
agent_active: false,
...overrides,
};
}
describe('ConsoleHeader', () => {
it('renders the connected-state labels in ja and no raw English literal leaks through', async () => {
await i18n.changeLanguage('ja');
const t = i18n.getFixedT('ja', 'detail');
const session = makeSession();
renderWithProviders(
<ConsoleHeader state={{ kind: 'connected', canWrite: true, cols: 80, rows: 24 }} session={session} />,
);
// Japanese "connected" label from console.connected must appear.
expect(screen.getByText(new RegExp(t('console.connected')))).toBeInTheDocument();
const container = document.body.textContent ?? '';
expect(container).not.toMatch(/●\s*Connected\b/);
expect(container.toLowerCase()).not.toContain('uptime');
expect(container.toLowerCase()).not.toContain('idle');
});
it('shows the read-only badge via i18n when canWrite is false', async () => {
await i18n.changeLanguage('ja');
const t = i18n.getFixedT('ja', 'detail');
renderWithProviders(
<ConsoleHeader state={{ kind: 'connected', canWrite: false, cols: 80, rows: 24 }} session={makeSession()} />,
);
expect(screen.getByText(t('console.readOnly'))).toBeInTheDocument();
});
it('renders connecting/replaying/disconnected/no-session states via i18n in ja', async () => {
await i18n.changeLanguage('ja');
const t = i18n.getFixedT('ja', 'detail');
const { unmount: unmount1 } = renderWithProviders(
<ConsoleHeader state={{ kind: 'no_session' }} session={null} />,
);
expect(screen.getByText(t('console.noActiveConsole'))).toBeInTheDocument();
unmount1();
const { unmount: unmount2 } = renderWithProviders(
<ConsoleHeader state={{ kind: 'connecting' }} session={null} />,
);
expect(screen.getByText(t('console.connecting'))).toBeInTheDocument();
unmount2();
const { unmount: unmount3 } = renderWithProviders(
<ConsoleHeader state={{ kind: 'replaying' }} session={null} />,
);
expect(screen.getByText(t('console.restoringScrollback'))).toBeInTheDocument();
unmount3();
renderWithProviders(
<ConsoleHeader state={{ kind: 'disconnected', reason: 'boom' }} session={null} />,
);
expect(screen.getByText(t('console.disconnected', { reason: 'boom' }))).toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ConnState } from '../../../../hooks/useConsoleSession';
import type { ConsoleStatus } from '../../../../lib/ssh-console-types';
import type { ConsoleSessionSummary } from '../../../../lib/ssh-console-types';
function fmtElapsed(ms: number): string {
const total = Math.floor(ms / 1000);
@@ -10,31 +11,47 @@ function fmtElapsed(ms: number): string {
return `${s}s`;
}
export function ConsoleHeader({ state, status }: { state: ConnState; status: ConsoleStatus | null }) {
/**
* Detail line for the currently-selected console tab: connecting/replaying/
* disconnected status, or (once connected) the connection id, uptime, idle
* time and a read-only badge. `session` is the selected tab's REST-polled
* summary (for label/timestamps); `state` is the live WS state from
* useConsoleSession (for connected/canWrite, which the poll can't see).
*/
export function ConsoleHeader({ state, session }: { state: ConnState; session: ConsoleSessionSummary | null }) {
const { t } = useTranslation('detail');
const [now, setNow] = useState(Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
if (state.kind === 'no_session') {
return <div className="px-3 py-2 text-sm text-slate-500">No active console AI will open one when needed.</div>;
return <div className="px-3 py-2 text-sm text-slate-500">{t('console.noActiveConsole')}</div>;
}
if (state.kind === 'connecting' || state.kind === 'replaying') {
return <div className="px-3 py-2 text-sm text-amber-600">{state.kind === 'connecting' ? 'Connecting…' : 'Restoring scrollback…'}</div>;
return (
<div className="px-3 py-2 text-sm text-amber-600">
{state.kind === 'connecting' ? t('console.connecting') : t('console.restoringScrollback')}
</div>
);
}
if (state.kind === 'disconnected') {
return <div className="px-3 py-2 text-sm text-red-700 dark:text-red-300">Disconnected ({state.reason ?? 'unknown'}).</div>;
return (
<div className="px-3 py-2 text-sm text-red-700 dark:text-red-300">
{t('console.disconnected', { reason: state.reason ?? t('console.unknownReason') })}
</div>
);
}
const startedAt = status?.started_at ? new Date(status.started_at).getTime() : now;
const lastAt = status?.last_activity_at ? new Date(status.last_activity_at).getTime() : now;
const startedAt = session?.started_at ? new Date(session.started_at).getTime() : now;
const lastAt = session?.last_activity_at ? new Date(session.last_activity_at).getTime() : now;
return (
<div className="px-3 py-2 text-sm text-slate-700 border-b border-slate-200 flex items-center gap-3">
<span className="text-green-600"> Connected</span>
<span className="text-slate-500">conn {status?.connection_id ?? '—'}</span>
<span className="text-slate-500">uptime {fmtElapsed(now - startedAt)}</span>
<span className="text-slate-500">idle {fmtElapsed(now - lastAt)}</span>
{!state.canWrite && <span className="ml-auto rounded bg-slate-100 px-2 py-0.5 text-xs">viewer (read-only)</span>}
<span className="text-green-600"> {t('console.connected')}</span>
<span className="text-slate-500">{t('console.connLabel', { label: session?.connection_label ?? session?.connection_id ?? '—' })}</span>
<span className="text-slate-500">{t('console.uptime', { time: fmtElapsed(now - startedAt) })}</span>
<span className="text-slate-500">{t('console.idle', { time: fmtElapsed(now - lastAt) })}</span>
{!state.canWrite && <span className="ml-auto rounded bg-slate-100 px-2 py-0.5 text-xs">{t('console.readOnly')}</span>}
</div>
);
}
@@ -0,0 +1,194 @@
// @vitest-environment jsdom
/**
* Component tests for ConsoleTabStrip — the per-task strip of SSH console
* session tabs. Verifies both tabs render with their labels, the agent-active
* indicator only shows on the session with agent_active:true, the close (✕)
* button is hidden when can_close:false, clicking a tab fires onSelect with
* its connection_id, clicking the add-connection button fires onAddConnection,
* and clicking a close button fires onClose without also firing onSelect.
*/
import '../../../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../../test/render-helpers';
import i18n from '../../../../i18n';
import type { ConsoleSessionSummary } from '../../../../lib/ssh-console-types';
import { ConsoleTabStrip } from './ConsoleTabStrip';
const t = i18n.getFixedT('en', 'detail');
function makeSession(overrides: Partial<ConsoleSessionSummary> = {}): ConsoleSessionSummary {
return {
connection_id: 'conn-a',
connection_label: 'Prod DB',
started_at: '2026-07-03T00:00:00Z',
last_activity_at: '2026-07-03T00:05:00Z',
status: 'connected',
can_write: true,
can_close: true,
agent_active: false,
...overrides,
};
}
describe('ConsoleTabStrip', () => {
it('renders a tab per session with its label', () => {
const sessions = [
makeSession({ connection_id: 'conn-a', connection_label: 'Prod DB', agent_active: true }),
makeSession({ connection_id: 'conn-b', connection_label: 'Staging', can_close: false }),
];
renderWithProviders(
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId="conn-a"
onSelect={() => {}}
onClose={() => {}}
onAddConnection={() => {}}
/>,
);
expect(screen.getByText('Prod DB')).toBeInTheDocument();
expect(screen.getByText('Staging')).toBeInTheDocument();
});
it('shows the agent-active indicator only on the session with agent_active:true', () => {
const sessions = [
makeSession({ connection_id: 'conn-a', connection_label: 'Prod DB', agent_active: true }),
makeSession({ connection_id: 'conn-b', connection_label: 'Staging', agent_active: false }),
];
renderWithProviders(
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId="conn-a"
onSelect={() => {}}
onClose={() => {}}
onAddConnection={() => {}}
/>,
);
const agentLabel = t('console.agentActive');
expect(screen.getAllByLabelText(agentLabel)).toHaveLength(1);
});
it('hides the close button when can_close is false, shows it when true', () => {
const sessions = [
makeSession({ connection_id: 'conn-a', connection_label: 'Prod DB', can_close: true }),
makeSession({ connection_id: 'conn-b', connection_label: 'Staging', can_close: false }),
];
renderWithProviders(
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId="conn-a"
onSelect={() => {}}
onClose={() => {}}
onAddConnection={() => {}}
/>,
);
const closeLabel = t('console.closeSession');
expect(screen.getAllByLabelText(closeLabel)).toHaveLength(1);
});
it('calls onSelect with the connection_id when a tab is clicked', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const sessions = [
makeSession({ connection_id: 'conn-a', connection_label: 'Prod DB' }),
makeSession({ connection_id: 'conn-b', connection_label: 'Staging' }),
];
renderWithProviders(
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId="conn-a"
onSelect={onSelect}
onClose={() => {}}
onAddConnection={() => {}}
/>,
);
await user.click(screen.getByText('Staging'));
expect(onSelect).toHaveBeenCalledWith('conn-b');
});
it('calls onAddConnection when the add-connection button is clicked', async () => {
const user = userEvent.setup();
const onAddConnection = vi.fn();
renderWithProviders(
<ConsoleTabStrip
sessions={[makeSession()]}
selectedConnectionId="conn-a"
onSelect={() => {}}
onClose={() => {}}
onAddConnection={onAddConnection}
/>,
);
await user.click(screen.getByText(t('console.addConnection')));
expect(onAddConnection).toHaveBeenCalledTimes(1);
});
it('calls onClose with the right connection_id and does not also fire onSelect', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onClose = vi.fn();
const sessions = [
makeSession({ connection_id: 'conn-a', connection_label: 'Prod DB', can_close: true }),
makeSession({ connection_id: 'conn-b', connection_label: 'Staging', can_close: false }),
];
renderWithProviders(
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId="conn-a"
onSelect={onSelect}
onClose={onClose}
onAddConnection={() => {}}
/>,
);
const closeLabel = t('console.closeSession');
await user.click(screen.getByLabelText(closeLabel));
expect(onClose).toHaveBeenCalledWith('conn-a');
expect(onSelect).not.toHaveBeenCalled();
});
it('calls onSelect when Enter/Space is pressed on the tab itself', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const sessions = [
makeSession({ connection_id: 'conn-a', connection_label: 'Prod DB' }),
makeSession({ connection_id: 'conn-b', connection_label: 'Staging' }),
];
renderWithProviders(
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId="conn-a"
onSelect={onSelect}
onClose={() => {}}
onAddConnection={() => {}}
/>,
);
const tab = screen.getByText('Staging').closest('[role="button"]') as HTMLElement;
tab.focus();
await user.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith('conn-b');
});
it('does not fire onSelect when the close button is activated via keyboard', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onClose = vi.fn();
const sessions = [
makeSession({ connection_id: 'conn-a', connection_label: 'Prod DB', can_close: true }),
];
renderWithProviders(
<ConsoleTabStrip
sessions={sessions}
selectedConnectionId="conn-a"
onSelect={onSelect}
onClose={onClose}
onAddConnection={() => {}}
/>,
);
const closeLabel = t('console.closeSession');
const closeButton = screen.getByLabelText(closeLabel);
closeButton.focus();
await user.keyboard('{Enter}');
expect(onClose).toHaveBeenCalledWith('conn-a');
expect(onSelect).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import type { ConsoleSessionSummary } from '../../../../lib/ssh-console-types';
export interface ConsoleTabStripProps {
sessions: ConsoleSessionSummary[];
selectedConnectionId: string | null;
onSelect: (connectionId: string) => void;
onClose: (connectionId: string) => void;
onAddConnection: () => void;
}
const STATUS_DOT_CLASS: Record<ConsoleSessionSummary['status'], string> = {
connected: 'bg-green-500',
idle: 'bg-slate-400',
closed: 'bg-red-500',
};
/**
* Horizontal strip of SSH console session tabs for a task. Only the selected
* tab's connection drives a live WS (via useConsoleSession in ConsoleTab) —
* this component is purely presentational + event-emitting.
*/
export function ConsoleTabStrip({
sessions,
selectedConnectionId,
onSelect,
onClose,
onAddConnection,
}: ConsoleTabStripProps) {
const { t } = useTranslation('detail');
return (
<div className="flex items-center gap-1 px-2 py-1 border-b border-hairline bg-surface/40 overflow-x-auto">
{sessions.map((session) => {
const selected = session.connection_id === selectedConnectionId;
return (
<div
key={session.connection_id}
role="button"
tabIndex={0}
aria-selected={selected}
onClick={() => onSelect(session.connection_id)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(session.connection_id);
}
}}
className={`flex items-center gap-1.5 px-2.5 h-7 rounded-md text-xs font-medium whitespace-nowrap cursor-pointer transition-colors border ${
selected
? 'bg-accent-soft text-accent border-accent/60'
: 'text-slate-300 border-transparent hover:bg-surface'
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${STATUS_DOT_CLASS[session.status]}`}
aria-hidden="true"
/>
<span className="truncate max-w-[10rem]">{session.connection_label}</span>
{session.agent_active && (
<span aria-label={t('console.agentActive')} title={t('console.agentActive')}>
</span>
)}
{session.can_close && (
<button
type="button"
aria-label={t('console.closeSession')}
onClick={(e) => {
e.stopPropagation();
onClose(session.connection_id);
}}
className="ml-0.5 w-4 h-4 flex items-center justify-center rounded text-slate-400 hover:text-red-300 hover:bg-red-500/20"
>
</button>
)}
</div>
);
})}
<button
type="button"
onClick={onAddConnection}
className="flex-shrink-0 flex items-center gap-1 px-2 h-7 rounded-md text-xs font-medium text-slate-400 border border-dashed border-hairline hover:text-slate-200 hover:bg-surface"
>
{t('console.addConnection')}
</button>
</div>
);
}
+48 -1
View File
@@ -13,11 +13,14 @@
* and EmbedBlock. i18n uses the real instance for the 'files' namespace.
*/
import '../../test/dom-setup';
import '../../i18n';
import i18n from '../../i18n';
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { screen, within } from '@testing-library/react';
import { render } from '@testing-library/react';
// office プレビューのラベルは翻訳されるので、テストは ja に固定して日本語文言で検証する。
beforeAll(async () => { await i18n.changeLanguage('ja'); });
// jsdom has no IntersectionObserver; MarkdownPreview's scroll-spy effect uses
// one when the doc has headings. Provide a no-op stub so the effect is inert.
beforeAll(() => {
@@ -54,6 +57,7 @@ vi.mock('../../api', async (importOriginal) => {
});
import { FilePreview } from './FilePreview';
import { fetchOfficePreview } from '../../api';
const noop = () => {};
@@ -123,4 +127,47 @@ describe('FilePreview body branches', () => {
expect(screen.getByTitle('report.md')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('office document: renders each page as an image with a ページ label', async () => {
vi.mocked(fetchOfficePreview).mockResolvedValueOnce({
kind: 'document',
pages: [
{ index: 1, dataUrl: 'data:image/png;base64,AAA' },
{ index: 2, dataUrl: 'data:image/png;base64,BBB' },
],
pageCount: 2,
truncated: false,
});
render(
<FilePreview
name="report.docx"
content=""
office={{ kind: 'document', url: '/api/x/office-preview', downloadUrl: '/api/x/raw' }}
onClose={noop}
/>,
);
const page1 = (await screen.findByAltText('ページ 1')) as HTMLImageElement;
expect(page1.getAttribute('src')).toBe('data:image/png;base64,AAA');
expect(screen.getByAltText('ページ 2')).toBeInTheDocument();
});
it('office document: shows the truncation note when truncated', async () => {
vi.mocked(fetchOfficePreview).mockResolvedValueOnce({
kind: 'document',
pages: [{ index: 1, dataUrl: 'data:image/png;base64,AAA' }],
pageCount: 60,
truncated: true,
});
render(
<FilePreview
name="long.docx"
content=""
office={{ kind: 'document', url: '/api/x/office-preview' }}
onClose={noop}
/>,
);
await screen.findByAltText('ページ 1');
// "先頭 1 ページのみ表示しています(全 60 ページ)。"
expect(screen.getByText(/全 60 ページ/)).toBeInTheDocument();
});
});
+85 -16
View File
@@ -6,8 +6,8 @@ import { Marked, Renderer } from 'marked';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
import hljs from 'highlight.js';
import { updateLocalFileContent, fetchOfficePreview, OfficePreviewError } from '../../api';
import type { OfficePreview, OfficeSpreadsheetPreview, OfficePresentationPreview } from '../../api';
import { updateLocalFileContent, fetchOfficePreview, OfficePreviewError, fetchFileProvenance } from '../../api';
import type { OfficePreview, OfficeSpreadsheetPreview, OfficePresentationPreview, OfficeDocumentPreview, FileProvenance } from '../../api';
import { EmbedBlock } from '../embed/EmbedBlock';
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
import { resolvePreviewImageHref } from '../../lib/filePreviewPath';
@@ -73,7 +73,7 @@ interface FilePreviewProps {
}
export interface OfficePreviewDescriptor {
kind: 'spreadsheet' | 'presentation';
kind: 'spreadsheet' | 'presentation' | 'document';
/** office-preview エンドポイントの完全 URL */
url: string;
/** 失敗時のダウンロード用 raw URL (任意) */
@@ -152,24 +152,55 @@ function OfficeSpreadsheetView({ data }: { data: OfficeSpreadsheetPreview }): JS
);
}
function OfficePresentationView({ data }: { data: OfficePresentationPreview }): JSX.Element {
const { t } = useTranslation('files');
if (data.slides.length === 0) return <p className="text-sm text-slate-400">{t('preview.noSlides')}</p>;
// PowerPoint スライドと Word ページは、どちらも「ページ画像を縦に並べる」共通表示。
// ラベル・空/切り詰め文言だけが違うので、描画は 1 コンポーネントに寄せる。
function OfficePagesView({ pages, emptyNote, truncatedNote, pageLabel, altLabel }: {
pages: { index: number; dataUrl: string }[];
emptyNote: string;
truncatedNote: string | null;
pageLabel: (index: number) => string;
altLabel: (index: number) => string;
}): JSX.Element {
if (pages.length === 0) return <p className="text-sm text-slate-400">{emptyNote}</p>;
return (
<div className="flex flex-col items-center gap-4">
{data.slides.map((s) => (
<div key={s.index} className="w-full max-w-3xl">
<div className="mb-1 text-2xs text-slate-400">{t('preview.slide', { index: s.index })}</div>
<img src={s.dataUrl} alt={t('preview.slideAlt', { index: s.index })} className="w-full rounded-lg border border-hairline shadow-sm" />
{pages.map((p) => (
<div key={p.index} className="w-full max-w-3xl">
<div className="mb-1 text-2xs text-slate-400">{pageLabel(p.index)}</div>
<img src={p.dataUrl} alt={altLabel(p.index)} className="w-full rounded-lg border border-hairline shadow-sm" />
</div>
))}
{data.truncated && (
<p className="text-2xs text-slate-400">{t('preview.slidesTruncated', { shown: data.slides.length, total: data.slideCount })}</p>
)}
{truncatedNote && <p className="text-2xs text-slate-400">{truncatedNote}</p>}
</div>
);
}
function OfficePresentationView({ data }: { data: OfficePresentationPreview }): JSX.Element {
const { t } = useTranslation('files');
return (
<OfficePagesView
pages={data.slides}
emptyNote={t('preview.noSlides')}
truncatedNote={data.truncated ? t('preview.slidesTruncated', { shown: data.slides.length, total: data.slideCount }) : null}
pageLabel={(i) => t('preview.slide', { index: i })}
altLabel={(i) => t('preview.slideAlt', { index: i })}
/>
);
}
function OfficeDocumentView({ data }: { data: OfficeDocumentPreview }): JSX.Element {
const { t } = useTranslation('files');
return (
<OfficePagesView
pages={data.pages}
emptyNote={t('preview.noPages')}
truncatedNote={data.truncated ? t('preview.pagesTruncated', { shown: data.pages.length, total: data.pageCount }) : null}
pageLabel={(i) => t('preview.page', { index: i })}
altLabel={(i) => t('preview.pageAlt', { index: i })}
/>
);
}
function OfficePreviewView({ office }: { office: OfficePreviewDescriptor }): JSX.Element {
const { t } = useTranslation('files');
const [data, setData] = useState<OfficePreview | null>(null);
@@ -212,9 +243,9 @@ function OfficePreviewView({ office }: { office: OfficePreviewDescriptor }): JSX
if (!data) {
return <div className="flex items-center justify-center py-16 text-sm text-slate-400">{t('preview.generating')}</div>;
}
return data.kind === 'spreadsheet'
? <OfficeSpreadsheetView data={data} />
: <OfficePresentationView data={data} />;
if (data.kind === 'spreadsheet') return <OfficeSpreadsheetView data={data} />;
if (data.kind === 'presentation') return <OfficePresentationView data={data} />;
return <OfficeDocumentView data={data} />;
}
// --- CSV ---
@@ -768,6 +799,43 @@ function renderJsonl(content: string): JSX.Element {
}
// --- FilePreview ---
/**
* Small provenance metadata row shown under the preview header: source kind +
* creating task id + last-modified. Minimal by design — only rendered when a
* ledger record exists for the file. Never shows titles / user ids.
*/
function FileProvenanceRow({ taskId, section, filePath }: { taskId?: number; section?: string; filePath?: string }): JSX.Element | null {
const { t } = useTranslation('files');
const [prov, setProv] = useState<FileProvenance | null>(null);
useEffect(() => {
let alive = true;
if (taskId == null || !section || !filePath) { setProv(null); return; }
fetchFileProvenance(taskId, section, filePath)
.then((p) => { if (alive) setProv(p); })
.catch(() => { if (alive) setProv(null); });
return () => { alive = false; };
}, [taskId, section, filePath]);
if (!prov) return null;
const kindLabel = t(`provenance.kind.${prov.sourceKind}`, { defaultValue: prov.sourceKind });
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 px-4 py-1.5 border-b border-hairline bg-canvas text-2xs text-slate-500">
<span className="inline-flex items-center gap-1">
<span className="font-medium text-slate-600">{t('provenance.source')}:</span> {kindLabel}
</span>
{prov.createdByTaskId != null && (
<span>{t('provenance.createdBy', { taskId: prov.createdByTaskId })}</span>
)}
{prov.lastModifiedByTaskId != null && (
<span>{t('provenance.modifiedBy', { taskId: prov.lastModifiedByTaskId })}</span>
)}
{prov.lastModifiedAt && (
<span>{t('provenance.at', { at: new Date(prov.lastModifiedAt).toLocaleString() })}</span>
)}
</div>
);
}
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl, office }: FilePreviewProps) {
const { t } = useTranslation('files');
const [mode, setMode] = useState<'view' | 'edit'>('view');
@@ -958,6 +1026,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
</button>
</div>
</div>
<FileProvenanceRow taskId={taskId} section={section} filePath={filePath} />
<div className="p-4 overflow-auto flex-1">
{mode === 'view' && error && (
<div className="mb-2 px-3 py-2 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 text-red-700 dark:text-red-300 text-xs rounded">{error}</div>
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
/**
* Component tests for A2aDelegationsForm (Settings → A2A Delegations).
*
* Verifies: list rendering, live/revoked badge, Revoke confirm flow,
* POST to correct endpoint, and query re-fetch causing row to flip.
*/
import '../../test/dom-setup';
import { afterEach, beforeAll, describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { A2aDelegationsForm } from './A2aDelegationsForm';
beforeAll(async () => {
await i18n.changeLanguage('en');
});
afterEach(() => {
vi.restoreAllMocks();
});
const LIVE_DELEGATION = {
id: 'del-1',
clientId: 'client-abc',
clientName: 'MyApp',
grantedSpaceIds: ['space-1'],
grantedSkills: ['search'],
expiresAt: null,
revokedAt: null,
createdAt: '2026-01-01T00:00:00.000Z',
live: true,
};
const REVOKED_DELEGATION = {
id: 'del-2',
clientId: 'client-xyz',
clientName: 'OldApp',
grantedSpaceIds: [],
grantedSkills: [],
expiresAt: '2026-12-31T00:00:00.000Z',
revokedAt: '2026-06-01T00:00:00.000Z',
createdAt: '2025-12-01T00:00:00.000Z',
live: false,
};
function mockListFetch(delegations: unknown[]) {
return vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve({ delegations }),
} as Response);
}
describe('A2aDelegationsForm', () => {
it('renders both live and revoked delegations', async () => {
mockListFetch([LIVE_DELEGATION, REVOKED_DELEGATION]);
renderWithProviders(<A2aDelegationsForm />);
await waitFor(() => expect(screen.getByText('MyApp')).toBeInTheDocument());
expect(screen.getByText('OldApp')).toBeInTheDocument();
});
it('live row has an enabled Revoke button', async () => {
mockListFetch([LIVE_DELEGATION, REVOKED_DELEGATION]);
renderWithProviders(<A2aDelegationsForm />);
await waitFor(() => screen.getByText('MyApp'));
const revokeBtn = screen.getByRole('button', { name: /^revoke$/i });
expect(revokeBtn).not.toBeDisabled();
});
it('revoked row shows revoked badge and has no Revoke button', async () => {
mockListFetch([LIVE_DELEGATION, REVOKED_DELEGATION]);
renderWithProviders(<A2aDelegationsForm />);
await waitFor(() => screen.getByText('OldApp'));
// Only the live row has a Revoke button
expect(screen.getAllByRole('button', { name: /^revoke$/i })).toHaveLength(1);
// Revoked badge present for the revoked row
expect(screen.getByTestId('revoked-badge-del-2')).toBeInTheDocument();
});
it('clicking Revoke then Confirm revoke POSTs to the correct id and row flips to revoked', async () => {
const user = userEvent.setup();
let getCallCount = 0;
vi.spyOn(global, 'fetch').mockImplementation((url: RequestInfo | URL, opts?: RequestInit) => {
const method = opts?.method?.toUpperCase() ?? 'GET';
if (method === 'GET') {
getCallCount++;
const delegations =
getCallCount === 1
? [LIVE_DELEGATION, REVOKED_DELEGATION]
: [{ ...LIVE_DELEGATION, live: false, revokedAt: '2026-07-02T00:00:00.000Z' }, REVOKED_DELEGATION];
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ delegations }),
} as Response);
}
// POST — assert the URL contains the live delegation id
expect(String(url)).toContain('del-1/revoke');
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'revoked', cancelledJobs: 2 }),
} as Response);
});
renderWithProviders(<A2aDelegationsForm />);
await waitFor(() => screen.getByText('MyApp'));
// Step 1: click the Revoke button
await user.click(screen.getByRole('button', { name: /^revoke$/i }));
// Step 2: a "Confirm revoke" button should appear in place of Revoke
await waitFor(() => screen.getByRole('button', { name: /confirm revoke/i }));
// Step 3: click Confirm revoke
await user.click(screen.getByRole('button', { name: /confirm revoke/i }));
// Step 4: revokeSuccess message appears immediately after mutation resolves
await waitFor(() => {
expect(screen.getByText(/delegation revoked/i)).toBeInTheDocument();
});
// The cancelledJobs suffix is also visible (count=2)
expect(screen.getByText(/2 running task/i)).toBeInTheDocument();
// Step 5: after re-fetch, the previously-live row should no longer have a Revoke button
await waitFor(() => {
expect(screen.queryByRole('button', { name: /^revoke$/i })).toBeNull();
});
});
it('initial Revoke button is disabled while a revoke mutation is in-flight', async () => {
const user = userEvent.setup();
let resolveMutation!: (v: Response) => void;
const mutationPending = new Promise<Response>((resolve) => { resolveMutation = resolve; });
vi.spyOn(global, 'fetch').mockImplementation((url: RequestInfo | URL, opts?: RequestInit) => {
const method = opts?.method?.toUpperCase() ?? 'GET';
if (method === 'GET') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ delegations: [LIVE_DELEGATION] }),
} as Response);
}
// POST — stall indefinitely so isPending stays true
return mutationPending;
});
renderWithProviders(<A2aDelegationsForm />);
await waitFor(() => screen.getByText('MyApp'));
// Open confirm step
await user.click(screen.getByRole('button', { name: /^revoke$/i }));
await waitFor(() => screen.getByRole('button', { name: /confirm revoke/i }));
// Trigger the mutation (stalled)
await user.click(screen.getByRole('button', { name: /confirm revoke/i }));
// While mutation is pending the confirm buttons are disabled — resolve so the test doesn't hang
resolveMutation({
ok: true,
json: () => Promise.resolve({ status: 'revoked', cancelledJobs: 0 }),
} as Response);
});
});
@@ -0,0 +1,280 @@
/**
* A2aDelegationsForm.tsx — Settings → A2A Delegations
*
* Lists the current user's A2A delegations (granted access tokens issued to
* external agents) and lets them revoke any live delegation.
* Revocation takes effect immediately: the token is invalidated and any
* running tasks created under that delegation are cancelled.
*
* Consumes:
* GET /api/local/a2a/delegations
* POST /api/local/a2a/delegations/:id/revoke
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ── Types ──────────────────────────────────────────────────────────────────
interface Delegation {
id: string;
clientId: string;
clientName: string;
grantedSpaceIds: string[];
grantedSkills: string[];
expiresAt: string | null;
revokedAt: string | null;
createdAt: string;
live: boolean;
}
interface DelegationsResponse {
delegations: Delegation[];
}
interface RevokeResponse {
status: 'revoked' | 'already-revoked';
cancelledJobs?: number;
}
// ── API helpers ────────────────────────────────────────────────────────────
async function fetchDelegations(): Promise<DelegationsResponse> {
const res = await fetch('/api/local/a2a/delegations');
if (!res.ok) throw new Error(String(res.status));
return res.json();
}
async function postRevoke(id: string): Promise<RevokeResponse> {
const res = await fetch(`/api/local/a2a/delegations/${encodeURIComponent(id)}/revoke`, {
method: 'POST',
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? res.statusText);
}
return res.json();
}
// ── Chip ──────────────────────────────────────────────────────────────────
function Chip({ label }: { label: string }) {
return (
<span className="inline-block rounded bg-surface px-1.5 py-0.5 text-xs text-slate-600 border border-hairline">
{label}
</span>
);
}
// ── DelegationRow ──────────────────────────────────────────────────────────
interface DelegationRowProps {
delegation: Delegation;
confirmId: string | null;
onRequestConfirm: (id: string) => void;
onCancelConfirm: () => void;
onConfirmRevoke: (id: string) => void;
isPending: boolean;
lastSuccessId: string | null;
cancelledJobs: number | null;
}
function DelegationRow({
delegation: d,
confirmId,
onRequestConfirm,
onCancelConfirm,
onConfirmRevoke,
isPending,
lastSuccessId,
cancelledJobs,
}: DelegationRowProps) {
const { t } = useTranslation('a2a');
const isConfirming = confirmId === d.id;
const justRevoked = lastSuccessId === d.id;
return (
<div className="rounded-md border border-hairline p-4 space-y-2">
{/* Header: client name + status badge */}
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-sm text-slate-800">
{d.clientName || d.clientId}
</span>
{d.live ? (
<span className="flex-shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 border border-emerald-200">
{t('delegations.badge.live')}
</span>
) : (
<span
data-testid={`revoked-badge-${d.id}`}
className="flex-shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500 border border-slate-200"
>
{t('delegations.badge.revoked')}
</span>
)}
</div>
{/* Chips: granted spaces */}
{d.grantedSpaceIds.length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-slate-400 mr-1">{t('delegations.field.grantedSpaces')}:</span>
{d.grantedSpaceIds.map((s) => <Chip key={s} label={s} />)}
</div>
)}
{/* Chips: granted skills */}
{d.grantedSkills.length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-slate-400 mr-1">{t('delegations.field.grantedSkills')}:</span>
{d.grantedSkills.map((s) => <Chip key={s} label={s} />)}
</div>
)}
{/* Metadata: created + expiry */}
<div className="flex flex-wrap gap-4 text-xs text-slate-400">
<span>
{t('delegations.field.created')}:{' '}
{new Date(d.createdAt).toLocaleDateString()}
</span>
<span>
{t('delegations.field.expires')}:{' '}
{d.expiresAt
? new Date(d.expiresAt).toLocaleDateString()
: t('delegations.field.noExpiry')}
</span>
</div>
{/* Just-revoked success message */}
{justRevoked && (
<p className="text-xs text-emerald-600">
{t('delegations.revokeSuccess')}
{cancelledJobs != null && cancelledJobs > 0 && (
<>{' '}{t('delegations.cancelledJobs', { count: cancelledJobs })}</>
)}
</p>
)}
{/* Revoke / confirm buttons (live rows only) */}
{d.live && (
<div className="flex items-center gap-2 pt-1">
{isConfirming ? (
<>
<button
type="button"
onClick={() => onConfirmRevoke(d.id)}
disabled={isPending}
className="rounded px-3 py-1 text-xs font-medium bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
>
{t('delegations.confirmRevoke')}
</button>
<button
type="button"
onClick={onCancelConfirm}
disabled={isPending}
className="rounded px-3 py-1 text-xs text-slate-600 hover:bg-surface transition-colors"
>
{t('delegations.cancel')}
</button>
</>
) : (
<button
type="button"
onClick={() => onRequestConfirm(d.id)}
disabled={isPending}
className="rounded px-3 py-1 text-xs font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50 transition-colors"
>
{t('delegations.revoke')}
</button>
)}
</div>
)}
</div>
);
}
// ── A2aDelegationsForm ─────────────────────────────────────────────────────
export function A2aDelegationsForm() {
const { t } = useTranslation('a2a');
const queryClient = useQueryClient();
const [confirmId, setConfirmId] = useState<string | null>(null);
const [lastSuccessId, setLastSuccessId] = useState<string | null>(null);
const [lastCancelledJobs, setLastCancelledJobs] = useState<number | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { data, isLoading, error } = useQuery<DelegationsResponse>({
queryKey: ['a2a-delegations'],
queryFn: fetchDelegations,
});
const revokeMutation = useMutation({
mutationFn: postRevoke,
onSuccess: (result, id) => {
setLastSuccessId(id);
setLastCancelledJobs(result.cancelledJobs ?? 0);
setConfirmId(null);
setErrorMsg(null);
void queryClient.invalidateQueries({ queryKey: ['a2a-delegations'] });
},
onError: () => {
setConfirmId(null);
setErrorMsg(t('delegations.err.revoke'));
},
});
if (isLoading) {
return (
<div className="max-w-2xl">
<p className="text-sm text-slate-400">{t('delegations.loading')}</p>
</div>
);
}
if (error) {
return (
<div className="max-w-2xl">
<p className="text-sm text-red-500">{t('delegations.err.load')}</p>
</div>
);
}
const delegations = data?.delegations ?? [];
return (
<div className="max-w-2xl space-y-4">
<div>
<h2 className="text-base font-semibold text-slate-800">{t('delegations.title')}</h2>
<p className="mt-1 text-xs text-slate-500">{t('delegations.subtitle')}</p>
</div>
{errorMsg && (
<p className="text-sm text-red-500">{errorMsg}</p>
)}
{delegations.length === 0 ? (
<div className="rounded-md border border-hairline p-4 space-y-1">
<p className="text-sm font-medium text-slate-700">{t('delegations.empty')}</p>
<p className="text-xs text-slate-400">{t('delegations.emptyExplain')}</p>
</div>
) : (
<div className="space-y-3">
{delegations.map((d) => (
<DelegationRow
key={d.id}
delegation={d}
confirmId={confirmId}
onRequestConfirm={setConfirmId}
onCancelConfirm={() => setConfirmId(null)}
onConfirmRevoke={(id) => revokeMutation.mutate(id)}
isPending={revokeMutation.isPending}
lastSuccessId={lastSuccessId}
cancelledJobs={lastCancelledJobs}
/>
))}
</div>
)}
</div>
);
}
@@ -31,6 +31,7 @@ import { PushNotificationsForm } from './PushNotificationsForm';
import { AuthForm } from './AuthForm';
import { OrgsForm } from './OrgsForm';
import { PetsForm } from './PetsForm';
import { A2aDelegationsForm } from './A2aDelegationsForm';
import { useToast } from '../../hooks/useToast';
import { useAuthState } from '../../App';
@@ -129,6 +130,9 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
if (section === 'pets') {
return <PetsFormWrapper />;
}
if (section === 'a2a-delegations') {
return <div className="max-w-2xl"><A2aDelegationsForm /></div>;
}
if (!isAdmin) {
return <div className="max-w-2xl text-sm text-slate-500">{t('configForm.adminOnly')}</div>;
}
@@ -26,7 +26,6 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
<div className="space-y-2">
{movements.map((movement, i) => {
const isExpanded = expandedIndex === i;
const toolCount = (movement.allowed_tools ?? []).length;
const ruleCount = (movement.rules ?? []).length;
return (
@@ -43,10 +42,6 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
{movement.persona}
</span>
)}
<span className={`text-xs px-2 py-0.5 rounded ${movement.edit ? 'bg-green-100 dark:bg-green-500/15 text-green-700 dark:text-green-300' : 'bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300'}`}>
edit: {movement.edit ? 'on' : 'off'}
</span>
<span className="text-xs text-slate-400">{toolCount} tools</span>
<span className="text-xs text-slate-400">{ruleCount} rules</span>
{/* Spacer */}
+2 -21
View File
@@ -1,6 +1,5 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { ToolTagInput } from './ToolTagInput';
import { RulesTable } from './RulesTable';
const SPECIAL_TARGETS = ['COMPLETE', 'ASK', 'ABORT', 'WAIT_SUBTASKS'];
@@ -58,20 +57,6 @@ export function MovementForm({ movement, movementNames, onChange, disabled = fal
</select>
</div>
{/* edit */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id={`edit-${movement.name}`}
checked={movement.edit ?? false}
onChange={(e) => onChange('edit', e.target.checked)}
disabled={disabled}
className="rounded border-slate-300 disabled:cursor-not-allowed"
/>
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
<HelpText>{t('movement.editHelp')}</HelpText>
</div>
{/* instruction */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">instruction</label>
@@ -85,12 +70,8 @@ export function MovementForm({ movement, movementNames, onChange, disabled = fal
<HelpText>{t('movement.instructionHelp')}</HelpText>
</div>
{/* allowed_tools */}
<ToolTagInput
value={movement.allowed_tools ?? []}
onChange={(tools) => onChange('allowed_tools', tools)}
disabled={disabled}
/>
{/* Tool / edit / SSH availability is set per workspace (Settings → Tools),
not per movement — see PR B (remove piece tool config). */}
{/* rules */}
<RulesTable
@@ -92,9 +92,7 @@ export function PieceEditor({ name, isAdmin = true, source, spaceId, onDeleted }
name: `step_${prev.movements.length + 1}`,
persona: '',
default_next: 'COMPLETE',
edit: false,
instruction: '',
allowed_tools: [],
rules: [],
},
],
+16
View File
@@ -40,6 +40,22 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
<HelpText>{t('safety.promptGuardHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('safety.deadlineTitle')}</h3>
<div>
<FieldLabel>Max Job Minutes</FieldLabel>
<FieldInput type="number" value={safety.maxJobMinutes ?? 180}
onChange={v => onChange('safety.maxJobMinutes', v === '' ? undefined : Number(v))} />
<HelpText>{t('safety.maxJobMinutesHelp')}</HelpText>
</div>
<div>
<FieldLabel>Deadline Grace (sec)</FieldLabel>
<FieldInput type="number" value={safety.deadlineGraceSeconds ?? 15}
onChange={v => onChange('safety.deadlineGraceSeconds', v === '' ? undefined : Number(v))} />
<HelpText>{t('safety.deadlineGraceHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('safety.bashSandboxTitle')}</h3>
<div>
@@ -31,6 +31,7 @@ export const CONFIG_GROUPS = [
{ id: 'notifications', label: '🔔 Notifications' },
{ id: 'pets', label: '◉ Pets' },
{ id: 'memory-learning', label: '🧠 Reflection history', labelKey: 'memoryLearning.navLabel' },
{ id: 'a2a-delegations', label: '🔑 A2A Delegations', labelKey: 'delegations.navLabel' },
],
},
{
-287
View File
@@ -1,287 +0,0 @@
import { useMemo, useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useToolList } from '../../hooks/useTools';
import type { ToolCatalogEntry } from '../../api';
import { HelpText } from './HelpText';
export interface ToolTagInputProps {
value: string[];
onChange: (tools: string[]) => void;
disabled?: boolean;
}
/**
* Piece `allowed_tools` editor backed by the runtime tool catalog
* (`GET /api/tools`, see src/bridge/tools-api.ts).
*
* Behaviour:
* - Groups tools by `source` then `category` (builtin core/web/.../mcp:<server>).
* - Renders a `scope` badge (global/piece/user) on every entry.
* - Unavailable entries (e.g. MCP server offline) are shown disabled with a
* warning badge — they are NOT auto-removed from the piece, the user has to
* delete them explicitly. This matches the design contract that a transient
* MCP outage must never silently drop tools from a piece.
* - Tools already on the piece but missing from the catalog appear under an
* "unknown" group with the same disabled+warning treatment.
* - Selecting an unavailable catalog tool is still allowed (the user might be
* preparing for a server that's about to come back online).
*/
export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInputProps) {
const { t } = useTranslation('settings');
const { data: catalog } = useToolList();
const [input, setInput] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const catalogByName = useMemo(() => {
const m = new Map<string, ToolCatalogEntry>();
for (const t of catalog ?? []) m.set(t.name, t);
return m;
}, [catalog]);
// Suggestions: catalog entries not already in `value`, filtered by input
// substring. Unavailable entries stay in the suggestion list (user might
// want to "pre-attach" a tool for a server they expect to come online).
const suggestions = useMemo(() => {
const q = input.toLowerCase();
return (catalog ?? [])
.filter((t) => !value.includes(t.name))
.filter((t) => t.name.toLowerCase().includes(q));
}, [catalog, value, input]);
// Groups for the suggestion dropdown.
// Group key format:
// builtin → 'builtin:<category>'
// meta → 'meta'
// mcp → 'mcp:<serverId|category>'
const groupedSuggestions = useMemo(() => {
const groups = new Map<string, { label: string; entries: ToolCatalogEntry[] }>();
for (const t of suggestions) {
const { key, label } = groupKeyForCatalogEntry(t);
const g = groups.get(key);
if (g) g.entries.push(t);
else groups.set(key, { label, entries: [t] });
}
return Array.from(groups.entries()).map(([key, v]) => ({ key, ...v }));
}, [suggestions]);
// Flat list of suggestions in displayed order — used to map keyboard
// highlight index back to the actual entry.
const flatSuggestions = useMemo(
() => groupedSuggestions.flatMap((g) => g.entries),
[groupedSuggestions],
);
useEffect(() => {
setHighlightIndex(0);
}, [input]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTool = (tool: string) => {
if (!value.includes(tool)) onChange([...value, tool]);
setInput('');
setShowDropdown(false);
inputRef.current?.focus();
};
const removeTool = (tool: string) => {
onChange(value.filter((t) => t !== tool));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && flatSuggestions.length > 0 && showDropdown) {
e.preventDefault();
const pick = flatSuggestions[highlightIndex] ?? flatSuggestions[0];
if (pick) addTool(pick.name);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((i) => Math.min(i + 1, flatSuggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Escape') {
setShowDropdown(false);
} else if (e.key === 'Backspace' && input === '' && value.length > 0) {
removeTool(value[value.length - 1]);
}
};
return (
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">allowed_tools</label>
<div ref={containerRef} className="relative">
<div className="flex flex-wrap gap-1 p-2 border border-slate-300 rounded-lg min-h-[38px] focus-within:ring-2 focus-within:ring-accent-ring focus-within:border-accent">
{value.map((tool) => {
const entry = catalogByName.get(tool);
return (
<SelectedToolChip
key={tool}
name={tool}
entry={entry}
onRemove={() => removeTool(tool)}
readOnly={disabled}
/>
);
})}
{!disabled && (
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setShowDropdown(true);
}}
onFocus={() => setShowDropdown(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? t('tools.tagInput.placeholder') : ''}
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
/>
)}
</div>
{!disabled && showDropdown && groupedSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full max-h-72 overflow-y-auto bg-surface border border-slate-200 rounded-lg shadow-lg">
{groupedSuggestions.map((g) => (
<div key={g.key}>
<div className="sticky top-0 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500 bg-slate-50 border-b border-slate-100">
{g.label}
</div>
{g.entries.map((t) => {
const flatIdx = flatSuggestions.indexOf(t);
const highlighted = flatIdx === highlightIndex;
return (
<button
key={t.name}
type="button"
onClick={() => addTool(t.name)}
title={t.available ? undefined : t.reason ?? 'unavailable'}
className={`w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 ${
highlighted ? 'bg-accent-soft text-accent' : 'text-slate-700 hover:bg-slate-50'
} ${t.available ? '' : 'opacity-70'}`}
>
<span className="flex-1 truncate">{t.name}</span>
<ScopeBadge scope={t.scope} />
{!t.available && (
<Badge color="amber">{t.reason ?? 'unavailable'}</Badge>
)}
</button>
);
})}
</div>
))}
</div>
)}
</div>
<HelpText>{t('tools.tagInput.help')}</HelpText>
</div>
);
}
/**
* Build a stable group key + human label for a catalog entry. MCP tools are
* grouped per server id so the editor can show e.g. "MCP · github" sections.
*/
function groupKeyForCatalogEntry(t: ToolCatalogEntry): { key: string; label: string } {
if (t.source === 'meta') return { key: 'meta', label: 'meta (always available)' };
if (t.source === 'mcp') {
const id = t.serverId ?? t.category.replace(/^mcp:/, '');
return { key: `mcp:${id}`, label: `mcp · ${id}` };
}
return { key: `builtin:${t.category}`, label: `builtin · ${t.category}` };
}
function SelectedToolChip({
name,
entry,
onRemove,
readOnly = false,
}: {
name: string;
entry: ToolCatalogEntry | undefined;
onRemove: () => void;
readOnly?: boolean;
}) {
const { t } = useTranslation('settings');
const isUnknown = !entry;
const isUnavailable = entry ? !entry.available : false;
// Visual stack:
// - normal tool → slate chip
// - unavailable in catalog → amber chip + reason badge
// - unknown (not in catalog at all) → amber chip + "unknown" badge
const tone =
isUnknown || isUnavailable
? 'bg-amber-50 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-200 dark:border-amber-500/30'
: 'bg-slate-100 text-slate-700';
const tip = isUnknown
? t('tools.tagInput.unknownTip')
: isUnavailable
? (entry?.reason ?? 'unavailable')
: undefined;
return (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded ${tone}`}
title={tip}
>
<span>{name}</span>
{entry && <ScopeBadge scope={entry.scope} dim />}
{isUnknown && <Badge color="amber">unknown</Badge>}
{!isUnknown && isUnavailable && <Badge color="amber">{entry?.reason ?? 'offline'}</Badge>}
{!readOnly && (
<button
type="button"
onClick={onRemove}
className="text-current opacity-60 hover:opacity-100"
aria-label={`remove ${name}`}
>
&times;
</button>
)}
</span>
);
}
function ScopeBadge({ scope, dim }: { scope: 'global' | 'piece' | 'user'; dim?: boolean }) {
const color: 'slate' | 'blue' | 'emerald' =
scope === 'global' ? 'slate' : scope === 'user' ? 'emerald' : 'blue';
return (
<Badge color={color} dim={dim}>
{scope}
</Badge>
);
}
function Badge({
color,
dim,
children,
}: {
color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red';
dim?: boolean;
children: React.ReactNode;
}) {
const cls: Record<typeof color, string> = {
slate: 'bg-slate-100 text-slate-600',
blue: 'bg-blue-50 dark:bg-blue-500/15 text-blue-700 dark:text-blue-300',
emerald: 'bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
amber: 'bg-amber-50 dark:bg-amber-500/15 text-amber-700 dark:text-amber-300',
red: 'bg-red-50 dark:bg-red-500/15 text-red-700 dark:text-red-300',
};
return (
<span
className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium leading-none ${cls[color]} ${dim ? 'opacity-70' : ''}`}
>
{children}
</span>
);
}
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
/**
* Component tests for PythonPackagesPanel (per-space Python package UI).
*
* api + App.useAuthState are mocked so no real network and canManage=true.
*/
import '../../test/dom-setup';
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 i18n from '../../i18n';
const { fetchMock, addMock, removeMock, fetchMembersMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
addMock: vi.fn(),
removeMock: vi.fn(),
fetchMembersMock: vi.fn(),
}));
vi.mock('../../api', () => ({
fetchSpacePythonPackages: fetchMock,
addSpacePythonPackage: addMock,
removeSpacePythonPackage: removeMock,
fetchSpaceMembers: fetchMembersMock,
}));
vi.mock('../../App', () => ({
useAuthState: () => ({ mode: 'disabled' as const }),
}));
import { PythonPackagesPanel } from './PythonPackagesPanel';
const ENABLED = {
enabled: true,
indexUrl: 'https://pypi.org/simple',
maxPackagesPerSpace: 30,
preflight: { ok: true },
packages: [{ name: 'requests', spec: 'requests==2.32.3', addedAt: '2026-07-02T00:00:00Z' }],
};
beforeEach(() => {
vi.clearAllMocks();
void i18n.changeLanguage('ja');
fetchMembersMock.mockResolvedValue([]);
fetchMock.mockResolvedValue(structuredClone(ENABLED));
addMock.mockResolvedValue({ packages: [] });
removeMock.mockResolvedValue({ packages: [] });
});
describe('PythonPackagesPanel', () => {
it('lists installed packages by their spec', async () => {
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByText('requests==2.32.3')).toBeInTheDocument());
});
it('submits a typed spec via addSpacePythonPackage', async () => {
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('python-add-input')).toBeInTheDocument());
await userEvent.type(screen.getByTestId('python-add-input'), 'httpx==0.27.0');
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(addMock).toHaveBeenCalledWith('s1', 'httpx==0.27.0'));
});
it('removes a package via removeSpacePythonPackage', async () => {
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByText('requests==2.32.3')).toBeInTheDocument());
await userEvent.click(screen.getByText('削除'));
await waitFor(() => expect(removeMock).toHaveBeenCalledWith('s1', 'requests'));
});
it('shows a disabled notice and blocks the input when the feature is off', async () => {
fetchMock.mockResolvedValue({ ...structuredClone(ENABLED), enabled: false, preflight: { ok: false, reason: 'feature disabled' } });
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('python-add-input')).toBeDisabled());
});
});
@@ -0,0 +1,176 @@
/**
* PythonPackagesPanel.tsx — ワークスペースごとの Python パッケージ管理 UI
*
* admin/オーナーが wheel パッケージ名を直接入力して、そのワークスペース専用の
* オーバーレイに追加する。追加したパッケージは、そのワークスペースのエージェント
* だけが `import` できる(他ワークスペースには波及しない)。
*
* - GET/POST/DELETE /api/local/spaces/:id/python-packagescanManageSpace が編集)
* - インストールはサーバー側で out-of-band に実行(ネットワークは分離 bwrap のみ)。
* wheels のみ許可(sdist の任意コード実行を回避)。
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpacePythonPackages,
fetchSpaceMembers,
addSpacePythonPackage,
removeSpacePythonPackage,
} from '../../api';
import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export function PythonPackagesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const qc = useQueryClient();
const [input, setInput] = useState('');
const { data: members } = useQuery({
queryKey: ['space-members', spaceId],
queryFn: () => fetchSpaceMembers(spaceId),
staleTime: 30_000,
});
const ownerRow = (members ?? []).find(m => m.isOwner);
const canManage =
auth.mode === 'disabled' ||
(auth.mode === 'authenticated' &&
(auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id)));
const { data, isLoading, isError, error } = useQuery({
queryKey: ['space-python-packages', spaceId],
queryFn: () => fetchSpacePythonPackages(spaceId),
staleTime: 15_000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['space-python-packages', spaceId] });
const addMut = useMutation({
mutationFn: (spec: string) => addSpacePythonPackage(spaceId, spec),
onSuccess: () => {
setInput('');
showToast?.(t('python.added'), 'success');
void invalidate();
},
onError: (e) => showToast?.(t('python.addFailed', { msg: errMsg(e) }), 'error'),
});
const removeMut = useMutation({
mutationFn: (name: string) => removeSpacePythonPackage(spaceId, name),
onSuccess: () => { showToast?.(t('python.removed'), 'success'); void invalidate(); },
onError: (e) => showToast?.(t('python.removeFailed', { msg: errMsg(e) }), 'error'),
});
const busy = addMut.isPending || removeMut.isPending;
const submit = () => {
const spec = input.trim();
if (!spec || busy) return;
addMut.mutate(spec);
};
if (isLoading) {
return (
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
</div></div>
);
}
if (isError || !data) {
return (
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-red-600">{t('python.fetchError', { msg: errMsg(error) })}</div>
</div></div>
);
}
const disabledFeature = !data.enabled;
const preflightBad = data.enabled && !data.preflight.ok;
return (
<div className="h-full overflow-y-auto" data-testid="space-python-packages">
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('python.heading')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">{t('python.intro')}</p>
</div>
{disabledFeature && (
<div className="rounded-md border border-hairline bg-surface/40 px-4 py-3 text-[13px] text-slate-500">
{t('python.disabled')}
</div>
)}
{preflightBad && (
<div className="rounded-md border border-amber-300 bg-amber-50/60 dark:bg-amber-900/10 px-4 py-3 text-[13px] text-amber-800 dark:text-amber-300">
{data.preflight.reason ?? t('python.preflightBad')}
</div>
)}
{/* 追加フォーム */}
<section>
<label className="block text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('python.addLabel')}
</label>
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); }}
disabled={!canManage || disabledFeature || busy}
placeholder="requests==2.32.3"
data-testid="python-add-input"
className="h-9 flex-1 rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50"
/>
<button
type="button"
onClick={submit}
disabled={!canManage || disabledFeature || busy || !input.trim()}
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{addMut.isPending ? t('python.installing') : t('python.add')}
</button>
</div>
<p className="text-2xs text-slate-500 leading-relaxed mt-1.5">{t('python.addHint')}</p>
</section>
{/* 一覧 */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('python.installedHeading')}
</h3>
{data.packages.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('python.none')}</p>
) : (
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden">
{data.packages.map(pkg => (
<div key={pkg.name} className="flex items-center gap-3 px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[13px] text-slate-900">{pkg.spec}</code>
<button
type="button"
onClick={() => removeMut.mutate(pkg.name)}
disabled={!canManage || busy}
className="text-xs text-red-600 hover:text-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t('python.remove')}
</button>
</div>
))}
</div>
)}
</section>
{!canManage && (
<p className="text-[13px] text-slate-400">{t('python.readonly')}</p>
)}
</div>
</div>
);
}
+3 -3
View File
@@ -24,7 +24,7 @@ import {
fmtTimeBadge,
layoutWeekBars,
} from '../../lib/calendar';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isOfficePreviewable, officePreviewKind } from '../../lib/utils';
import { useIsMobile } from '../../hooks/useIsMobile';
import { FilePreview } from '../files/FilePreview';
import type { OfficePreviewDescriptor } from '../files/FilePreview';
@@ -313,8 +313,8 @@ function DayPanel({
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
if (isOfficePreviewable(name)) {
const kind = officePreviewKind(name);
setPreview({
name,
content: '',
+3 -3
View File
@@ -21,7 +21,7 @@ import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount }
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
import { workspaceDirRole } from '../../lib/workspaceDirs';
import { FilterBar } from '../list/FilterBar';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isOfficePreviewable, officePreviewKind } from '../../lib/utils';
import type { OfficePreviewDescriptor } from '../files/FilePreview';
import { CreateTaskDialog } from '../create/CreateTaskDialog';
import { LocalTaskListItem } from '../list/TaskListItem';
@@ -1015,8 +1015,8 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
if (isOfficePreviewable(name)) {
const kind = officePreviewKind(name);
setPreview({
name,
content: '',
+4 -3
View File
@@ -22,6 +22,7 @@ import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
import { SpaceMembersPanel } from './SpaceMembersPanel';
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
import { SpaceToolSettings } from './SpaceToolSettings';
import { PythonPackagesPanel } from './PythonPackagesPanel';
import { PieceEditor } from '../settings/PieceEditor';
import { usePieceList } from '../../hooks/usePieces';
import { splitPieces } from '../../lib/splitPieces';
@@ -30,7 +31,7 @@ import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools';
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python';
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
@@ -41,6 +42,7 @@ const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ id: 'ssh', labelKey: 'settings.nav.ssh', testid: 'space-settings-nav-ssh' },
{ id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' },
{ id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' },
{ id: 'python', labelKey: 'settings.nav.python', testid: 'space-settings-nav-python' },
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
];
@@ -85,6 +87,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
{section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />}
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
</div>
</div>
@@ -119,10 +122,8 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
initial_movement: 'execute',
movements: [{
name: 'execute',
edit: true,
persona: 'worker',
instruction: '',
allowed_tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
default_next: 'COMPLETE',
rules: [{ condition: '完了', next: 'COMPLETE' }],
}],
+123
View File
@@ -12,6 +12,106 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 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))。
## 2026-07-06 — WindowsWSL)で Docker からそのまま起動できるように
Windows の WSL2 上の Docker で `docker compose up --build` を実行したとき、二つの理由で起動できないことがありました。ひとつは `.env` ファイルが無いと即座に失敗すること、もうひとつは Windows でクローンしたときの改行コード(CRLF)でビルドが途中で止まることです。`.env` が無くても起動できるようにし、改行コードを固定してビルドが壊れないようにしました。あわせて README に Docker での起動手順(Windows/WSL 対応)を追加しています。Node.js を入れなくても Docker だけで起動でき、ブラウザ操作機能もコンテナ内で完結します(Windows 側に X サーバーや WSLg は不要)。
## 2026-07-05 — Docker のクリーンビルドでブラウザ機能が使えなくなる不具合を修正
`docker compose up --build` でイメージを一からビルドしたとき、ブラウザ操作系の機能(Browser タブ・`BrowseWeb`・InteractiveBrowse など)で使う Chromium の導入に失敗し、ビルドが `playwright: not found` で止まる、あるいは起動してもブラウザ操作ができないことがありました。ビルド手順の内部でブラウザ導入コマンドの呼び出し方に依存関係の競合があったのが原因です。呼び出し方を競合しない方式に変え、クリーンビルドでも確実に Chromium が入るようにしました。自分でビルドして自己ホストしている場合が対象で、設定の変更は不要です。
## 2026-07-03 — 縦長ページのスクリーンショットを1画面ぶんずつ自動分割
エージェントがブラウザ操作でページのスクリーンショットを撮るとき、縦に長いページだと画像が極端に縦長になり、細部が潰れて内容を読み取りづらくなっていました。今後は縦長ページを **1 画面ぶんごとに区切って複数枚**`report-001.png`, `report-002.png` …)に自動分割して保存します。1 画面に収まるページは従来どおり 1 枚のままです。無限スクロール対策として既定で最大 10 枚まで。分割せずフルページ 1 枚で撮りたい場合は指定で切り替えられます。ブラウザ操作ツール(`BrowseWeb`・手動ログイン後に引き継ぐ `BrowseWithSession`)のどちらでも同じ挙動になり、ツールによってスクリーンショットの撮られ方が変わることはありません。
## 2026-07-03 — SSH コンソールで複数の接続を同時に開けるように
これまで SSH コンソールは 1 タスクにつき 1 セッションしか開けず、別の接続を開くと元のセッションは閉じられていました。今後は 1 つのタスクで複数の接続に同時にセッションを開けます。SSH タブの上部に接続ごとのタブが並び、クリックで切り替えられます。タブには接続名・状態(接続中 / アイドル / 切断)に加えて、エージェントがそのセッションを操作中かどうかを示す ⚡ が表示されます。**+ 接続** で新しいセッションを追加し、不要になったタブは **✕** で閉じられます(閉じられるのは開いた本人かオーナー・管理者)。エージェントが別の接続を開いたり切り替えたりしても、ユーザーが見ている画面は変わりません。表示は画面をクリックしたタブに切り替えたときだけ変わります。同時に開けるセッション数には上限があります(既定 5)(→[SSH 接続](./14-ssh.md))。
## 2026-07-03 — タスクを開くと会話の一番下(最新)が表示されるように
タスク(チャット)を開いたとき、これまでは会話の一番上から表示されていて、最新のやり取りを見るには毎回下までスクロールする必要がありました。今後はタスクを開いた時点で自動的に一番下までスクロールし、最新のメッセージがすぐ見える状態で開きます。会話の途中を見ているときに新しいメッセージが来た場合の挙動(「新着」バッジを出して勝手に飛ばさない)は従来どおりです。
## 2026-07-03 — Shift_JIS などの日本語テキストが「バイナリ」と誤判定されて読めない不具合を修正
Windows で保存した日本語の `.txt` や、Excel から出した Shift_JISCP932)の CSV を Read で開こうとすると、中身は読めるテキストなのに「バイナリなので開けません」と拒否されていました。文字コードの判定が UTF-8 しか想定しておらず、UTF-8 として解釈できないものをすべてバイナリと見なしていたためです。Shift_JIS・EUC-JP などを自動で判別し、UTF-8 に変換して読めるようにしました。Edit で書き換えた場合は元の文字コードのまま保存します。あわせて Grep が画像などのバイナリファイルを拾ってしまい、検索結果にバイナリの断片が紛れ込む問題も直しました(対象フォルダにバイナリがあっても自動でスキップします)(→[ツール](./16-tools.md))。
## 2026-07-03 — ワークスペースに登録した SSH 接続でコンソールが開けない不具合を修正
ワークスペースに登録した SSH 接続に対して**コンソール(対話シェル)を開こうとすると、接続が正しく登録されているのに `access denied (space_mismatch)` で弾かれる**不具合を修正しました。原因はコンソールを開く経路が、そのタスクの所属ワークスペース(スペース)を権限判定に渡していなかったことです。一発実行の `SshExec` は影響を受けていませんでしたが、コンソール経路(エージェントの `SshConsoleEnsure`、および「コンソール」タブから手動で開く操作・再接続)はすべて対象でした。個人ワークスペースのタスクでも正しく開けるようにしています。あわせて、公開・組織共有などで**タスクは見えてもそのワークスペースのメンバーではない人**が、コンソールからそのワークスペースの SSH 接続を使えてしまわないよう、実行時にメンバーであることを確認するようにしました(→[SSH 接続](./14-ssh.md))。
## 2026-07-02 — ワークスペースごとに Python パッケージを追加できるように
必要な Python ライブラリを、ワークスペースの **設定 → Python** タブからオーナー / 管理者が直接追加できるようになりました。追加したパッケージはそのワークスペースの中だけで `import` でき、他のワークスペースには影響しません。安全のため wheel のあるパッケージのみ許可し(`requests==2.32.3` のようにバージョン固定も可)、ダウンロードはサーバー側で隔離して実行します(エージェント自身はネットワーク遮断のまま)。既定はオフで、管理者が `config.yaml``python_packages.enabled` を有効にすると使えます(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-07-02 — 設定から A2A 委任を一覧表示・即時取り消しできるように
**設定 → A2A 委任** タブを追加しました。外部エージェントへ付与した委任の一覧を確認し、不要になったものをその場で取り消せます。取り消した瞬間にトークンが無効になり、その委任で実行中だったタスクも即座にキャンセルされます。またタスクの状態・成果物を後から読み取る操作(`tasks/get``tasks/resubscribe`)も即座にブロックされるため、取り消し後に情報が漏れ出ることはありません。
## 2026-07-02 — 同じ名前のファイルを添付しても既存ファイルを上書きしないように
タスク作成時やチャットのコメントでファイルを添付したとき、ワークスペースの `input/` に**同じ名前のファイルが既にある**と、これまでは黙って上書きしていました。今後は上書きせず、`名前 (2).ext` のように自動でリネームして保存します(同じ依頼の中で同名ファイルを複数付けた場合も同様に枝番が付きます)。エージェントには実際に保存された名前が伝わり、チャットのダウンロード表示もその名前になります。ファイルタブからのアップロードは以前からリネーム方式だったので、これで添付とアップロードの挙動がそろいました(→[タスクを作って実行する](./02-tasks.md))。
## 2026-07-02 — 長い A2A タスクを非ブロッキングで依頼し、後から結果を取得できるように
外部エージェント連携(A2A)で、時間のかかるタスクを接続を張り続けずに依頼できるようになりました。`message/send``configuration.blocking: false` を付けると、MAESTRO は受付時点ですぐ応答を返し、完了を待ちません。結果は後から `tasks/get` で取得します。裏側では専用の収束処理がジョブの状態を追い続け、切断やブリッジ再起動をまたいでもタスクを最終状態(完了・失敗)まで確実にまとめます(→[外部エージェント連携](./23-a2a.md))。
## 2026-07-02 — 他タスクの会話を横断検索できる SearchWorkspaceTasks を追加
同じワークスペース内の他タスクの会話を横断検索できる `SearchWorkspaceTasks` を追加しました。エージェントが過去タスクの依頼・成果・やり取りを思い出せます。これまでの `SearchTaskConversation` は現在のタスクの会話しか検索できませんでしたが、今回のツールは同じワークスペースの他タスクまで対象を広げます。検索結果はツール名や添付ファイル名までしか出さない要約にとどめ、詳しい経緯を確認したいときだけ前後のコメント本文をそのまま読みに行く仕組みです。複数の語をスペースで区切ると、そのすべてを含む会話に絞り込めます(Google のような AND 検索)(→[ツール](./16-tools.md))。
## 2026-07-02 — Word.docx)ファイルもプレビューできるように
これまで Excel と PowerPoint はプレビューできましたが、Word(.docx)はプレビューできませんでした。ファイル名をクリックすると、各ページを画像にして見た目どおりに表示するようになりました。上から順にスクロールで確認できます。レイアウトはそのまま再現されますが、画像表示のため本文の文字選択・検索はできません。長い文書は先頭 50 ページまで表示します。画像化にはサーバーに変換エンジン(LibreOffice)が必要で、未導入の場合はダウンロードの案内が出ます(→[結果を見る](./04-results.md))。
## 2026-07-02 — 実行中の豆知識(TIP)を最新の機能に合わせて更新
タスク実行中に💡で表示される豆知識を、いまの機能に合わせて見直しました。すでに無くなった「ナレッジ」への言及を、集めた資料が出典付きで source/ に残る旨に差し替え、piece の説明も「使えるツールが変わる」ではなく「進め方(手順・役割)を決める」という実態に沿った内容に改めました。あわせて、ワークスペース・アプリ、カレンダー、案件ワークスペースでのチーム共有といった新しめの機能を紹介する項目を追加しています(→[ヘルプ](./01-intro.md))。
## 2026-07-01 — ツールのオン/オフを「設定 → ツール」に一本化
エージェントが使えるツール・ファイル編集(Write/Edit)の可否・到達できる SSH 接続を、**ワークスペースの設定(設定 → ツール/SSH)だけ**で切り替えられるようにしました。これまで Piece(実行テンプレート)側にも書けたツール設定は撤去し、Piece は「作業の流れ(手順と遷移)」だけを定義するようになりました。「Piece に書いたのに使えない/設定で切ったのに使える」といった二重管理の混乱がなくなります。既存の Piece に古いツール設定が残っていてもエラーにはならず、単に無視されます(→[piece を使う・作る](./05-pieces.md)・[ツール](./16-tools.md))。
## 2026-07-01 — 実行時間の上限を設定から調整できるように+強制終了の理由を明確化
長時間走り続けるジョブには実行時間の上限(デッドライン)があり、超えると自動終了してワーカーの空きを確保します。この上限を **設定 → Safety** から分単位で調整できるようにしました(既定を 60 分から 180 分に延長。0 で無効)。あわせて、**ユーザーがキャンセルした場合**と**上限に達して自動終了した場合**を区別して表示するようにしました(従来はどちらも同じ「キャンセル」表示で見分けられませんでした)。上限到達後に中断が効かず固まったジョブを確実に片付ける保険(Deadline Grace 秒)も追加しています(→[設定](./17-settings.md))。
## 2026-07-01 — ファイルの読み取りを Read に一本化
Excel・Word・PDF・PowerPoint・Outlook メール(.msg)を、専用ツール(ReadExcel / ReadDocx / ReadPdf / ReadPPTX / ReadMsg)ではなく **Read だけ**で読めるようにしました。Read が拡張子から形式を自動判定して中身を抽出します。「どの読み取りツールを選ぶか」でエージェントが迷って誤る問題が減り、指示もシンプルになります。sheet や range、PDF の query といった形式ごとの細かい指定は従来どおり Read にそのまま渡せます(画像を見る ReadImage は別ツールのまま)(→[ツール](./16-tools.md))。
## 2026-07-01 — ファイルの来歴(どのタスクが作ったか)を表示
共有ワークスペースでは、過去のタスクが作ったファイルやアップロードした資料がそのまま残ります。どのファイルが「今の作業のもの」で、どれが「別タスクのもの」か分かりにくい問題に対処しました。ファイルプレビューを開くと、そのファイルの種別(ユーザーがアップロード / エージェント生成 / コマンド生成 など)と、作成・最終変更したタスク番号が小さく表示されます。エージェント側も、来歴が別タスクやユーザーのアップロードを示すファイルを編集する前に関連性を確認し、迷ったら新しい出力ファイルを作るようになりました(→[ワークスペース](./21-workspaces.md))。
## 2026-07-01 — 会話の想起を強化(Mission Brief 拡張+過去ログ検索)
長い会話や継続タスクで、最初の指示や途中の制約をエージェントが忘れてしまう問題に対処しました。Mission Brief に「ユーザー制約」「決定事項」「現在の焦点」の 3 項目を追加し、Overview タブから編集できます。あわせて、エージェントが過去のコメントや実行ログをキーワードで検索して前後を読み直せるようになり、ユーザーに聞き直す前に自分で思い出してから動くよう促しています(→[ツール](./16-tools.md))。
## 2026-06-30 — タスク実行の安定性を改善(ステップ切り替え・コンテキスト逼迫まわり)
エージェントがステップを切り替えたり実行を終える際に、内部のやり取りが不整合になって厳格なモデルから弾かれることがありました。切り替え・終了の処理を整え、後続ステップに不整合が残らないようにしています。あわせて、扱う情報量が上限に達したときに「成功」や「確認待ち」として誤って終わるのを防ぎ、安全に中断してやり直す挙動に統一しました。エージェントがユーザーに質問する場合は、なぜ既定値で進められないのかの理由を必ず添えるようになり、不要な確認が減ります(→[設定](./17-settings.md))。
## 2026-06-30 — テンプレート付きスキルのインストールが失敗していた不具合を修正
エージェントがワークスペース上で組み立てたフォルダ(`SKILL.md` `templates/` などのサブフォルダ)をスキルとして登録するとき、「ワークスペース内にないパス」と誤って拒否されることがありました。ワークスペースからの相対パスが正しく解決されていなかったのが原因で、修正後はフォルダごと(テンプレートや補助ファイルも含めて)登録できます(→[スキル](./11-skills.md))。
## 2026-06-30 — エージェントの同名ファイル保存で古い版を old/ に退避
エージェントが既存ファイルと同じ名前で成果物を書き込むとき、これまでは `report (競合コピー 1).md` のような別名ファイルを作っていました。今後は古いファイルを同じ階層の `old/` フォルダへ `report_old1.md` のような名前で移動し、新しい内容は元のファイル名で保存します。成果物の場所が変わりにくくなり、過去版も `old/` から確認できます(→[タスク作成とファイル](./02-tasks.md))。
## 2026-06-29 — 一部の LLM モデルで「System message must be at the beginning」エラーになる不具合を修正
バックエンドに特定のモデルを選ぶと、タスク実行のたびに `System message must be at the beginning.` という 400 エラーで止まることがありました。チャットテンプレートが厳格なモデルで、こちらが送るメッセージの並びが弾かれていたためです。先頭のシステムメッセージを 1 つにまとめ、ステップ切り替え時の案内文も通常のメッセージとして送るようにして、これらのモデルでも問題なく動くようにしました(→[設定](./17-settings.md))。
## 2026-06-29 — サブタスク内の「委譲(delegate)」の進捗が親タスクに表示されるように
サブタスクの中で実行された委譲(delegate)の進捗が、これまで親タスクの委譲ビューに出ていませんでした。今後は親タスクの委譲ビューに、サブタスクごとにまとめて表示されます(履歴・リアルタイム両方)(→[サブタスク](./10-subtasks.md))。
## 2026-06-29 — 個人ワークスペースの「実行中」バッジを監視範囲とそろえた(管理者)
左のワークスペース一覧に出る緑の「● N 実行中」バッジが、管理者の個人ワークスペースで、監視できる他ユーザーの個人ワークスペースの実行中タスクを数え落としていた不具合を修正しました。「他のメンバー」タブで見える実行中タスクと、バッジの件数が一致するようになりました。一般ユーザーのバッジは従来どおり自分のぶんだけを数えます(→[ワークスペース](./21-workspaces.md))。
@@ -40,6 +140,29 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
ログイン状態を保存したブラウザセッションをチャットに紐づけても、エージェントが調査をサブタスク(delegate)に委譲したとき、その中の BrowseWeb が保存セッションを引き継がず未ログインのままアクセスしてしまう不具合を修正しました。委譲先のサブタスクや、質問への回答後に再開したジョブでも、親に紐づけたログインセッションがそのまま使われます。リサーチが委譲経由で動くようになって以降、ログインが必要なサイトの取得に影響していた問題です。
## 2026-06-28 — 外部エージェントが委任されたスキルを実行できるように
A2A 連携で、外部エージェントが委任されたスキルを実際に呼び出して結果を受け取れるようになりました。
- 委任に同意したスペース内のスキル(ピース)を外部エージェントがリクエストすると、MAESTRO があなたの代わりにそのスキルを実行します
- 実行の進捗はストリーミングで順次返されます
- 実行結果と出力ファイル(Artifact)を外部エージェントが受け取れます
- 委任スコープ外のスキルはリクエストされても実行されません(fail-closed
## 2026-06-27 — A2A Agent Card の公開とスペース単位のスキル設定
外部エージェントが MAESTRO の Agent Card(接続情報文書)を取得できるエンドポイントを追加しました。また、スペースのオーナーが外部エージェントに公開するスキル(ピース)を選べるようになりました。
- **公開 Agent Card** はサーバーの接続情報のみを返します。ユーザー固有のデータは含みません
- **委任スコープ付き Agent Card** は、委任に同意したスペース・スキルの範囲だけを返します。外部エージェントが見えるのはユーザーが許可した内容に限られます
- スペースオーナーは **設定 → ワークスペース → A2A 公開スキル** で公開するスキルを選べます
実際のスキル実行(外部エージェントからのタスク起動)は次の更新で対応予定です。
## 2026-06-27 — A2A 認可サーバー(基盤)
外部エージェント連携(A2A)の土台として、OAuth2 認可サーバーを追加しました(既定は無効)。管理者が外部クライアントを登録でき、ユーザーは委任への同意・取り消しができます。実際の A2A エンドポイント公開は次の更新で行います。
## 2026-06-26 — フィードバックの評価タグも英語表示に対応
タスクの良かった/改善点フィードバックで選ぶ評価タグ(「出力の精度が高い」など)が、これまで日本語固定でした。表示言語が English のときは英語で表示されるようにしました。過去に登録済みのフィードバックも、保存内容はそのままに表示だけ言語に追従します。
+4
View File
@@ -35,6 +35,8 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
ダイアログのドロップゾーンにファイルをドラッグ&ドロップ、またはクリックで選択して添付できます。添付したファイルはワークスペースの `input/` に保存され、エージェントが読み込めます。依頼文で「input のファイルを読んで」と明示すると確実です。
`input/` に同じ名前のファイルが既にある場合は、上書きせず `名前 (2).ext` のように自動でリネームして保存します(元のファイルは残ります)。
## 詳細設定
「詳細設定を開く」を押すと、次の項目を調整できます。
@@ -109,6 +111,8 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
アップロード・削除はタスクのオーナー(と管理者)だけが行えます。エージェントの実行中はファイルを変更できません(実行が終わってから操作してください)。同名のファイルをアップロードすると、既存を上書きせず `名前 (2).拡張子` のように別名で保存します。
エージェントが成果物を書き込むとき、同じ名前のファイルが既にあり、エージェントがその最新版を読んでいない場合は、古いファイルを同じ階層の `old/` フォルダへ退避してから新しい内容を元のファイル名で保存します。退避先では `report_old1.md``report_old2.md` のように連番が付きます。
## ファイルのダウンロード
ファイルにマウスを重ねると、タイル右上にダウンロードアイコンが出て、1 件だけその場で保存できます。ダウンロードは閲覧操作なので、編集権の無い閲覧メンバーでも行えます。
+2 -1
View File
@@ -57,8 +57,9 @@ keywords: [ファイル, output, プレビュー, PDF, 印刷, ダウンロー
- **PDF**: 埋め込みビューアで表示
- **Excel (.xlsx / .xlsm)**: 各シートを表として表示。シートが複数あるときは上部のタブで切り替えられます
- **PowerPoint (.pptx / .ppt)**: 各スライドを画像にして見た目どおりに表示。上から順にスクロールで確認できます
- **Word (.docx)**: 各ページを画像にして見た目どおりに表示。上から順にスクロールで確認できます(本文の文字選択・検索はできません)
Excel・PowerPoint は、開いたときにサーバー側で表示用に変換します(少し時間がかかることがあります)。PowerPoint の画像化にはサーバーに変換エンジン(LibreOffice)が必要で、未導入の場合はプレビューの代わりにダウンロードの案内が出ます。
Excel・PowerPoint・Word は、開いたときにサーバー側で表示用に変換します(少し時間がかかることがあります)。PowerPoint と Word の画像化にはサーバーに変換エンジン(LibreOffice)が必要で、未導入の場合はプレビューの代わりにダウンロードの案内が出ます。長い Word 文書は先頭 50 ページまでを表示します。
output 配下の Markdown を編集できる場合は、プレビュー右上に **「編集」** ボタンが出ます。
+6 -6
View File
@@ -12,11 +12,11 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
## Piece とは
1 つの Piece は **movement(フェーズ)の並び** で構成されます。各 movement には「使ってよいツール(`allowed_tools`)」「ファイル編集の可否(`edit`)」「次の movement への遷移条件(`rules`)」が定義されています。
1 つの Piece は **movement(フェーズ)の並び** で構成されます。各 movement には「役割(persona)」「やること(instruction)」「次の movement への遷移条件(`rules`)」が定義されています。
シンプルな Piece は単一 movement(例: `chat`)、調査系は「分解 → 集約 → 検証」のように複数 movement を持ちます。
> **ツールの可否はワークスペースが決めます**: 最終的にエージェントが使えるツールは、いまは Piece の `allowed_tools` ではなく**ワークスペースのツールポリシー**(設定 → ツール)で決まります。Bash・ブラウザ・SSH・外部 MCP などのセンシティブなツールは、ワークスペースでオンにしていなければ Piece に書いても使えません。`allowed_tools` は「この movement の手順で使う道具」を表す記述に役割が移りつつあります。ツールが使えないときは、まずワークスペースのツール設定を確認してください(→[ツールリファレンス](16-tools.md)・[ワークスペースとメンバー](21-workspaces.md))。
> **ツールの可否はワークスペースが決めます**: エージェントが使えるツール、ファイル編集(Write/Edit)の可否、到達できる SSH 接続は、いずれも Piece ではなく**ワークスペースのツールポリシー**(設定 → ツールSSH)で決まります。Bash・ブラウザ・SSH・外部 MCP などのセンシティブなツールは、ワークスペースでオンにしていなければ使えません。Piece はツールを宣言しません(手順の流れだけを定義します。ツールが使えないときは、まずワークスペースのツール設定を確認してください(→[ツールリファレンス](16-tools.md)・[ワークスペースとメンバー](21-workspaces.md))。
## Piece はどう選ばれるか
@@ -90,8 +90,8 @@ Default Piece の行にある `⎘` ボタンをクリックすると「複製
- `description` は分類器が読みます。「○○をする。選ぶべき場合: … / 選ぶべきでない場合: …」の形式が効きます
- `instruction`(指示書)は長く書いて構いません。手順・避けるべきこと・終了方法を明示するとエージェントの動きが安定します
- movement の開始時に、その movement の `allowed_tools` と 1 行サマリが自動で system prompt に注入されます。指示書にツール一覧を重複して書く必要はありません
- 必要なツールは `allowed_tools` に列挙します。MCP ツールをまとめて許可するなら `mcp__*` を追加します
- すべての movement で共通して使うツールは、トップレベルの `shared_tools` にまとめて書けます。`shared_tools` のツールは各 movement の `allowed_tools` に自動で合算されるので、movement ごとに同じツールを繰り返す必要がなく、書き忘れも減ります。`edit`Write/Edit の可否と SSH 接続の許可は従来どおり movement ごとに効くため、`shared_tools` に入れても接続宣言していない movement では SSH ツールは使えません
- movement の開始時に、その時点でワークスペースが許可しているツール一覧と 1 行サマリが自動で system prompt に注入されます。指示書にツール一覧を書く必要はありません
- Piece にはツールを列挙しません。使えるツール(MCP を含む)はワークスペースの **設定 → ツール** で決まります
- ファイル編集Write/Editの可否と SSH 接続もワークスペースの設定で決まります。Piece 側に編集フラグや接続宣言はありません
- 使おうとしたツールがワークスペースのツールポリシーで許可されていない場合は弾かれます。その場合はまずワークスペースの **設定 → ツール** で該当カテゴリを有効にしてください([困ったときは](08-troubleshooting.md) 参照)
- エージェントは、作業に必要なのにこの movement に無いツールを見つけると、その利用を「要求」できます。対話的に実行中のタスクでは**チャットに承認カード**が出て、その場で「許可/拒否」を選べます。許可するとそのツールはそのタスクで使えるようになり、エージェントが続行します。恒久的に使えるようにするには、Piece の `allowed_tools``shared_tools` にそのツールを追加してください
- エージェントは、作業に必要なのにいま許可されていないツールを見つけると、その利用を「要求」できます。対話的に実行中のタスクでは**チャットに承認カード**が出て、その場で「許可/拒否」を選べます。許可するとそのツールはそのタスクで使えるようになり、エージェントが続行します。恒久的に使えるようにするには、ワークスペースの **設定 → ツール** で該当カテゴリを有効にしてください
+2
View File
@@ -57,6 +57,8 @@ delegate は標準で有効なので、特別な設定は不要です。実行
実行中の delegate サブエージェントは、チャット欄に専用の小さなコンソール枠でリアルタイムに文字出力が流れます(メインエージェントと同じ見え方)。完了後は「概要 > サブ実行」に作業記録(タイムライン)が残ります。
SpawnSubTask で起動したサブタスクの**中で** delegate が動いた場合も、その進捗が親タスクの委譲ビューに表示されます。チャット欄ではサブタスクごとにまとめたリアルタイムコンソールで様子を確認でき、完了後は「概要 > サブ実行」のカード内にそのサブタスクが実施した delegate の記録も残ります。並列サブタスクを使う構成でも、ひとつ上の親タスク画面から委譲の進捗をまとめて把握できます。
- ヘッダーに「N/M 完了」のカウンタが出る
- 各サブタスク・delegate 実行はカード表示で、ステータス・出力ファイル・ログ・入力ファイルを開ける
- delegate 実行は展開して「何をしたか」の詳細(ツール呼び出し・成功/中断)を確認できます
+12 -2
View File
@@ -3,7 +3,7 @@ id: ssh
title: SSH リモート操作
category: advanced
order: 140
keywords: [SSH, リモート, exec, アップロード, コンソール, PTY, デプロイ]
keywords: [SSH, リモート, exec, アップロード, コンソール, PTY, デプロイ, タブ, 複数セッション]
---
## SSH でできること
@@ -56,6 +56,16 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作
`ssh-console` piece でタスクを実行すると、エージェントがコンソールセッションを開きます。アクティブなセッションがある間、タスク詳細に「SSH」タブが現れ、ここでターミナル画面をリアルタイムに見て、人間が直接コマンドを打つこともできます。タスク詳細での見方・介入は [実行中のタスクを見る・介入する](./03-running.md) を参照してください。
### 複数のセッションを同時に開く
1 つのタスクで、複数の SSH 接続に同時にセッションを開けます。SSH タブの上部には接続ごとのタブが並び、クリックで切り替えられます。各タブには接続名と、状態を示す丸(接続中 / アイドル / 切断)が付き、**エージェントがそのセッションを操作している間は ⚡ が点灯**します。
- **+ 接続** ボタンを押すと、別の接続を選んで新しいセッションを追加できます。既存のタブは閉じません
- 不要になったセッションはタブの **✕** で閉じられます。閉じられるのは、そのセッションを開いた本人か、ワークスペースのオーナー・管理者だけです
- 画面をリアルタイムに流し続けるのは、いま選んでいるタブだけです。他のタブに切り替えると、それまでの出力(scrollback)を巻き戻して表示します
- **エージェントが別の接続を開いたり切り替えたりしても、いま見ている画面は変わりません。** 表示が切り替わるのは、ユーザー自身がタブをクリックしたときだけです
- 同時に開けるセッション数には上限があります(既定 5。admin の設定次第でユーザー単位の上限も加わります)。上限に達すると新しいセッションは開けず、使っていないタブを閉じるよう案内が出ます
## SSH 接続プロファイルを登録する
接続は、ワークスペースを開いて **設定 → SSH** から登録します(旧「ユーザーフォルダ → SSH 接続」タブは廃止され、ワークスペース設定に集約されました)。個人ワークスペースで登録すれば自分専用、案件ワークスペースで登録すればメンバー共有の接続になります。秘密鍵はワークスペースの鍵で暗号化保存され、メンバーは接続を使えても鍵の中身は見えません。
@@ -76,7 +86,7 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作
4. `ssh-ops` または `ssh-console` を使うタスクを作成して実行する
5. ssh-console の場合はタスク詳細の SSH タブで画面を確認・操作する
Piece の選び方`allowed_tools` の考え方は [piece を使う・作る](./05-pieces.md) を参照してください。
Piece の選び方は [piece を使う・作る](./05-pieces.md) を参照してください。SSH 接続の可否はワークスペースの **設定 → ツール/SSH** で決まります(Piece には書きません)。
## 他のワークスペースから取り込む
+19 -3
View File
@@ -20,11 +20,11 @@ movement の開始時には、その movement で使えるツールの一覧と
| カテゴリ | できること | 例 |
|---|---|---|
| ファイル / シェル | ワークスペースのファイル操作とコマンド実行 | Read / Write / Edit / Bash / Glob / Grep |
| ファイル / シェル | ワークスペースのファイル操作とコマンド実行Read は Excel / Word / PDF / PPTX / メールも拡張子で自動判定して読む) | Read / Write / Edit / Bash / Glob / Grep |
| Web / 検索 | Web 検索・取得・ダウンロード | WebSearch / WebFetch / DownloadFile |
| 技術ドキュメント | Microsoft Learn の公式ドキュメントを検索・取得 | SearchMicrosoftLearn / FetchMicrosoftLearn |
| ブラウザ | 実ブラウザでのページ操作 | BrowseWeb |
| Office / ドキュメント | Excel / Word / PDF / PPTX の解析 | ReadExcel / ReadPdf / ReadDocx |
| Office / ドキュメント | Excel / Word / PDF / PPTX は Read で解析(前処理・画像化は専用ツール) | Read / SplitExcelSheets / PdfToImages |
| データ | SQLite データベース操作 | SQLite |
| 画像 | 画像の読み取り・注釈 | ReadImage / AnnotateImage |
| レビュー | LLM による一括レビュー | BatchReviewTextWithLLM |
@@ -39,6 +39,20 @@ movement の開始時には、その movement で使えるツールの一覧と
SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照してください。
### ブラウザのスクリーンショット
ブラウザ操作ツール(`BrowseWeb``BrowseWithSession`)でページのスクリーンショットを撮ると、縦に長いページは既定で **1 画面ぶん(ビューポート高さ)ごとに区切った複数枚**`page-001.png`, `page-002.png` …)に自動分割して保存します。1 画面に収まるページは 1 枚のままです。全体を縦長の 1 枚で撮りたいときはフルページ指定に切り替えられます。どちらのツールでも同じ挙動です(詳細は `ReadToolDoc({ name: "BrowseWeb" })`)。
### テキストの文字コードとバイナリの扱い
Read と Grep は、UTF-8 以外で保存されたテキストも読めます。Windows で保存した日本語の `.txt` や、Excel から出した CSV に多い **Shift_JISCP932**、そのほか EUC-JP なども自動で判別し、UTF-8 に変換して表示・検索します。Read の先頭には検出したエンコーディングが注記されます。Edit で書き換えたときは元のエンコーディングのまま保存するので、Windows 側のツールがそのまま使えます。
画像・ZIP・実行ファイルなどの本物のバイナリは、これまでどおり Read が拒否します。Grep も対象フォルダに画像などのバイナリが混じっていた場合は自動でスキップするので、検索結果にバイナリの断片が紛れ込みません。画像を内容まで読みたいときは `ReadImage` を使ってください。
### PDF・Excel を途中から読むときの範囲指定
Read の `offset``limit`(行指定)と `byte_offset``byte_length` は**テキストファイル専用**です。PDF・Excel・Word ではこれらは無視されます。PDF を特定のページだけ読むときは `page_range`(例 `"5-10"`)、Excel は `sheet``range`(例 `"A1:D50"`)を使います。誤って PDF に `offset` を渡した場合は、無視して先頭に戻る代わりに、正しいパラメータへの案内が出力に付きます。
## 常時利用できるメタツール
一部のツールは、ワークスペースのツールポリシーやカテゴリ設定に関係なく常に利用できます。エージェントが「自分の状況を確認する」「足りないものを補う」ための土台になるツール群です。
@@ -48,7 +62,9 @@ SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照
- `RequestTool` — タスクに足りないツールの利用を申請する。チャット上でオーナーが承認すると、その場で使えるようになる
- `ReadUserMemory` / `UpdateUserMemory` — ワークスペースのメモリを読み書きする(→[メモリ](./12-memory.md)
- `CreateChecklist` / `CheckItem` / `GetChecklist` — タスク内の進捗チェックリスト
- `MissionUpdate` — 長時間タスクで、目標と現在地(進捗)をユーザーに途中報告するためのピン留めメモを更新する
- `MissionUpdate` — 長時間タスクで、目標・進捗に加えてユーザー制約・決定事項・現在の焦点をピン留めするメモ(Mission Briefを更新する
- `SearchTaskConversation` / `ReadTaskConversation` — このタスクの過去のやり取り(コメント+実行ログ)を検索し、前後の文脈を読み直す。「前に何を言ったか」を聞き直す前にエージェントが自分で思い出すために使う
- `SearchWorkspaceTasks` — 同じワークスペース内の**他タスク**の会話を横断検索する(`SearchTaskConversation` は現在タスクのみ)。タスクの状態は問わず、同じワークスペースを見られるメンバーの範囲で検索する。検索結果はツール名や添付ファイル名までしか出さない要約だが、`around_ref` で前後を辿ると元のコメント本文をそのまま読める
- `GetMyOrchestratorState` — 自分が今どのワークスペース・タスクで動いているかを把握する
- `ReadAppDoc` / `ListAppDocs` — アプリ内のヘルプ・ドキュメントをエージェント自身が読む(`#help` のヘルプ応答などで使われる)
+1 -1
View File
@@ -62,7 +62,7 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。
|-----------|------|
| Ask / Subtasks | ASK 上限・サブタスクの制御 |
| Context | コンテキスト使用率の警告閾値 (warn / prompt / force_transition) |
| Safety | `max_iterations``max_revisits`・history 要約などの自爆防止 |
| Safety | `max_iterations``max_revisits`**実行デッドライン(Max Job Minutes / Deadline Grace**・history 要約などの自爆防止 |
| Reflection | タスク完了後の自動学習。詳細は [Reflection の調整](#reflection) |
### Tools グループ (admin)
+18
View File
@@ -43,6 +43,15 @@ keywords: [ワークスペース, 個人ワークスペース, 案件ワーク
エージェントが作った **成果物** は、Files タブの `output/` に保存されます。チャット一覧や成果物プレビューが空のときにもその旨を案内するので、できあがったファイルの探し場所に迷いません。
### ファイルの来歴(どのタスクが作ったか)
共有ワークスペースでは、複数のタスクが同じファイルツリーを使うため、あるファイルが「今の作業のもの」か「別タスクのもの」か分かりにくくなります。ファイルを開いてプレビューすると、ヘッダーのすぐ下に小さな **来歴** 行が出ます。表示するのは次のとおりで、どのタスクに由来するかを一目で確認できます。
- **種別**: ユーザーがアップロード / エージェント生成 / コマンドで生成 / エージェントが編集 など
- **作成したタスク番号** と **最後に変更したタスク番号・日時**
エージェント側も同じ来歴を参照します。別タスクが作ったファイルやユーザーがアップロードした資料を編集する前に関連性を確認し、迷ったときは上書きせず新しい出力ファイルを作るようになっています。来歴に出るのはタスク番号だけで、タイトルやユーザー名は表示しません。
### フォルダの役割(書き込める場所・書き込めない場所)
Files タブの各フォルダには役割バッジが付き、エージェントが書き込める場所が一目で分かります。
@@ -119,6 +128,15 @@ token・API key・SSH 秘密鍵などの機密値は、そのワークスペー
- センシティブなツールは強力な操作(遠隔シェル・実ブラウザ操作・任意コマンド実行)を伴います。不要なワークスペースでは有効にしないことを推奨します。
- 設定変更はオーナーのみ行えます。変更するとその場でワークスペース内の新規タスクに即時反映されます。
## Python パッケージ(ワークスペース単位で追加する)
エージェントの Python 実行環境には、あらかじめよく使うライブラリ(pandas・openpyxl・requests 以外は基本入っていません)が入っています。それ以外のライブラリが必要なときは、ワークスペースの **設定 → Python** タブから、オーナー / 管理者がパッケージ名を直接入力して追加できます。
- ここで追加したパッケージは、**そのワークスペースの中だけ** で `import` できます。他のワークスペースには影響しません。
- インストールは **wheel があるパッケージのみ** 許可します(`requests``requests==2.32.3` のように、バージョンは `==` で固定できます)。標準ライブラリやプリインストール済みの名前は追加できません。
- ダウンロードはサーバー側で安全に隔離して実行されます。エージェント自身は引き続きネットワークから遮断されたまま、追加されたライブラリだけを読み込めます。
- この機能は既定でオフです。管理者が `config.yaml``python_packages.enabled` を有効にすると、タブに入力フォームが現れます。無効のとき、またはサーバー側の準備(サンドボックスや `pip`)が整っていないときは、その旨が画面に表示されます(黙って失敗しません)。インストールは安全なサンドボックスが使える環境でのみ実行できます。
## 関連
- 各設定(AGENTS.md / メモリ / Pieces / スキル / MCP / SSH / ブラウザ / ツール / メンバー / 招待リンク)の詳しい場所と操作 → [個人の資産(ワークスペース設定)](./09-userfolder.md)
+123
View File
@@ -0,0 +1,123 @@
---
id: a2a
title: 外部エージェント連携(A2A 認可サーバー)
category: advanced
order: 230
keywords: [A2A, エージェント連携, OAuth2, 認可サーバー, 外部エージェント, クライアント登録, 委任, agent-to-agent]
---
# 外部エージェント連携(A2A 認可サーバー)
MAESTRO には、外部エージェントやサービスがあなたのワークスペースにアクセスするための OAuth2 認可サーバーが組み込まれています。Agent-to-Agent(A2A)連携の土台となる機能です。
> **既定は無効です。** 利用するには管理者が `config.yaml` で `a2a.enabled: true` を設定してください。
## A2A 認可サーバーとは
外部のエージェントや自動化ツールがあなたのワークスペースに代わって操作を行うには、あなたの同意を得た上でアクセストークンを取得する必要があります。この仕組みを管理するのが A2A 認可サーバーです。
OAuth2 の標準フロー(認可コード + PKCE)をベースにしており、信頼できるクライアントだけがアクセスできるよう設計されています。
## 管理者の作業:クライアントを登録する
外部エージェントを接続するには、まず管理者がそのクライアントを登録する必要があります。
**設定 → 管理 → A2A クライアント** から操作できます。
| 項目 | 内容 |
|------|------|
| クライアント名 | 分かりやすい表示名(例: 「集計ボット」) |
| リダイレクト URI | 外部エージェント側が受け取るコールバック URL |
| スコープ | 付与する操作範囲(`tasks:read` / `tasks:write` など) |
登録するとクライアント ID が発行されます。シークレットは登録直後にのみ表示されるので、すぐ控えてください。
## ユーザーの作業:委任に同意・取り消しをする
外部エージェントがアクセスを要求すると、あなたの画面に同意ページが表示されます。内容を確認して「許可」すると、そのエージェントはあなたに代わって指定のスコープ内で操作できるようになります。
### 設定 → A2A 委任 から一覧・取り消し
**設定 → A2A 委任** を開くと、自分が過去に承認した委任の一覧が表示されます。各行には次の情報が確認できます。
| 項目 | 内容 |
|------|------|
| クライアント名 | 委任を受けた外部エージェントの名前 |
| スペース | アクセスを許可したスペース |
| スキル | 実行を許可したスキル(ピース) |
| 付与日時 | 委任を承認した日付 |
| 有効期限 | トークンの有効期限(期限なしの場合はその旨表示) |
| ステータス | 有効(Active)または取り消し済み(Revoked) |
**取り消し手順:**
1. 取り消したい委任の行にある「取り消す」ボタンをクリックします。
2. 確認ボタン(「取り消しを確定」)が表示されるので、再度クリックします。
3. 取り消しは即座に反映されます。そのトークンは無効になり、その委任のもとで実行中だったタスクがあればキャンセルされます。
一度取り消した委任は元に戻せません。外部エージェントが再度アクセスするには、最初から認可フローをやり直す必要があります。
## Agent Card と公開スキル
外部エージェントが MAESTRO に接続する際、最初に **Agent Card**`.well-known/agent.json`)を取得します。Agent Card はこのサーバーへの接続情報を記述した文書で、外部エージェントはここから認証フローを開始します。
カードには「公開版」と「委任スコープ付き版」の2種類があります。
| カード種別 | 内容 |
|-----------|------|
| 公開版(認証なし) | サーバーの接続情報のみ。ユーザー固有の情報は含まない |
| 委任版(認証あり) | 委任に同意したスペース・スキルだけが記載される |
### スペース単位の公開スキル設定
スペースのオーナーは、そのスペースで外部エージェントに公開するスキル(ピース)を選べます。**設定 → ワークスペース → A2A 公開スキル** で選択できます。
- 選択したスキルだけが委任版 Agent Card に含まれます
- 未選択のスペースはすべて非公開扱いです
- 外部エージェントが見えるのは、ユーザーが委任に同意し、かつオーナーが公開設定したスペース・スキルの範囲だけです
## 有効化の設定
`config.yaml` に以下を追加してください。
```yaml
a2a:
enabled: true
```
その他のオプション(トークン有効期限・セッション鍵など)は AGENTS.md または管理者向け設定ドキュメントを参照してください。
## スキルの実行と結果の受け取り
同意を得た外部エージェントは、委任されたスコープ内のスキルを呼び出してタスクを実行できます。
外部エージェントがスキルをリクエストすると、MAESTRO はあなたの代わりに対象スペース内でマッチするピースを起動します。実行の進捗はリクエスト側に順次ストリーミングされ、完了後は実行結果とファイル(Artifact)を受け取れます。
| 段階 | 内容 |
|------|------|
| リクエスト | 外部エージェントが委任トークンを添えてスキルを指定する |
| 実行 | 委任スコープを再確認後、対象スペース内でピースを起動する |
| 進捗 | 実行状況をストリーミングで順次返す |
| 完了 | 実行結果と出力ファイル(Artifact)を返却する |
委任スコープ外のスキルはリクエストされても実行されません。
### 長いタスクを非ブロッキングで依頼する
時間のかかるタスクは、接続を張り続けずに依頼できます。`message/send``configuration.blocking: false` を付けると、MAESTRO はタスクを受け付けた時点(`submitted` / `working`)で**即座に応答を返します**。完了を待たないので、接続を保持し続ける必要はありません。
結果は後から `tasks/get``params.id` にタスク ID を指定)で取得します。タスクが完了していれば `completed` 状態と出力ファイル(Artifact)が返り、まだ実行中なら現在の状態が返ります。ブリッジ側は裏側でジョブの状態を追い続け、切断や再起動をまたいでもタスクを最終状態まで収束させます。
| 段階 | 内容 |
|------|------|
| 依頼 | `configuration.blocking: false` を付けて `message/send` |
| 即応 | 受付時点の非終了状態(`submitted` / `working`)がすぐ返る |
| 取得 | 後から `tasks/get` で最新状態・結果を取得する |
> `tasks/resubscribe` は現在、**その時点の最新状態を返すだけ**です。切断後に進捗ストリームを途中から再開することはできません(進捗を追う場合は `tasks/get` でポーリングしてください)。
## 注意事項
- 外部エージェントに付与するスコープは必要最小限にしてください。
- クライアントシークレットは安全に管理し、外部に漏らさないでください。
- push 通知(webhook)やリソース上限(同時実行数・ペイロードサイズ)は後続のアップデートで対応予定です。
+121
View File
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
/**
* Hook test for useConsoleSession(taskId, connectionId):
* - connectionId=null never opens a socket (idle/no_session).
* - connectionId='A' builds a WS URL with ?connection_id=A.
* - changing connectionId 'A' -> 'B' closes the old socket, opens a new one
* with ?connection_id=B, and emits a terminal-reset byte sequence to
* output listeners so a switched tab doesn't show the previous buffer.
*/
import '../test/dom-setup';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConsoleSession } from './useConsoleSession';
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSING = 2;
readonly CLOSED = 3;
url: string;
readyState = 0;
binaryType = '';
onopen: ((ev: any) => void) | null = null;
onclose: ((ev: any) => void) | null = null;
onmessage: ((ev: any) => void) | null = null;
onerror: ((ev: any) => void) | null = null;
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
send(): void {
// no-op for these tests
}
close(): void {
if (this.readyState === this.CLOSED) return;
this.readyState = this.CLOSED;
this.onclose?.({});
}
}
let realWebSocket: typeof WebSocket;
beforeEach(() => {
FakeWebSocket.instances = [];
realWebSocket = global.WebSocket;
(global as any).WebSocket = FakeWebSocket;
});
afterEach(() => {
(global as any).WebSocket = realWebSocket;
vi.restoreAllMocks();
});
describe('useConsoleSession(taskId, connectionId)', () => {
it('opens no socket when connectionId is null', () => {
renderHook(() => useConsoleSession('task1', null));
expect(FakeWebSocket.instances).toHaveLength(0);
});
it('builds a WS URL containing connection_id=A on mount', () => {
renderHook(() => useConsoleSession('task1', 'A'));
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0].url).toContain('connection_id=A');
});
it('closes the old socket and opens a new one when connectionId changes', () => {
const { rerender } = renderHook(
({ connectionId }: { connectionId: string | null }) => useConsoleSession('task1', connectionId),
{ initialProps: { connectionId: 'A' as string | null } },
);
expect(FakeWebSocket.instances).toHaveLength(1);
const first = FakeWebSocket.instances[0];
expect(first.readyState).not.toBe(first.CLOSED);
act(() => {
rerender({ connectionId: 'B' });
});
expect(first.readyState).toBe(first.CLOSED);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(FakeWebSocket.instances[1].url).toContain('connection_id=B');
});
it('emits a terminal-reset byte sequence to output listeners when switching to a new connectionId', () => {
const { result, rerender } = renderHook(
({ connectionId }: { connectionId: string | null }) => useConsoleSession('task1', connectionId),
{ initialProps: { connectionId: 'A' as string | null } },
);
const received: Uint8Array[] = [];
act(() => {
result.current.onOutput((data) => received.push(data));
});
act(() => {
rerender({ connectionId: 'B' });
});
expect(received).toHaveLength(1);
const text = new TextDecoder().decode(received[0]);
expect(text).toBe('\x1bc');
});
it('does not emit a reset on the very first connect (nothing to clear yet)', () => {
const received: Uint8Array[] = [];
const { result } = renderHook(() => useConsoleSession('task1', 'A'));
act(() => {
result.current.onOutput((data) => received.push(data));
});
expect(received).toHaveLength(0);
});
});
+33 -3
View File
@@ -26,6 +26,12 @@ export interface ConsoleSessionApi {
reconnectNow(): void;
}
// Sent to output listeners (never over the wire) whenever the hook switches
// from one connected session to a different one, so a terminal that renders
// raw PTY bytes (xterm.js interprets ESC c as "reset to initial state") gets
// cleared instead of showing the previous session's leftover buffer.
const TERMINAL_RESET_BYTES = new TextEncoder().encode('\x1bc');
/**
* WS client for the shared SSH console.
*
@@ -35,25 +41,49 @@ export interface ConsoleSessionApi {
* is ready is a normal case, not an error. The hook silently keeps
* retrying until the session appears, at which point the next attempt
* succeeds and the terminal "comes alive".
*
* `connectionId` selects which console session (of possibly several open on
* the task) this hook instance attaches to. Passing `null` means "no session
* selected" (e.g. the tab-strip empty state) — the hook stays idle and never
* opens a socket. Changing `connectionId` to a different non-null value
* tears down the old socket, emits a terminal-reset to output listeners, and
* connects to the new session (replay flows through the normal attach path).
*/
export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
export function useConsoleSession(taskId: string | number, connectionId: string | null): ConsoleSessionApi {
const wsRef = useRef<WebSocket | null>(null);
const [state, setState] = useState<ConnState>({ kind: 'no_session' });
const outputListeners = useRef(new Set<(d: Uint8Array) => void>());
const noticeListeners = useRef(new Set<(m: any) => void>());
const lastAttachRef = useRef<{ canWrite: boolean; cols: number; rows: number } | null>(null);
// Tracks the previously-connected connectionId so we can tell "switched to
// a different session" apart from "first connect" (nothing to reset yet).
const prevConnectionIdRef = useRef<string | null>(null);
// Populated by the connection effect with a callback that forces an
// immediate reconnect (resetting backoff). Held in a ref so the stable
// `reconnectNow` returned below can delegate to the live closure.
const reconnectNowRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (connectionId == null) {
// Empty state / no session selected: stay idle, never open a socket.
prevConnectionIdRef.current = null;
setState({ kind: 'no_session' });
return;
}
if (prevConnectionIdRef.current != null && prevConnectionIdRef.current !== connectionId) {
// Switching tabs: clear whatever the previous session's terminal had
// rendered before the new session's replay starts writing.
outputListeners.current.forEach((l) => l(TERMINAL_RESET_BYTES));
}
prevConnectionIdRef.current = connectionId;
let cancelled = false;
let retryDelayMs = 1000; // start: 1s; doubles each failure
const MAX_RETRY_MS = 30_000; // cap at 30s
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const url = `${location.origin.replace(/^http/, 'ws')}/api/local/tasks/${encodeURIComponent(String(taskId))}/console/ws`;
const url = `${location.origin.replace(/^http/, 'ws')}/api/local/tasks/${encodeURIComponent(String(taskId))}/console/ws?connection_id=${encodeURIComponent(connectionId)}`;
const connect = (): void => {
if (cancelled) return;
@@ -134,7 +164,7 @@ export function useConsoleSession(taskId: string | number): ConsoleSessionApi {
if (retryTimer) clearTimeout(retryTimer);
try { wsRef.current?.close(); } catch {}
};
}, [taskId]);
}, [taskId, connectionId]);
return {
state,
+3 -3
View File
@@ -8,7 +8,7 @@ import {
getTrustedLocalHtmlUrl,
subtaskFileRawUrl,
} from '../api';
import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../lib/utils';
import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable, isOfficePreviewable, officePreviewKind } from '../lib/utils';
import type { OfficePreviewDescriptor } from '../components/files/FilePreview';
export interface PreviewState {
@@ -36,8 +36,8 @@ export function useFilePreview(onError: (msg: string) => void) {
) => {
try {
const canEdit = section === 'output' && isTextPreviewable(name);
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
if (isOfficePreviewable(name)) {
const kind = officePreviewKind(name);
setPreviewState({
name,
content: '',
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { reduceDelegateStreams } from './useJobStream';
describe('reduceDelegateStreams originJobId contract', () => {
it('lifecycle の originJobId を run に紐づける', () => {
let s = reduceDelegateStreams(
{},
{
type: 'delegate_lifecycle',
delegateRunId: 'r1',
originJobId: 'sub1',
status: 'running',
depth: 1,
description: 'x',
},
);
s = reduceDelegateStreams(s, {
type: 'delegate_text_delta',
delegateRunId: 'r1',
text: 'hello',
});
const run = s['r1'];
expect(run).toBeDefined();
expect(run.originJobId).toBe('sub1');
expect(run.text).toContain('hello');
});
it('originJobId が無い lifecycle は originJobId が null のまま', () => {
const s = reduceDelegateStreams(
{},
{
type: 'delegate_lifecycle',
delegateRunId: 'r2',
status: 'running',
depth: 1,
description: 'parent',
},
);
// originJobId は undefined か null — どちらも null-ish として扱われる
expect(s['r2'].originJobId == null).toBe(true);
});
it('originJobId は text_delta / tool イベントを経ても引き継がれる', () => {
let s = reduceDelegateStreams(
{},
{
type: 'delegate_lifecycle',
delegateRunId: 'r3',
originJobId: 'sub2',
status: 'running',
depth: 1,
description: 'y',
},
);
s = reduceDelegateStreams(s, {
type: 'delegate_tool',
delegateRunId: 'r3',
toolName: 'WebFetch',
});
s = reduceDelegateStreams(s, {
type: 'delegate_text_delta',
delegateRunId: 'r3',
text: 'result',
});
expect(s['r3'].originJobId).toBe('sub2');
});
});
+5 -2
View File
@@ -21,6 +21,8 @@ export interface DelegateStreamEntry {
status: 'running' | 'success' | 'aborted' | 'needs_user_input';
text: string;
currentTool: string | null;
/** SSE delegate_lifecycle の originJobId フィールド。サブタスクグループ分類に使用。 */
originJobId?: string | null;
}
export interface JobStreamState {
@@ -34,7 +36,7 @@ export interface JobStreamState {
function emptyEntry(delegateRunId: string): DelegateStreamEntry {
return {
delegateRunId, parentRunId: null, depth: 0, description: '',
status: 'running', text: '', currentTool: null,
status: 'running', text: '', currentTool: null, originJobId: null,
};
}
@@ -43,7 +45,7 @@ export function reduceDelegateStreams(
prev: Record<string, DelegateStreamEntry>,
data: { type: string; delegateRunId?: string; parentRunId?: string | null;
depth?: number; description?: string; status?: DelegateStreamEntry['status'];
text?: string; toolName?: string },
text?: string; toolName?: string; originJobId?: string | null },
): Record<string, DelegateStreamEntry> {
const id = data.delegateRunId;
if (!id) return prev;
@@ -56,6 +58,7 @@ export function reduceDelegateStreams(
depth: data.depth ?? existing.depth,
description: data.description || existing.description,
status: data.status ?? existing.status,
originJobId: data.originJobId !== undefined ? data.originJobId : existing.originJobId,
} };
case 'delegate_text_delta':
return { ...prev, [id]: { ...existing, text: existing.text + (data.text ?? ''), currentTool: null } };
+30
View File
@@ -0,0 +1,30 @@
{
"delegations": {
"title": "A2A Delegations",
"subtitle": "External agents that have been granted access to act on your behalf.",
"empty": "No delegations yet.",
"emptyExplain": "When an external agent requests access and you approve it, the delegation appears here. You can revoke any delegation at any time — the token is invalidated immediately and any running tasks are cancelled.",
"loading": "Loading delegations…",
"err": {
"load": "Failed to load delegations.",
"revoke": "Failed to revoke delegation."
},
"badge": {
"live": "Active",
"revoked": "Revoked"
},
"field": {
"client": "Client",
"grantedSpaces": "Spaces",
"grantedSkills": "Skills",
"created": "Granted",
"expires": "Expires",
"noExpiry": "No expiry"
},
"revoke": "Revoke",
"confirmRevoke": "Confirm revoke",
"cancel": "Cancel",
"revokeSuccess": "Delegation revoked.",
"cancelledJobs": "{{count}} running task(s) cancelled."
}
}
+6 -4
View File
@@ -45,17 +45,19 @@
"Split large research into subtasks to run them in parallel and finish faster.",
"Register an SSH connection to let the agent operate remote servers.",
"Turn frequent procedures into a Skill so the agent can call them.",
"Ingest documents into Knowledge to search across them.",
"Sources the agent gathers from the web are kept in source/ with citations, so you can verify them later.",
"The piece is auto-selected from your prompt, but you can also pick it manually at creation.",
"Attach images or PDFs and the agent will read them as it works.",
"The more specific your instructions, the better the result — add the format and constraints you want.",
"Name a skill (\"use the X skill\") to make the agent use it for sure.",
"Each piece exposes different tools. If a tool is missing, try switching the piece.",
"You can change a task's piece later — switch it if the agent behaves unexpectedly.",
"A piece sets how a task is carried out — its steps and role. If the agent behaves unexpectedly, try switching to a different piece.",
"Not sure how something works? The Help docs explain each feature.",
"Connect an MCP server to call external tools directly from the agent.",
"The Usage tab shows LLM consumption per user and per model.",
"Web search results aren't always current — open the source page to verify anything important."
"Web search results aren't always current — open the source page to verify anything important.",
"Ask the agent to \"build a workspace app\" and it assembles a small HTML tool that can work with your files.",
"The Calendar tab lets you review each day's tasks, changed files, and events in one place.",
"Create a project workspace and invite members to share tasks, files, and settings with your team."
]
},
"subtask": {
+17 -3
View File
@@ -1,6 +1,8 @@
{
"delegateRuns": {
"eventsEmpty": "No events"
"eventsEmpty": "No events",
"subtaskGroupTitle": "Subtask #{{n}}",
"subtaskSectionHeading": "Delegate runs in subtasks"
},
"tabs": { "chat": "Chat", "overview": "Overview", "activity": "Progress", "files": "Files", "trace": "Trace", "browser": "Browser", "ssh": "SSH" },
"chatTabsLabel": "Chat and details",
@@ -33,6 +35,9 @@
"goal": { "label": "Goal", "placeholder": "The essential goal of this task (Markdown OK)", "emptyHint": "Not set — the agent will write it first" },
"done": { "label": "Done", "placeholder": "Completed milestones (Markdown bullets recommended)", "emptyHint": "Nothing completed yet" },
"open": { "label": "Open", "placeholder": "Remaining work / blockers", "emptyHint": "No open items recorded" },
"user_constraints": { "label": "User constraints", "placeholder": "Durable constraints stated by the user (\"don't change X\")", "emptyHint": "No constraints pinned" },
"decisions": { "label": "Decisions", "placeholder": "Decisions made after clarification, with rationale", "emptyHint": "No decisions recorded" },
"current_focus": { "label": "Current focus", "placeholder": "What is being worked on right now", "emptyHint": "No current focus set" },
"clarifications": { "label": "Notes & constraints", "placeholder": "Constraints / notes added along the way", "emptyHint": "No notes" }
}
},
@@ -86,14 +91,23 @@
"sshDisabled": "The SSH subsystem is disabled. Set ssh.enabled: true in config.yaml and restart the server.",
"startTitle": "Start an SSH console", "startDesc": "Pick a connection and start a session; a terminal opens and is shared with the AI on this task.",
"loadFailed": "Failed to load", "noConnections": "No SSH connections available — register/grant them in Settings → SSH Connections.",
"starting": "Starting...", "startSession": "Start session", "replaceStart": "Replace the current session and start",
"starting": "Starting...", "startSession": "Start session", "loadingConnections": "Loading…",
"cancelAddConnection": "Cancel adding a connection",
"noActiveConsole": "No active console. Add a connection to start a session.",
"connecting": "Connecting…", "restoringScrollback": "Replaying scrollback…",
"unknownReason": "unknown reason", "disconnected": "Disconnected: {{reason}}",
"connected": "Connected", "connLabel": "conn: {{label}}",
"uptime": "uptime {{time}}", "idle": "idle {{time}}", "readOnly": "viewer (read-only)",
"agentActive": "Agent is active in this session", "closeSession": "Close this session",
"addConnection": "Add connection",
"errors": {
"hostKeyNotVerified": "Verify the connection's host key (Settings → SSH Connections → Test)",
"noGrant": "You don't have permission for this connection (ask an admin to grant it)",
"hostKeyMismatch": "Host key mismatch (possible MITM). Contact an admin.",
"disabled": "This connection is disabled", "abuseLocked": "This connection is temporarily locked (abuse detection)",
"notFound": "Connection not found", "startFailed": "Could not start the session: {{code}}",
"sessionExists": "A session for another connection already exists. You can replace it and start."
"taskSessionCap": "This task has reached its console session limit. Close a session before starting another.",
"userSessionCap": "You've reached the account-wide console session limit. Close a session before starting another."
}
}
}
+19
View File
@@ -75,6 +75,21 @@
"confirmCount": "Move here ({{count}})",
"confirm": "Move here"
},
"provenance": {
"source": "Source",
"createdBy": "Created by task #{{taskId}}",
"modifiedBy": "Last modified by task #{{taskId}}",
"at": "on {{at}}",
"kind": {
"user_input": "Uploaded by user",
"agent_output": "Agent output",
"agent_edit": "Agent-edited",
"bash_generated": "Generated by command",
"subtask_output": "Subtask output",
"imported_existing": "Pre-existing file",
"unknown": "Unknown"
}
},
"preview": {
"noSheets": "No sheets.",
"sheetTruncated": "Showing only the first {{shown}} rows of a large sheet (total {{rows}} rows, {{cols}} columns). Download the file to view everything.",
@@ -82,6 +97,10 @@
"slide": "Slide {{index}}",
"slideAlt": "Slide {{index}}",
"slidesTruncated": "Showing only the first {{shown}} slides (total {{total}}).",
"noPages": "No pages.",
"page": "Page {{index}}",
"pageAlt": "Page {{index}}",
"pagesTruncated": "Showing only the first {{shown}} pages (total {{total}}).",
"loadFailed": "Failed to load",
"converterUnavailable": "This server does not have a conversion engine (LibreOffice) installed, so a preview cannot be shown. Download the file to view it.",
"previewFailed": "Could not show a preview: {{message}}",
+6 -1
View File
@@ -418,6 +418,9 @@
"loadMore": "Show more"
}
},
"delegations": {
"navLabel": "🔑 A2A Delegations"
},
"reflection": {
"intro": "Every time a normal job completes, the LLM extracts the lessons learned from that job and automatically updates the user's memory (data/users/{userId}/memory/) and, when needed, a custom piece. All changes are saved as snapshots and can be reverted from the Memory & Learning tab.",
"enableLabel": "Enable Reflection (auto-apply)",
@@ -561,6 +564,9 @@
"maxRevisitsHelp": "Re-visit limit for the same movement (loop detection). Default: 3",
"maxToolLoopHelp": "When the exact same tool call (tool name + args) repeats consecutively this many times within one movement, it is treated as a loop and force-aborted (2 or more, default: 5). A warning is injected to the agent one step before.",
"promptGuardHelp": "What fraction of the context limit the prompt may occupy before auto-compaction kicks in prior to sending (0.50.95, default: 0.8)",
"deadlineTitle": "Execution deadline",
"maxJobMinutesHelp": "Hard wall-clock ceiling (minutes) for one job's active execution. Past this the job is auto-terminated (marked cancelled, reason \"timed out\") so its worker slot is released — the last-resort backstop for runaways or hangs in non-abortable tools. Default: 180, 0 disables.",
"deadlineGraceHelp": "Grace period (seconds) after the deadline fires before the worker force-releases the slot and marks the job cancelled (reason \"timed out\"), in case the cooperative abort is ignored. Insurance against jobs stuck in tools that don't honor the abort signal. Default: 15, 0 disables this fallback (not recommended).",
"bashSandboxTitle": "Bash sandbox",
"bashSandboxAuto": "auto (sandboxed if bwrap is present, otherwise hardened-whitelist)",
"bashSandboxAlways": "always (force sandboxed; fail at startup if bwrap is missing)",
@@ -724,7 +730,6 @@
"keywordsHelp": "When a task body contains these keywords, this piece is auto-selected"
},
"movement": {
"editHelp": "When enabled, the Write / Edit tools are offered to the LLM",
"instructionHelp": "The instruction passed to the LLM. Markdown is supported"
},
"rules": {
+20
View File
@@ -342,6 +342,7 @@
"ssh": "SSH",
"browser": "Browser",
"tools": "Tools",
"python": "Python",
"members": "Members"
},
"pieces": {
@@ -376,5 +377,24 @@
"Bash": "Allows arbitrary command execution. Any shell command can run on the host system.",
"SpawnSubTask": "Allows decomposition into parallel subtasks (separate jobs). Each child occupies a worker and a GPU slot. The serial 'delegate' tool is usually enough; enable this only when parallel execution is genuinely required."
}
},
"python": {
"heading": "Python Packages",
"intro": "Add Python packages (wheels) that this workspace's agents can use. Packages added here are importable only inside this workspace and never affect other workspaces.",
"disabled": "This feature is disabled. An admin can enable it via python_packages.enabled in config.yaml.",
"preflightBad": "pip is not available on the server, so installs cannot run.",
"addLabel": "Add a package",
"addHint": "Only packages with a wheel are allowed (e.g. requests or requests==2.32.3). Pin a version with ==. Standard-library and preinstalled names cannot be added.",
"add": "Add",
"installing": "Installing…",
"installedHeading": "Installed",
"none": "Nothing added yet.",
"remove": "Remove",
"readonly": "Only this workspace's owner or an admin can edit.",
"fetchError": "Failed to load packages: {{msg}}",
"added": "Package added.",
"addFailed": "Add failed: {{msg}}",
"removed": "Package removed.",
"removeFailed": "Remove failed: {{msg}}"
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"delegations": {
"title": "A2A 委任",
"subtitle": "あなたに代わって操作する権限を付与された外部エージェントの一覧です。",
"empty": "委任はまだありません。",
"emptyExplain": "外部エージェントからアクセスを要求され、あなたが承認すると、委任がここに表示されます。いつでも取り消せます。取り消した瞬間にトークンが無効になり、実行中のタスクはキャンセルされます。",
"loading": "委任を読み込んでいます…",
"err": {
"load": "委任の読み込みに失敗しました。",
"revoke": "委任の取り消しに失敗しました。"
},
"badge": {
"live": "有効",
"revoked": "取り消し済み"
},
"field": {
"client": "クライアント",
"grantedSpaces": "スペース",
"grantedSkills": "スキル",
"created": "付与日時",
"expires": "有効期限",
"noExpiry": "期限なし"
},
"revoke": "取り消す",
"confirmRevoke": "取り消しを確定",
"cancel": "キャンセル",
"revokeSuccess": "委任を取り消しました。",
"cancelledJobs": "実行中のタスク {{count}} 件をキャンセルしました。"
}
}
+6 -4
View File
@@ -45,17 +45,19 @@
"大きめの調査はサブタスクに分割すると、並列で速く進みます。",
"リモートサーバーの操作は、SSH 接続を登録するとエージェントから実行できます。",
"よく使う手順はスキルとして登録すると、エージェントが呼び出せます。",
"ナレッジにドキュメントを取り込むと、横断検索で参照できます。",
"エージェントが Web から集めた資料は出典付きで source/ に残るので、後から根拠を確認できます。",
"piece は内容から自動で選ばれますが、作成時に手動で指定もできます。",
"画像や PDF も添付すれば、エージェントが読み取って作業します。",
"指示は具体的なほど精度が上がります。成果物の形式や条件も添えてみてください。",
"「○○のスキルを使って」と名前を挙げると、そのスキルを確実に使わせられます。",
"piece ごとに使えるツールが違います。必要なツールが無いときは piece 切り替えてみてください。",
"タスクの piece は後からでも変更できます。想定と違う動きをしたら切り替えを。",
"piece はタスクの進め方(手順・役割)を決めます。想定と違う動きをしたら別の piece 切り替えてみてください。",
"使い方に迷ったら、ヘルプのドキュメントに各機能の説明があります。",
"MCP サーバーを接続すると、外部ツールをエージェントから直接呼べます。",
"使用量タブで、ユーザーやモデルごとの LLM 利用状況を確認できます。",
"Web 検索の結果は最新とは限りません。重要な情報は元ページを開いて裏取りを。"
"Web 検索の結果は最新とは限りません。重要な情報は元ページを開いて裏取りを。",
"「ワークスペース・アプリを作って」と頼むと、ファイルを操作できる小さな HTML の道具をエージェントが組み立てます。",
"カレンダータブで、日ごとのタスク・変更ファイル・予定をまとめて振り返れます。",
"案件ワークスペースを作ってメンバーを招くと、チームでタスク・ファイル・設定を共有できます。"
]
},
"subtask": {
+17 -3
View File
@@ -1,6 +1,8 @@
{
"delegateRuns": {
"eventsEmpty": "イベントなし"
"eventsEmpty": "イベントなし",
"subtaskGroupTitle": "サブタスク #{{n}}",
"subtaskSectionHeading": "サブタスク内の委譲"
},
"tabs": { "chat": "会話", "overview": "概要", "activity": "進捗", "files": "ファイル", "trace": "トレース", "browser": "ブラウザ", "ssh": "SSH" },
"chatTabsLabel": "チャットと詳細",
@@ -33,6 +35,9 @@
"goal": { "label": "目標", "placeholder": "このタスクの本質的な目標 (Markdown 可)", "emptyHint": "未設定 — エージェントが最初に書きます" },
"done": { "label": "完了", "placeholder": "完了したマイルストーン (Markdown 箇条書き推奨)", "emptyHint": "まだ何も完了していません" },
"open": { "label": "残タスク", "placeholder": "残っている作業 / ブロッカー", "emptyHint": "残タスク未記入" },
"user_constraints": { "label": "ユーザー制約", "placeholder": "ユーザーが明示した恒久的な制約(「X は変えないで」等)", "emptyHint": "制約の pin なし" },
"decisions": { "label": "決定事項", "placeholder": "確定した設計判断とその理由", "emptyHint": "決定事項の記録なし" },
"current_focus": { "label": "現在の焦点", "placeholder": "いま取り組んでいる作業の焦点", "emptyHint": "現在の焦点は未設定" },
"clarifications": { "label": "補足・制約", "placeholder": "途中で追加された制約・補足", "emptyHint": "補足なし" }
}
},
@@ -86,14 +91,23 @@
"sshDisabled": "SSH サブシステムは無効です。config.yaml の ssh.enabled: true を設定後にサーバーを再起動してください。",
"startTitle": "SSH コンソールを開始", "startDesc": "接続を選んでセッションを開始すると、ターミナルが開き AI とこのタスクで共有されます。",
"loadFailed": "読み込みに失敗しました", "noConnections": "利用できる SSH 接続がありません — Settings → SSH Connections で登録/grant してください。",
"starting": "開始中…", "startSession": "セッション開始", "replaceStart": "現在のセッションを置き換えて開始",
"starting": "開始中…", "startSession": "セッション開始", "loadingConnections": "読み込み中…",
"cancelAddConnection": "接続の追加をキャンセル",
"noActiveConsole": "アクティブなコンソールがありません。接続を追加してセッションを開始してください。",
"connecting": "接続中…", "restoringScrollback": "スクロールバックを復元中…",
"unknownReason": "不明な理由", "disconnected": "切断されました: {{reason}}",
"connected": "接続中", "connLabel": "接続: {{label}}",
"uptime": "稼働 {{time}}", "idle": "アイドル {{time}}", "readOnly": "閲覧のみ(読み取り専用)",
"agentActive": "このセッションでエージェントが作業中", "closeSession": "このセッションを閉じる",
"addConnection": "接続を追加",
"errors": {
"hostKeyNotVerified": "接続の host key を検証してください(Settings → SSH Connections → Test",
"noGrant": "この接続への権限がありません(admin に grant を依頼してください)",
"hostKeyMismatch": "host key 不一致(MITM の可能性)。admin に連絡してください",
"disabled": "この接続は無効化されています", "abuseLocked": "この接続は一時的にロックされています(abuse 検知)",
"notFound": "接続が見つかりません", "startFailed": "セッションを開始できませんでした: {{code}}",
"sessionExists": "別の接続のセッションが既に存在します。置き換えて開始できます。"
"taskSessionCap": "このタスクはコンソールセッション数の上限に達しています。新しく開く前に既存のセッションを閉じてください。",
"userSessionCap": "アカウント全体のコンソールセッション数の上限に達しています。新しく開く前に既存のセッションを閉じてください。"
}
}
}
+19
View File
@@ -75,6 +75,21 @@
"confirmCount": "ここに移動({{count}} 件)",
"confirm": "ここに移動"
},
"provenance": {
"source": "種別",
"createdBy": "作成: タスク #{{taskId}}",
"modifiedBy": "最終変更: タスク #{{taskId}}",
"at": "{{at}}",
"kind": {
"user_input": "ユーザーがアップロード",
"agent_output": "エージェント生成",
"agent_edit": "エージェントが編集",
"bash_generated": "コマンドで生成",
"subtask_output": "サブタスク成果物",
"imported_existing": "既存ファイル",
"unknown": "不明"
}
},
"preview": {
"noSheets": "シートがありません。",
"sheetTruncated": "大きいシートのため先頭 {{shown}} 行のみ表示しています(全 {{rows}} 行・{{cols}} 列)。全体はダウンロードして確認してください。",
@@ -82,6 +97,10 @@
"slide": "スライド {{index}}",
"slideAlt": "スライド {{index}}",
"slidesTruncated": "先頭 {{shown}} 枚のみ表示しています(全 {{total}} 枚)。",
"noPages": "ページがありません。",
"page": "ページ {{index}}",
"pageAlt": "ページ {{index}}",
"pagesTruncated": "先頭 {{shown}} ページのみ表示しています(全 {{total}} ページ)。",
"loadFailed": "読み込みに失敗しました",
"converterUnavailable": "このサーバーには変換エンジン(LibreOffice)が未導入のため、プレビューを表示できません。ファイルをダウンロードして確認してください。",
"previewFailed": "プレビューを表示できませんでした:{{message}}",
+6 -1
View File
@@ -418,6 +418,9 @@
"loadMore": "さらに表示"
}
},
"delegations": {
"navLabel": "🔑 A2A 委任"
},
"reflection": {
"intro": "通常ジョブが完了するたびに LLM がそのジョブから学んだ教訓を抽出し、ユーザーの memory (data/users/{userId}/memory/) と必要に応じて custom piece を自動更新します。全変更は snapshot として保存され、Memory & Learning タブから revert 可能です。",
"enableLabel": "Reflection を有効化(自動適用)",
@@ -561,6 +564,9 @@
"maxRevisitsHelp": "同一 movement への再訪問上限(ループ検出)。デフォルト: 3",
"maxToolLoopHelp": "同一 movement 内で全く同じツール呼び出し(ツール名+引数)を連続で繰り返した回数がこの値に達したら、ループとみなして強制中断する(2以上、デフォルト: 5)。手前で1回エージェントに警告を注入する",
"promptGuardHelp": "送信前に prompt がコンテキスト上限の何割を占めたら自動圧縮するか(0.5〜0.95、デフォルト: 0.8",
"deadlineTitle": "実行デッドライン",
"maxJobMinutesHelp": "1 ジョブの実行時間の上限(分)。超過するとジョブを自動終了(キャンセル扱い・理由「時間切れ」)してワーカースロットを解放する。暴走や中断不能なツールで固まった場合の最終防壁。デフォルト: 180、0 で無効",
"deadlineGraceHelp": "デッドライン到達後、協調的な中断が効かないときに強制的にスロットを解放してキャンセル扱い(理由「時間切れ」)にするまでの猶予(秒)。中断シグナルを無視するツールで固まったジョブへの保険。デフォルト: 15、0 で無効(保険を切る・非推奨)",
"bashSandboxTitle": "Bash サンドボックス",
"bashSandboxAuto": "autobwrap があれば sandboxed、無ければ hardened-whitelist",
"bashSandboxAlways": "alwayssandboxed を強制・bwrap 不在なら起動時 fail",
@@ -724,7 +730,6 @@
"keywordsHelp": "タスク本文にこれらのキーワードが含まれると、この piece が自動選択されます"
},
"movement": {
"editHelp": "有効にすると Write / Edit ツールが LLM に提示されます",
"instructionHelp": "LLM に渡される指示文。Markdown 記法が使えます"
},
"rules": {
+20
View File
@@ -342,6 +342,7 @@
"ssh": "SSH",
"browser": "ブラウザ",
"tools": "ツール",
"python": "Python",
"members": "メンバー"
},
"pieces": {
@@ -376,5 +377,24 @@
"Bash": "任意コマンド実行を許可します。ホストシステムで任意のシェルコマンドが実行できます。",
"SpawnSubTask": "並列サブタスク(別ジョブ)への分解を許可します。子ごとにワーカーと GPU スロットを占有します。通常は直列の delegate で十分です。本当に並列実行が必要なときだけ有効化してください。"
}
},
"python": {
"heading": "Python パッケージ",
"intro": "このワークスペースのエージェントが使える Python パッケージ(wheel)を追加します。ここで入れたパッケージは、このワークスペースの中だけで import でき、他のワークスペースには影響しません。",
"disabled": "この機能は無効です。管理者が config.yaml の python_packages.enabled を有効にすると使えるようになります。",
"preflightBad": "サーバーで pip が使えないため、インストールできません。",
"addLabel": "パッケージを追加",
"addHint": "wheel のあるパッケージのみ許可します(例: requests または requests==2.32.3)。バージョンは == で固定できます。標準ライブラリやプリインストール済みの名前は追加できません。",
"add": "追加",
"installing": "インストール中…",
"installedHeading": "インストール済み",
"none": "まだ何も追加されていません。",
"remove": "削除",
"readonly": "編集はこのワークスペースのオーナーまたは管理者のみ可能です。",
"fetchError": "パッケージ一覧の取得に失敗しました: {{msg}}",
"added": "パッケージを追加しました。",
"addFailed": "追加に失敗しました: {{msg}}",
"removed": "パッケージを削除しました。",
"removeFailed": "削除に失敗しました: {{msg}}"
}
}
+14
View File
@@ -23,6 +23,20 @@ describe('buildDelegateRunTree', () => {
it('delegate が無ければ空', () => {
expect(buildDelegateRunTree([])).toEqual([]);
});
it('グループ単位で閉じる — 別ジョブの parentRunId は跨がない(cross-job parentRunId は孤児扱い)', () => {
// ジョブ A のみのスライスを渡した場合、ジョブ B の run (b) が parentRunId として
// ジョブ A の run (a) を参照していても、ここでは b は含まれていないため
// a は root として扱われ、b が子になることはない。
// 逆に、ジョブ A のスライス [a, b] を渡すと a が root で b が子になる。
const runs = [
run('a', null, 1), // job-A の root run
run('b', 'a', 2), // job-A の子 run
];
const tree = buildDelegateRunTree(runs as any);
expect(tree).toHaveLength(1); // a が root、b はその子
expect(tree[0].children).toHaveLength(1);
});
});
describe('delegateStatusBadge', () => {
+15
View File
@@ -14,6 +14,21 @@ export interface DelegateRunNode extends DelegateRun {
children: DelegateRunNode[];
}
/** サブタスクジョブに紐づく delegate run のグループ。Task 7 でレンダリングされる。 */
export interface SubtaskDelegateGroup {
jobId: string;
issueNumber: number;
depth: number;
status: string;
runs: DelegateRun[];
}
/** GET /:id/delegate-runs のレスポンス型。 */
export interface DelegateRunsResult {
runs: DelegateRun[];
subtasks: SubtaskDelegateGroup[];
}
/** delegate run の status に対応する i18n ラベルキーと Tailwind 色クラス。 */
export function delegateStatusBadge(
status: DelegateRun['status'],
+13
View File
@@ -27,3 +27,16 @@ export interface ConsoleStatus {
cols?: number;
rows?: number;
}
export interface ConsoleSessionSummary {
connection_id: string;
connection_label: string;
started_at: string;
last_activity_at: string;
status: 'connected' | 'idle' | 'closed';
can_write: boolean;
can_close: boolean;
agent_active: boolean;
cols?: number;
rows?: number;
}
+1
View File
@@ -9,6 +9,7 @@ const SETTINGS_SECTIONS = [
'pets',
'notifications',
'memory-learning',
'a2a-delegations',
// System group
'branding',
'paths-storage',
+17 -1
View File
@@ -5,7 +5,9 @@ import {
formatActivityMeta,
isSpreadsheetPreviewable,
isPresentationPreviewable,
isDocumentPreviewable,
isOfficePreviewable,
officePreviewKind,
isTextPreviewable,
} from './utils';
@@ -34,11 +36,25 @@ describe('office プレビュー判定', () => {
expect(isPresentationPreviewable('old.PPT')).toBe(true);
expect(isPresentationPreviewable('notes.key')).toBe(false);
});
it('isOfficePreviewable は両方を拾う', () => {
it('Word は docx のみ (旧 .doc は対象外)', () => {
expect(isDocumentPreviewable('report.docx')).toBe(true);
expect(isDocumentPreviewable('report.DOCX')).toBe(true);
expect(isDocumentPreviewable('report.doc')).toBe(false);
expect(isDocumentPreviewable('report.odt')).toBe(false);
});
it('isOfficePreviewable は Excel/PowerPoint/Word を拾う', () => {
expect(isOfficePreviewable('a.xlsx')).toBe(true);
expect(isOfficePreviewable('a.pptx')).toBe(true);
expect(isOfficePreviewable('a.docx')).toBe(true);
expect(isOfficePreviewable('a.pdf')).toBe(false);
});
it('officePreviewKind は拡張子から表示種別を返す', () => {
expect(officePreviewKind('a.xlsx')).toBe('spreadsheet');
expect(officePreviewKind('a.xlsm')).toBe('spreadsheet');
expect(officePreviewKind('a.pptx')).toBe('presentation');
expect(officePreviewKind('a.ppt')).toBe('presentation');
expect(officePreviewKind('a.docx')).toBe('document');
});
it('csv はテキストプレビュー側で扱う (office とは排他)', () => {
expect(isTextPreviewable('data.csv')).toBe(true);
expect(isOfficePreviewable('data.csv')).toBe(false);
+15 -1
View File
@@ -91,8 +91,22 @@ export function isPresentationPreviewable(name: string): boolean {
return /\.(pptx|ppt)$/i.test(name);
}
// Word は LibreOffice で PDF 化できる docx のみ(旧バイナリ .doc は再現精度が低く対象外)。
export function isDocumentPreviewable(name: string): boolean {
return /\.docx$/i.test(name);
}
export function isOfficePreviewable(name: string): boolean {
return isSpreadsheetPreviewable(name) || isPresentationPreviewable(name);
return isSpreadsheetPreviewable(name) || isPresentationPreviewable(name) || isDocumentPreviewable(name);
}
// office-preview の表示種別を拡張子から判定する。呼出側(useFilePreview / SpaceDetail /
// SpaceCalendar)はこのヘルパーで descriptor.kind を決める。isOfficePreviewable が true の
// ファイルにのみ使うこと(それ以外では 'document' に落ちる)。
export function officePreviewKind(name: string): 'spreadsheet' | 'presentation' | 'document' {
if (isSpreadsheetPreviewable(name)) return 'spreadsheet';
if (isPresentationPreviewable(name)) return 'presentation';
return 'document';
}
export function isPreviewable(name: string): boolean {
-2
View File
@@ -159,10 +159,8 @@ function PiecesSidebar({
initial_movement: 'execute',
movements: [{
name: 'execute',
edit: true,
persona: 'worker',
instruction: '',
allowed_tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
default_next: 'COMPLETE',
rules: [{ condition: '完了', next: 'COMPLETE' }],
}],