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
+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' }],
}],