This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
×
|
||||
</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-packages(canManageSpace が編集)
|
||||
* - インストールはサーバー側で 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>
|
||||
);
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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' }],
|
||||
}],
|
||||
|
||||
Reference in New Issue
Block a user