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

This commit is contained in:
oss-sync
2026-06-11 01:52:48 +00:00
parent 000a2474aa
commit d061ad08d8
237 changed files with 8441 additions and 5549 deletions
+30 -25
View File
@@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
/**
* Local-account dialogs:
@@ -25,10 +26,11 @@ function ErrorNote({ msg }: { msg: string | null }) {
}
function Actions({ onClose, busy, submitLabel }: { onClose: () => void; busy: boolean; submitLabel: string }) {
const { t } = useTranslation('common');
return (
<div className="flex justify-end gap-2 mt-1">
<button type="button" onClick={onClose} className="px-3 h-8 rounded-md text-xs font-medium border border-hairline text-slate-700 hover:bg-surface">
{t('cancel')}
</button>
<button type="submit" disabled={busy} className="px-3 h-8 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90">
{busy ? '...' : submitLabel}
@@ -38,6 +40,7 @@ function Actions({ onClose, busy, submitLabel }: { onClose: () => void; busy: bo
}
export function CreateLocalUserDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { t } = useTranslation('auth');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<'user' | 'admin'>('user');
@@ -47,7 +50,7 @@ export function CreateLocalUserDialog({ onClose, onCreated }: { onClose: () => v
const submit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
if (password.length < 8) { setErr('パスワードは8文字以上にしてください'); return; }
if (password.length < 8) { setErr(t('errors.passwordTooShort')); return; }
setBusy(true);
try {
const res = await fetch('/api/admin/users', {
@@ -55,8 +58,8 @@ export function CreateLocalUserDialog({ onClose, onCreated }: { onClose: () => v
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password, role }),
});
if (res.status === 409) { setErr('そのメールアドレスは既に登録されています'); return; }
if (!res.ok) { setErr('作成に失敗しました'); return; }
if (res.status === 409) { setErr(t('errors.emailTaken')); return; }
if (!res.ok) { setErr(t('errors.createFailed')); return; }
onCreated();
onClose();
} finally {
@@ -67,28 +70,29 @@ export function CreateLocalUserDialog({ onClose, onCreated }: { onClose: () => v
return (
<div className={overlay} onClick={onClose}>
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
<h3 className="text-sm font-semibold text-slate-900 mb-4"></h3>
<h3 className="text-sm font-semibold text-slate-900 mb-4">{t('create.title')}</h3>
<ErrorNote msg={err} />
<Field label="メールアドレス">
<Field label={t('create.email')}>
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} className={inputCls} autoComplete="off" />
</Field>
<Field label="初期パスワード(8文字以上)">
<Field label={t('create.initialPassword')}>
<input type="password" required minLength={8} value={password} onChange={e => setPassword(e.target.value)} className={inputCls} autoComplete="new-password" />
</Field>
<Field label="ロール">
<Field label={t('create.role')}>
<select value={role} onChange={e => setRole(e.target.value as 'user' | 'admin')} className={inputCls}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
<p className="text-2xs text-slate-500 mb-3">active</p>
<Actions onClose={onClose} busy={busy} submitLabel="作成" />
<p className="text-2xs text-slate-500 mb-3">{t('create.note')}</p>
<Actions onClose={onClose} busy={busy} submitLabel={t('create.submit')} />
</form>
</div>
);
}
export function ResetPasswordDialog({ userId, email, onClose }: { userId: string; email: string; onClose: () => void }) {
const { t } = useTranslation('auth');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -97,7 +101,7 @@ export function ResetPasswordDialog({ userId, email, onClose }: { userId: string
const submit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
if (password.length < 8) { setErr('パスワードは8文字以上にしてください'); return; }
if (password.length < 8) { setErr(t('errors.passwordTooShort')); return; }
setBusy(true);
try {
const res = await fetch(`/api/admin/users/${userId}/password`, {
@@ -105,7 +109,7 @@ export function ResetPasswordDialog({ userId, email, onClose }: { userId: string
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!res.ok) { setErr('リセットに失敗しました'); return; }
if (!res.ok) { setErr(t('errors.resetFailed')); return; }
setDone(true);
setTimeout(onClose, 900);
} finally {
@@ -116,17 +120,17 @@ export function ResetPasswordDialog({ userId, email, onClose }: { userId: string
return (
<div className={overlay} onClick={onClose}>
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
<h3 className="text-sm font-semibold text-slate-900 mb-1"></h3>
<h3 className="text-sm font-semibold text-slate-900 mb-1">{t('reset.title')}</h3>
<p className="text-2xs text-slate-500 mb-4 truncate">{email}</p>
{done ? (
<p className="text-xs text-emerald-700 dark:text-emerald-300"></p>
<p className="text-xs text-emerald-700 dark:text-emerald-300">{t('reset.done')}</p>
) : (
<>
<ErrorNote msg={err} />
<Field label="新しいパスワード(8文字以上)">
<Field label={t('reset.newPassword')}>
<input type="password" required minLength={8} value={password} onChange={e => setPassword(e.target.value)} className={inputCls} autoComplete="new-password" />
</Field>
<Actions onClose={onClose} busy={busy} submitLabel="リセット" />
<Actions onClose={onClose} busy={busy} submitLabel={t('reset.submit')} />
</>
)}
</form>
@@ -135,6 +139,7 @@ export function ResetPasswordDialog({ userId, email, onClose }: { userId: string
}
export function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation('auth');
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [err, setErr] = useState<string | null>(null);
@@ -144,7 +149,7 @@ export function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
const submit = async (e: FormEvent) => {
e.preventDefault();
setErr(null);
if (next.length < 8) { setErr('新しいパスワードは8文字以上にしてください'); return; }
if (next.length < 8) { setErr(t('errors.newPasswordTooShort')); return; }
setBusy(true);
try {
const res = await fetch('/api/auth/password', {
@@ -152,9 +157,9 @@ export function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: current, newPassword: next }),
});
if (res.status === 403) { setErr('現在のパスワードが正しくありません'); return; }
if (res.status === 400) { setErr('このアカウントにはローカルパスワードがありません'); return; }
if (!res.ok) { setErr('変更に失敗しました'); return; }
if (res.status === 403) { setErr(t('errors.currentPasswordWrong')); return; }
if (res.status === 400) { setErr(t('errors.noLocalPassword')); return; }
if (!res.ok) { setErr(t('errors.changeFailed')); return; }
setDone(true);
setTimeout(onClose, 900);
} finally {
@@ -165,19 +170,19 @@ export function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
return (
<div className={overlay} onClick={onClose}>
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
<h3 className="text-sm font-semibold text-slate-900 mb-4"></h3>
<h3 className="text-sm font-semibold text-slate-900 mb-4">{t('change.title')}</h3>
{done ? (
<p className="text-xs text-emerald-700 dark:text-emerald-300"></p>
<p className="text-xs text-emerald-700 dark:text-emerald-300">{t('change.done')}</p>
) : (
<>
<ErrorNote msg={err} />
<Field label="現在のパスワード">
<Field label={t('change.current')}>
<input type="password" required value={current} onChange={e => setCurrent(e.target.value)} className={inputCls} autoComplete="current-password" />
</Field>
<Field label="新しいパスワード(8文字以上)">
<Field label={t('change.newPassword')}>
<input type="password" required minLength={8} value={next} onChange={e => setNext(e.target.value)} className={inputCls} autoComplete="new-password" />
</Field>
<Actions onClose={onClose} busy={busy} submitLabel="変更" />
<Actions onClose={onClose} busy={busy} submitLabel={t('change.submit')} />
</>
)}
</form>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { POLLING } from '../../lib/constants.js';
import { usePictureInPicture } from '../../lib/usePictureInPicture.js';
@@ -35,6 +36,7 @@ async function releaseSession(id: string): Promise<void> {
}
export function BrowserSessionPanel() {
const { t } = useTranslation('browser');
const queryClient = useQueryClient();
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
@@ -142,7 +144,7 @@ export function BrowserSessionPanel() {
</div>
{pip.isOpen ? (
<div className="w-full h-[500px] flex items-center justify-center bg-slate-50 text-xs text-slate-500">
PiP
{t('session.pipActive')}
</div>
) : (
<iframe
+13 -11
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { PipController } from '../../lib/usePictureInPicture.js';
interface Props {
@@ -11,10 +12,10 @@ interface Props {
hideWhenUnsupported?: boolean;
}
const REASON_HINT: Record<string, string> = {
browser: 'Picture-in-Picture は Chromium 系ブラウザ (Chrome / Edge / Arc / Opera 116+) のみ対応',
'insecure-context': 'Picture-in-Picture は HTTPS / localhost からのアクセスでのみ使えます',
iframe: 'iframe 内では親フレームに document-picture-in-picture 権限が必要です',
const REASON_HINT_KEY: Record<string, string> = {
browser: 'pip.reason.browser',
'insecure-context': 'pip.reason.insecureContext',
iframe: 'pip.reason.iframe',
};
/**
@@ -25,25 +26,26 @@ const REASON_HINT: Record<string, string> = {
* user gesture, iframe permission policy, etc).
*/
export function PipButton({ pip, className, hideWhenUnsupported = true }: Props) {
const { t } = useTranslation('browser');
if (!pip.supported && hideWhenUnsupported) return null;
const baseClass = 'text-2xs px-2 py-1 rounded-md border border-hairline bg-canvas hover:bg-surface text-slate-700 disabled:opacity-50';
const merged = className ? `${baseClass} ${className}` : baseClass;
if (!pip.supported) {
const hint = REASON_HINT[pip.unsupportedReason ?? 'browser'] ?? REASON_HINT.browser;
const hintKey = REASON_HINT_KEY[pip.unsupportedReason ?? 'browser'] ?? REASON_HINT_KEY.browser;
return (
<button type="button" disabled className={merged} title={hint}>
PiP
<button type="button" disabled className={merged} title={t(hintKey)}>
{t('pip.unsupportedTag')}
</button>
);
}
const tooltip = pip.lastError
? `直前のエラー: ${pip.lastError}`
? t('pip.lastError', { error: pip.lastError })
: pip.isOpen
? 'PiP ウィンドウを閉じてここに戻す'
: '別ウィンドウに切り出す(常に最前面)';
? t('pip.closeTooltip')
: t('pip.openTooltip');
return (
<span className="inline-flex items-center gap-2">
@@ -56,7 +58,7 @@ export function PipButton({ pip, className, hideWhenUnsupported = true }: Props)
className={merged}
title={tooltip}
>
{pip.isOpen ? '↩ PiP を戻す' : '⇱ PiP'}
{pip.isOpen ? t('pip.return') : t('pip.open')}
</button>
{pip.lastError && !pip.isOpen && (
<span
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
taskId: number;
@@ -19,6 +20,7 @@ type Status =
* or a plain-text error for 5 s on failure.
*/
export function SaveRecordingButton({ taskId, className }: Props) {
const { t } = useTranslation('browser');
const [status, setStatus] = useState<Status>({ kind: 'idle' });
const [linkVisible, setLinkVisible] = useState(false);
@@ -77,15 +79,15 @@ export function SaveRecordingButton({ taskId, className }: Props) {
function label(): string {
switch (status.kind) {
case 'loading':
return '保存中…';
return t('saveRecording.saving');
case 'success':
return `✓ 保存: ${status.recordingName}.json`;
return t('saveRecording.saved', { name: status.recordingName });
case 'error':
return `× ${status.message}`;
return t('saveRecording.error', { message: status.message });
case 'no_recording':
return 'BrowseWeb で recordTo を指定するとここで保存できます';
return t('saveRecording.noRecording');
default:
return '💾 録画を保存';
return t('saveRecording.save');
}
}
@@ -98,8 +100,8 @@ export function SaveRecordingButton({ taskId, className }: Props) {
className={merged}
title={
status.kind === 'no_recording'
? 'BrowseWeb ツールの recordTo オプションで録画を開始すると保存できます'
: '録画バッファをファイルに書き出す'
? t('saveRecording.noRecordingTitle')
: t('saveRecording.saveTitle')
}
>
{label()}
@@ -110,7 +112,7 @@ export function SaveRecordingButton({ taskId, className }: Props) {
onClick={handleUserFolderClick}
className="text-[10px] text-accent hover:underline pl-0.5"
>
User Folder
{t('saveRecording.openInUserFolder')}
</button>
)}
</span>
+8 -4
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTaskComment } from '../../api';
import { MarkdownPreview } from '../files/FilePreview';
import { MarkdownText } from '../../lib/markdown-text';
@@ -105,6 +106,7 @@ function formatDuration(ms: number): string {
}
function ChecklistCard({ comment }: { comment: LocalTaskComment }) {
const { t } = useTranslation('chat');
const [expanded, setExpanded] = useState(false);
const data = tryParseChecklistProgress(comment.body);
if (!data) return null;
@@ -229,7 +231,7 @@ function ChecklistCard({ comment }: { comment: LocalTaskComment }) {
onClick={() => setExpanded(true)}
className="text-2xs text-slate-500 hover:text-slate-900 hover:underline mt-1.5"
>
{'\u4ED6'} {items.length - 20} {'\u4EF6\u3092\u8868\u793A...'}
{t('checklist.showMore', { count: items.length - 20 })}
</button>
)}
</div>
@@ -272,6 +274,7 @@ function ProgressPill({ icon, children, variant = 'inline' }: { icon: React.Reac
}
function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; isStaleThinking?: boolean }) {
const { t } = useTranslation('chat');
// Interjection ack → minimal centered confirmation
const ackData = tryParseInterjectionAck(comment.body);
if (ackData) {
@@ -279,7 +282,7 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
<div className="flex justify-center">
<div className="inline-flex items-center gap-1.5 px-3 py-1 text-[10px] text-green-600 font-medium">
<span>{'✓'}</span>
<span></span>
<span>{t('message.messageAcked')}</span>
</div>
</div>
);
@@ -318,7 +321,7 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
if (data) {
const toolEntries = Object.entries(data.tools);
const toolSummary = toolEntries.map(([name, count]) => `${name}\u00D7${count}`).join(', ');
const text = `${data.movement} \u5B8C\u4E86${toolSummary ? ` \u00B7 ${toolSummary}` : ''} \u00B7 ${formatDuration(data.durationMs)}`;
const text = `${t('movement.complete', { movement: data.movement })}${toolSummary ? ` \u00B7 ${toolSummary}` : ''} \u00B7 ${formatDuration(data.durationMs)}`;
return <ProgressPill icon={<span className="text-green-600">{'\u2713'}</span>}>{text}</ProgressPill>;
}
@@ -331,6 +334,7 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
}
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) {
const { t } = useTranslation('chat');
const { kind, author, body, createdAt } = comment;
// Progress card (center)
@@ -349,7 +353,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
</div>
<MarkdownText text={body} />
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
{isPending ? '⏳ エージェント確認待ち' : `✓ 確認済み ${new Date(comment.injectedAt!).toLocaleTimeString()}`}
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
</div>
</div>
</div>
+19 -17
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTask, LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
@@ -30,6 +31,7 @@ interface ChatPaneProps {
}
export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: ChatPaneProps) {
const { t } = useTranslation('chat');
const [draft, setDraft] = useState('');
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [submitting, setSubmitting] = useState(false);
@@ -121,7 +123,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
} catch (e) {
setSendError(e instanceof Error && e.message ? e.message : '送信に失敗しました');
setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed'));
releaseSubmitting();
}
};
@@ -261,9 +263,9 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
<button
onClick={onOpenDetail}
className="px-2.5 h-7 text-2xs font-medium text-slate-700 border border-hairline bg-canvas hover:bg-surface rounded-md transition-colors"
title="詳細を表示"
title={t('pane.viewDetail')}
>
{t('pane.detail')}
</button>
)}
</div>
@@ -276,7 +278,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
{comments.length === 0 && (
<div className="text-center text-slate-400 text-[13px] py-8">
{t('pane.empty')}
</div>
)}
{(() => {
@@ -342,7 +344,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
</div>
) : liveToolContent ? (
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
<div className="text-2xs text-slate-500 mb-1 font-mono">{liveToolContent.name} </div>
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
{liveToolContent.text}
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
@@ -354,7 +356,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
...
{t('pane.agentResponding')}
</div>
)}
</div>
@@ -372,9 +374,9 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
<path d="M4 6l4 4 4-4" />
</svg>
{newMessageCount > 0 ? (
<span className="text-blue-600 font-medium">{newMessageCount} </span>
<span className="text-blue-600 font-medium">{t('pane.newMessages', { count: newMessageCount })}</span>
) : (
<span></span>
<span>{t('pane.toLatest')}</span>
)}
</button>
)}
@@ -392,7 +394,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span>{canInterject ? 'エージェント実行中 — メッセージで指示を送れます' : 'エージェントがタスクを実行中です。少々お待ちください。'}</span>
<span>{canInterject ? t('pane.interjectHint') : t('pane.agentRunningWait')}</span>
</div>
)}
{sendError && !isBusy && (
@@ -404,7 +406,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
disabled={submitting}
className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/15 disabled:opacity-50"
>
{t('pane.resend')}
</button>
</div>
)}
@@ -430,8 +432,8 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
onClick={() => fileInputRef.current?.click()}
disabled={inputLocked || submitting}
className="flex-shrink-0 w-9 h-9 flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-surface rounded-md transition-colors disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title="ファイルを添付"
aria-label="ファイルを添付"
title={t('pane.attachFile')}
aria-label={t('pane.attachFile')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
@@ -444,7 +446,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
onPaste={e => void handlePaste(e)}
rows={2}
disabled={inputLocked}
placeholder={inputLocked ? 'ジョブ割り当て中...' : canInterject ? '実行中のエージェントに指示...' : 'メッセージを入力... (Ctrl+Enter で送信)'}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : t('pane.placeholder.default')}
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
/>
{isBusy && onCancel ? (
@@ -459,19 +461,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t('pane.interject')}
</button>
)}
<button
disabled={cancelling}
onClick={() => void handleCancel()}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
title="エージェントの実行を停止"
title={t('pane.stopAgent')}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
</svg>
{cancelling ? '停止中...' : '停止'}
{cancelling ? t('pane.stopping') : t('pane.stop')}
</button>
</div>
) : (
@@ -484,7 +486,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
<path d="M22 2 11 13" />
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
</svg>
{t('pane.send')}
</button>
)}
</div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
import { isThinkingComment } from './thinkingUtils';
@@ -107,6 +108,7 @@ interface MovementGroupExpandedProps {
}
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx }: MovementGroupExpandedProps) {
const { t } = useTranslation('chat');
const [expanded, setExpanded] = useState(false);
const { movementName, summary, inner } = item;
const previewText = getPreviewText(item);
@@ -173,7 +175,7 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx }:
if (blocks.length === 0) {
return (
<div className="ml-5 mt-1 mb-1 border-l-2 border-slate-100 pl-3">
<div className="text-[10px] text-slate-400 py-1"></div>
<div className="text-[10px] text-slate-400 py-1">{t('movement.noIntermediateOutput')}</div>
</div>
);
}
+6 -4
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SubtaskInfo } from '../../api';
import { statusTone, formatStatusLabel } from '../../lib/utils';
@@ -51,6 +52,7 @@ function SubtaskStatusIcon({ status }: { status: string }) {
}
export function SubtaskInlineCard({ subtasks, subtaskCount, subtaskCompleted }: SubtaskInlineCardProps) {
const { t } = useTranslation('chat');
const [expanded, setExpanded] = useState(true);
const progressPct = subtaskCount > 0 ? Math.round((subtaskCompleted / subtaskCount) * 100) : 0;
const allDone = subtaskCompleted === subtaskCount;
@@ -77,7 +79,7 @@ export function SubtaskInlineCard({ subtasks, subtaskCount, subtaskCompleted }:
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
)}
<span className="text-[13px] font-semibold text-slate-900 truncate"></span>
<span className="text-[13px] font-semibold text-slate-900 truncate">{t('subtask.title')}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-2xs text-slate-500 font-mono tabular-nums">
@@ -135,13 +137,13 @@ export function SubtaskInlineCard({ subtasks, subtaskCount, subtaskCompleted }:
</span>
</span>
{st.status === 'running' && (
<span className="text-blue-500 text-2xs font-mono flex-shrink-0"></span>
<span className="text-blue-500 text-2xs font-mono flex-shrink-0">{t('subtask.running')}</span>
)}
{st.status === 'succeeded' && (
<span className="text-emerald-500 text-2xs font-mono flex-shrink-0"></span>
<span className="text-emerald-500 text-2xs font-mono flex-shrink-0">{t('subtask.done')}</span>
)}
{st.status === 'failed' && (
<span className="text-red-500 text-2xs font-mono flex-shrink-0"></span>
<span className="text-red-500 text-2xs font-mono flex-shrink-0">{t('subtask.failed')}</span>
)}
</div>
);
+6 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
buildCommands, filterCommands, groupCommands,
type CommandContext, type CommandItem,
@@ -11,6 +12,7 @@ interface Props {
}
export function CommandPalette({ open, onClose, ctx }: Props) {
const { t } = useTranslation('layout');
const dialogRef = useRef<HTMLDialogElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const openerRef = useRef<Element | null>(null);
@@ -71,7 +73,7 @@ export function CommandPalette({ open, onClose, ctx }: Props) {
return (
<dialog
ref={dialogRef}
aria-label="コマンドパレット"
aria-label={t('commandPalette.label')}
className="m-0 mt-[12vh] mx-auto w-[min(560px,92vw)] rounded-xl border border-hairline bg-surface text-ink shadow-2xl p-0 backdrop:bg-black/40"
>
<div className="p-2 border-b border-hairline">
@@ -80,17 +82,17 @@ export function CommandPalette({ open, onClose, ctx }: Props) {
role="combobox"
aria-expanded="true"
aria-controls="cmdk-listbox"
aria-label="コマンド・タスクを検索"
aria-label={t('commandPalette.searchLabel')}
aria-activedescendant={highlightedId ? `cmdk-opt-${highlightedId}` : undefined}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="コマンド・タスクを検索…"
placeholder={t('commandPalette.searchPlaceholder')}
className="w-full h-9 px-2 bg-transparent outline-none text-sm"
/>
</div>
<div id="cmdk-listbox" role="listbox" className="max-h-[52vh] overflow-y-auto p-1">
{flat.length === 0 && <div role="status" className="px-3 py-6 text-center text-sm text-muted"></div>}
{flat.length === 0 && <div role="status" className="px-3 py-6 text-center text-sm text-muted">{t('commandPalette.empty')}</div>}
{groups.map((g) => (
<div key={g.group} role="group" aria-label={g.label}>
<div className="section-label px-2 pt-2 pb-1">{g.label}</div>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface AttachmentDropzoneProps {
attachments: Array<{ name: string; contentBase64: string }>;
@@ -18,6 +19,7 @@ async function toBase64(file: File): Promise<string> {
}
export function AttachmentDropzone({ attachments, onFilesChange }: AttachmentDropzoneProps) {
const { t } = useTranslation('create');
const [dragOver, setDragOver] = useState(false);
const handleFiles = async (files: FileList | null) => {
@@ -37,8 +39,8 @@ export function AttachmentDropzone({ attachments, onFilesChange }: AttachmentDro
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); void handleFiles(e.dataTransfer.files); }}
>
<div className="font-bold text-[13px] text-slate-700"></div>
<div className="mt-1 text-xs text-slate-400">&</div>
<div className="font-bold text-[13px] text-slate-700">{t('attachments.title')}</div>
<div className="mt-1 text-xs text-slate-400">{t('attachments.hint')}</div>
<input
type="file"
multiple
+35 -37
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
@@ -22,6 +23,7 @@ interface CreateTaskDialogProps {
}
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder }: CreateTaskDialogProps) {
const { t } = useTranslation('create');
const { data: pieces } = usePieceList();
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs, staleTime: 5 * 60 * 1000 });
const { data: sessionProfiles = [] } = useQuery({
@@ -91,7 +93,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
const handleSubmit = async () => {
if (!form.body.trim()) {
setError('依頼内容は必須です');
setError(t('errors.bodyRequired'));
return;
}
try {
@@ -111,7 +113,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
...schedule,
}),
});
if (!res.ok) throw new Error('スケジュール作成に失敗しました');
if (!res.ok) throw new Error(t('errors.scheduleFailed'));
onClose();
return;
}
@@ -153,17 +155,15 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
{initialPiece === 'help' ? 'AI ヘルプに質問' : '新しい Task'}
{initialPiece === 'help' ? t('title.help') : t('title.new')}
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
{initialPiece === 'help'
? '使い方や設計について自由に質問してください'
: '依頼内容を入力して実行'}
{initialPiece === 'help' ? t('subtitle.help') : t('subtitle.new')}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label="閉じる"
aria-label={t('close')}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -176,7 +176,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<div className="flex flex-col gap-4">
{/* Textarea */}
<div>
<label className="block text-[13px] text-slate-600 mb-1.5"></label>
<label className="block text-[13px] text-slate-600 mb-1.5">{t('body.label')}</label>
<textarea
autoFocus
value={form.body}
@@ -189,9 +189,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
}}
rows={8}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
placeholder={placeholder ?? (initialPiece === 'help'
? '例: 「ユーザーフォルダの memory/ と AGENTS.md の違いは?」 / 「MCP サーバーを個人で追加するには?」 / 「自分の最近のタスクは?」'
: '依頼内容を入力してください (Ctrl+Enter で送信)')}
placeholder={placeholder ?? (initialPiece === 'help' ? t('body.placeholderHelp') : t('body.placeholder'))}
/>
</div>
@@ -202,7 +200,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
{missingMcp.length > 0 && (
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
<div>
<strong> MCP :</strong> {missingMcp.join(', ')}
<strong>{t('mcp.required')}</strong> {missingMcp.join(', ')}
</div>
<div className="flex flex-wrap gap-2">
{missingMcp.map((id) => (
@@ -213,12 +211,12 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
target="_blank"
rel="noopener noreferrer"
>
{id}
{t('mcp.connect', { id })}
</a>
))}
</div>
<div className="text-2xs text-yellow-700 dark:text-yellow-300">
waiting_human
{t('mcp.note')}
</div>
</div>
)}
@@ -229,27 +227,27 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onClick={() => setShowAdvanced(prev => !prev)}
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
>
{showAdvanced ? '詳細設定を隠す' : '詳細設定を開く'}
{showAdvanced ? t('advanced.hide') : t('advanced.show')}
</button>
{showAdvanced && (
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
{/* Row 1: Piece, Profile, Priority */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.taskType')}</label>
<select
value={form.piece}
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value="auto"></option>
<option value="auto">{t('advanced.auto')}</option>
{resolvedPieces.map(p => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.profile')}</label>
<select
value={form.profile}
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value }))}
@@ -261,7 +259,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
</select>
</div>
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.priority')}</label>
<select
value={form.priority}
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value }))}
@@ -277,7 +275,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
{/* Row 2: Output Format, Ask Policy */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.outputFormat')}</label>
<select
value={form.outputFormat}
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value }))}
@@ -289,14 +287,14 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
</select>
</div>
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.askPolicy')}</label>
<select
value={form.askPolicy}
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value="low">low ()</option>
<option value="high">high ()</option>
<option value="low">{t('advanced.askLow')}</option>
<option value="high">{t('advanced.askHigh')}</option>
</select>
</div>
</div>
@@ -310,7 +308,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onChange={e => setMcpDisabled(e.target.checked)}
className="rounded"
/>
MCP ()
{t('advanced.disableMcp')}
</label>
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
<input
@@ -319,14 +317,14 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onChange={e => setSkillsDisabled(e.target.checked)}
className="rounded"
/>
Skills
{t('advanced.disableSkills')}
</label>
</div>
{/* Browser Session (only if active profiles exist) */}
{activeSessionProfiles.length > 0 && (
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.browserSession')}</label>
<select
value={browserSessionProfileId ?? ''}
onChange={e =>
@@ -334,32 +332,32 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value=""></option>
<option value="">{t('advanced.none')}</option>
{activeSessionProfiles.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
<p className="text-2xs text-slate-400 mt-1">
使
{t('advanced.browserSessionHint')}
</p>
</div>
)}
{/* Visibility */}
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('visibility.label')}</label>
<div className="flex gap-3 text-xs">
<label className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
{t('visibility.private')}
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
{t('visibility.org')}
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
{t('visibility.public')}
</label>
</div>
{visibility === 'org' && orgs.length > 1 && (
@@ -372,10 +370,10 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
</select>
)}
{visibility === 'org' && orgs.length === 1 && (
<div className="mt-1 text-2xs text-slate-500">: {orgs[0].orgName}</div>
<div className="mt-1 text-2xs text-slate-500">{t('visibility.sharedWith', { org: orgs[0].orgName })}</div>
)}
{visibility === 'org' && orgs.length === 0 && (
<div className="mt-1 text-2xs text-slate-400">使 Gitea </div>
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
)}
</div>
@@ -389,7 +387,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onChange={e => setIsScheduled(e.target.checked)}
className="rounded"
/>
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer"></label>
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer">{t('schedule.enable')}</label>
</div>
{isScheduled && (
<ScheduleFields schedule={schedule} onChange={setSchedule} />
@@ -405,7 +403,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<div className="flex justify-end items-center gap-2 pt-1">
<Dialog.Close asChild>
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
{t('cancel')}
</button>
</Dialog.Close>
<button
@@ -413,7 +411,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onClick={() => void handleSubmit()}
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
>
{submitting ? '作成中...' : isScheduled ? 'スケジュール作成' : 'Task 作成'}
{submitting ? t('submitting') : isScheduled ? t('submitSchedule') : t('submit')}
</button>
</div>
</div>
+16 -12
View File
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
interface ScheduleState {
scheduleType: string;
hour: number;
@@ -14,26 +16,28 @@ interface ScheduleFieldsProps {
}
export function ScheduleFields({ schedule, onChange }: ScheduleFieldsProps) {
const { t } = useTranslation('create');
const weekdays = t('schedule.weekdays', { returnObjects: true }) as string[];
return (
<div className="pl-4 border-l-2 border-blue-200 space-y-2 mt-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="block text-xs text-slate-600 mb-1"></label>
<label className="block text-xs text-slate-600 mb-1">{t('schedule.type')}</label>
<select
value={schedule.scheduleType}
onChange={e => onChange(p => ({ ...p, scheduleType: e.target.value }))}
className="w-full px-2 py-1.5 border border-slate-200 rounded-lg text-xs"
>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
<option value="cron">Cron式</option>
<option value="once"></option>
<option value="daily">{t('schedule.daily')}</option>
<option value="weekly">{t('schedule.weekly')}</option>
<option value="monthly">{t('schedule.monthly')}</option>
<option value="cron">{t('schedule.cron')}</option>
<option value="once">{t('schedule.once')}</option>
</select>
</div>
{schedule.scheduleType !== 'cron' && schedule.scheduleType !== 'once' && (
<div>
<label className="block text-xs text-slate-600 mb-1"></label>
<label className="block text-xs text-slate-600 mb-1">{t('schedule.time')}</label>
<div className="flex items-center gap-1">
<input
type="number"
@@ -58,13 +62,13 @@ export function ScheduleFields({ schedule, onChange }: ScheduleFieldsProps) {
</div>
{schedule.scheduleType === 'weekly' && (
<div>
<label className="block text-xs text-slate-600 mb-1"></label>
<label className="block text-xs text-slate-600 mb-1">{t('schedule.dayOfWeek')}</label>
<select
value={schedule.dayOfWeek}
onChange={e => onChange(p => ({ ...p, dayOfWeek: Number(e.target.value) }))}
className="w-full px-2 py-1.5 border border-slate-200 rounded-lg text-xs"
>
{['日曜', '月曜', '火曜', '水曜', '木曜', '金曜', '土曜'].map((d, i) => (
{weekdays.map((d, i) => (
<option key={i} value={i}>{d}</option>
))}
</select>
@@ -72,7 +76,7 @@ export function ScheduleFields({ schedule, onChange }: ScheduleFieldsProps) {
)}
{schedule.scheduleType === 'monthly' && (
<div>
<label className="block text-xs text-slate-600 mb-1"></label>
<label className="block text-xs text-slate-600 mb-1">{t('schedule.dayOfMonth')}</label>
<input
type="number"
min={1}
@@ -85,7 +89,7 @@ export function ScheduleFields({ schedule, onChange }: ScheduleFieldsProps) {
)}
{schedule.scheduleType === 'cron' && (
<div>
<label className="block text-xs text-slate-600 mb-1">Cron式</label>
<label className="block text-xs text-slate-600 mb-1">{t('schedule.cron')}</label>
<input
value={schedule.cronExpression}
onChange={e => onChange(p => ({ ...p, cronExpression: e.target.value }))}
@@ -96,7 +100,7 @@ export function ScheduleFields({ schedule, onChange }: ScheduleFieldsProps) {
)}
{schedule.scheduleType === 'once' && (
<div>
<label className="block text-xs text-slate-600 mb-1"></label>
<label className="block text-xs text-slate-600 mb-1">{t('schedule.runAt')}</label>
<input
type="datetime-local"
value={schedule.scheduledAt}
+18 -16
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { DashboardWidgetKind } from '../../api';
import { useBackdropClose } from '../../lib/useBackdropClose';
@@ -9,13 +10,6 @@ interface Props {
onCreate: (input: { slug: string; title: string; kind: DashboardWidgetKind }) => Promise<void>;
}
// Default titles per kind so the user can pick a kind and get a sensible
// title for free. Either field can be overridden before submit.
const KIND_TITLES: Record<DashboardWidgetKind, string> = {
'markdown': '',
'node-status': 'ノード状況',
};
function slugify(title: string, existing: string[]): string {
const base = title
.toLowerCase()
@@ -33,6 +27,7 @@ function slugify(title: string, existing: string[]): string {
}
export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Props) {
const { t } = useTranslation('dashboard');
const [title, setTitle] = useState('');
const [kind, setKind] = useState<DashboardWidgetKind>('markdown');
const [saving, setSaving] = useState(false);
@@ -40,9 +35,16 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
if (!open) return null;
// Default titles per kind so the user can pick a kind and get a sensible
// title for free. Either field can be overridden before submit.
const kindDefaultTitle: Record<DashboardWidgetKind, string> = {
'markdown': '',
'node-status': t('kindTitles.nodeStatus'),
};
// Effective title: explicit input wins, otherwise the kind's default
// so the user can submit "node-status" without typing anything.
const effectiveTitle = title.trim() || KIND_TITLES[kind];
const effectiveTitle = title.trim() || kindDefaultTitle[kind];
const canSubmit = !saving && effectiveTitle.length > 0;
return (
@@ -54,17 +56,17 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
className="bg-surface rounded-md shadow-lg w-[320px] p-4 flex flex-col gap-3"
onClick={(e) => e.stopPropagation()}
>
<div className="text-sm font-semibold"></div>
<div className="text-sm font-semibold">{t('addWidget.title')}</div>
<label className="flex flex-col gap-1 text-[11px] text-slate-600">
{t('addWidget.kindLabel')}
<select
value={kind}
onChange={(e) => setKind(e.target.value as DashboardWidgetKind)}
disabled={saving}
className="border border-hairline rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="markdown">Markdown </option>
<option value="node-status"> (NodeStatus)</option>
<option value="markdown">{t('addWidget.kindMarkdown')}</option>
<option value="node-status">{t('addWidget.kindNodeStatus')}</option>
</select>
</label>
<input
@@ -74,8 +76,8 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
onChange={(e) => setTitle(e.target.value)}
placeholder={
kind === 'node-status'
? `タイトル(例: ${KIND_TITLES['node-status']}`
: 'タイトル(例: メモ、ニュース)'
? t('addWidget.titlePlaceholderNodeStatus', { example: kindDefaultTitle['node-status'] })
: t('addWidget.titlePlaceholderMarkdown')
}
maxLength={64}
className="border border-hairline rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-ring"
@@ -87,7 +89,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
disabled={saving}
className="px-3 py-1 text-xs border border-hairline rounded hover:bg-surface-2"
>
{t('addWidget.cancel')}
</button>
<button
type="button"
@@ -106,7 +108,7 @@ export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Prop
}}
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50"
>
{t('addWidget.create')}
</button>
</div>
</div>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownText } from '../../lib/markdown-text';
import type { DashboardWidget } from '../../api';
@@ -9,6 +10,7 @@ interface Props {
}
export function MarkdownWidget({ widget, onSave, onDelete }: Props) {
const { t } = useTranslation('dashboard');
const [editing, setEditing] = useState(false);
const [draftContent, setDraftContent] = useState(widget.markdownContent);
const [saving, setSaving] = useState(false);
@@ -20,13 +22,13 @@ export function MarkdownWidget({ widget, onSave, onDelete }: Props) {
type="button"
onClick={() => { setDraftContent(widget.markdownContent); setEditing(true); }}
className="absolute top-2 right-2 px-2 py-1 text-[11px] bg-canvas border border-hairline rounded hover:bg-surface-2"
aria-label="編集"
aria-label={t('markdown.editAria')}
>
</button>
{widget.markdownContent
? <MarkdownText text={widget.markdownContent} />
: <div className="text-xs text-slate-400 italic">( widget )</div>}
: <div className="text-xs text-slate-400 italic">{t('markdown.emptyHint')}</div>}
</div>
);
}
@@ -53,26 +55,26 @@ export function MarkdownWidget({ widget, onSave, onDelete }: Props) {
}}
className="px-3 py-1 bg-accent text-accent-fg text-xs rounded hover:bg-accent-deep disabled:opacity-50"
>
{t('markdown.save')}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="px-3 py-1 bg-canvas border border-hairline text-xs rounded hover:bg-surface-2"
>
{t('markdown.cancel')}
</button>
<div className="flex-1" />
<button
type="button"
onClick={async () => {
if (!window.confirm(`"${widget.title}" を削除しますか?`)) return;
if (!window.confirm(t('markdown.deleteConfirm', { title: widget.title }))) return;
await onDelete();
}}
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-500/15 rounded"
aria-label="削除"
aria-label={t('markdown.deleteAria')}
>
🗑
{t('markdown.delete')}
</button>
</div>
</div>
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { useNodeStatus, type NodeStatus } from '../../hooks/useNodeStatus';
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
import { useActivePet } from '../../hooks/useActivePet';
@@ -14,23 +15,24 @@ import { PetSprite } from '../pets/PetSprite';
* already maintaining — see hooks/useNodeStatus for the rationale.
*/
export function NodeStatusWidget() {
const { t } = useTranslation('dashboard');
const { nodes, isLoading, isError, isUnavailable } = useNodeStatus();
if (isLoading) return <div className="text-xs text-slate-500 p-3">...</div>;
if (isLoading) return <div className="text-xs text-slate-500 p-3">{t('nodeStatus.loading')}</div>;
if (isUnavailable) {
return (
<div className="text-xs text-slate-500 p-3">
node-status registry <br />
config.yaml provider.workers
{t('nodeStatus.unavailableTitle')}<br />
{t('nodeStatus.unavailableHint')}
</div>
);
}
if (isError) return <div className="text-xs text-red-600 p-3"></div>;
if (isError) return <div className="text-xs text-red-600 p-3">{t('nodeStatus.fetchError')}</div>;
if (nodes.length === 0) {
return (
<div className="text-xs text-slate-500 p-3">
<br />
config.yaml provider.workers
{t('nodeStatus.emptyTitle')}<br />
{t('nodeStatus.emptyHint')}
</div>
);
}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDashboardWidgets } from '../../hooks/useDashboardWidgets';
import { WidgetTabBar, WORKER_TAB_SLUG } from './WidgetTabBar';
import { WorkerStatusWidget } from './WorkerStatusWidget';
@@ -20,6 +21,7 @@ export function SideInfoPanel({
collapsed,
onToggleCollapse,
}: Props) {
const { t } = useTranslation('dashboard');
const { widgets, create, update, remove } = useDashboardWidgets();
const [localActive, setLocalActive] = useState<string>(WORKER_TAB_SLUG);
const activeSlug = activeSlugProp ?? localActive;
@@ -37,7 +39,7 @@ export function SideInfoPanel({
onSelect={setActive}
onAdd={() => setDialogOpen(true)}
onDeleteWidget={async (w) => {
if (!window.confirm(`"${w.title}" を削除しますか?`)) return;
if (!window.confirm(t('panel.deleteConfirm', { title: w.title }))) return;
await remove.mutateAsync(w.id);
if (activeSlug === w.slug) setActive(WORKER_TAB_SLUG);
}}
@@ -64,7 +66,7 @@ export function SideInfoPanel({
/>
)}
{activeSlug !== WORKER_TAB_SLUG && !activeWidget && (
<div className="p-3 text-xs text-slate-500"></div>
<div className="p-3 text-xs text-slate-500">{t('panel.widgetNotFound')}</div>
)}
</div>
)}
+8 -5
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { DashboardWidget } from '../../api';
export const WORKER_TAB_SLUG = 'worker-status';
@@ -22,6 +23,7 @@ export function WidgetTabBar({
collapsed,
onToggleCollapse,
}: Props) {
const { t } = useTranslation('dashboard');
return (
<div className="flex items-center gap-1 px-1 py-1 border-b border-hairline overflow-x-auto">
<TabButton
@@ -41,9 +43,9 @@ export function WidgetTabBar({
<button
type="button"
onClick={onAdd}
title="ウィジェットを追加"
title={t('tabBar.addWidget')}
className="px-2 py-1 text-xs text-slate-500 hover:text-slate-800 hover:bg-surface-2 rounded"
aria-label="ウィジェットを追加"
aria-label={t('tabBar.addWidget')}
>
+
</button>
@@ -53,7 +55,7 @@ export function WidgetTabBar({
type="button"
onClick={onToggleCollapse}
className="px-2 py-1 text-xs text-slate-500 hover:text-slate-800 hover:bg-surface-2 rounded"
aria-label={collapsed ? '展開' : '折りたたみ'}
aria-label={collapsed ? t('tabBar.expand') : t('tabBar.collapse')}
>
{collapsed ? '▲' : '▼'}
</button>
@@ -65,6 +67,7 @@ export function WidgetTabBar({
function TabButton({
active, onClick, label, onDelete,
}: { active: boolean; onClick: () => void; label: string; onDelete?: () => void }) {
const { t } = useTranslation('dashboard');
// group/tab を親に付け、× は group-hover で表示。タブ自体の active 状態でも常時表示する
// ことで、編集中のタブを誤って閉じる怖さは confirm dialog 側で吸収する。
return (
@@ -87,8 +90,8 @@ function TabButton({
e.stopPropagation();
onDelete();
}}
aria-label="このウィジェットを削除"
title="削除"
aria-label={t('tabBar.deleteWidget')}
title={t('tabBar.delete')}
className={`mr-1 w-4 h-4 inline-flex items-center justify-center rounded text-[11px] leading-none transition-opacity ${
active
? 'opacity-70 hover:opacity-100 hover:bg-white/20'
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useWorkerStatus } from '../../hooks/useWorkerStatus';
import { useActivePet } from '../../hooks/useActivePet';
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
@@ -11,12 +12,13 @@ function usePrefersReducedMotion(): boolean {
}
export function WorkerStatusWidget() {
const { t } = useTranslation('dashboard');
const { workers, isLoading, isError } = useWorkerStatus();
if (isLoading) return <div className="text-xs text-slate-500 p-3">...</div>;
if (isError) return <div className="text-xs text-red-600 p-3"></div>;
if (isLoading) return <div className="text-xs text-slate-500 p-3">{t('worker.loading')}</div>;
if (isError) return <div className="text-xs text-red-600 p-3">{t('worker.fetchError')}</div>;
if (workers.length === 0) {
return <div className="text-xs text-slate-500 p-3">Worker </div>;
return <div className="text-xs text-slate-500 p-3">{t('worker.empty')}</div>;
}
return (
@@ -53,6 +55,7 @@ function WorkerRow({
totalSlots?: number;
online?: boolean;
}) {
const { t } = useTranslation('dashboard');
// Default expanded so the operator sees backend granularity on first
// load. Collapse is a local-only convenience for noisy pools.
const [collapsed, setCollapsed] = useState(false);
@@ -73,7 +76,7 @@ function WorkerRow({
: state === 'running' ? 'bg-emerald-500' : 'bg-slate-300';
const showPet = pet?.pet && pet.imageUrl;
const hasBackends = proxy && Array.isArray(backends) && backends.length > 0;
const proxyAriaLabel = hasBackends ? (collapsed ? '展開する' : '折りたたむ') : undefined;
const proxyAriaLabel = hasBackends ? (collapsed ? t('worker.expand') : t('worker.collapse')) : undefined;
// Slot caption: only render when the registry has produced a usable
// totalSlots figure. Unset (= no probe row) and 0 (= probe row but
// /slots was empty) both suppress the caption so we don't paint a
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
import { usePieceList } from '../../hooks/usePieces';
@@ -23,6 +24,7 @@ export function ContinueWithPieceDialog({
prevJob,
onClose,
}: ContinueWithPieceDialogProps) {
const { t } = useTranslation('detail');
const [piece, setPiece] = useState<string>(prevJob.pieceName);
const [instruction, setInstruction] = useState<string>('');
const [resultExpanded, setResultExpanded] = useState<boolean>(false);
@@ -74,10 +76,10 @@ export function ContinueWithPieceDialog({
<div className="flex items-center gap-3 px-5 py-4 border-b border-hairline">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-800">
Task #{taskId} piece
{t('continue.heading', { id: taskId })}
</div>
<div className="text-2xs text-slate-500 mt-0.5">
workspace (output/ piece )
{t('continue.workspaceNote')}
</div>
</div>
<button
@@ -103,7 +105,7 @@ export function ContinueWithPieceDialog({
aria-expanded={resultExpanded}
>
<span>
piece "{prevJob.pieceName}" {prevResult.kind === 'ask' ? '質問' : '結果'}
{t('continue.prevPiece', { name: prevJob.pieceName })} {prevResult.kind === 'ask' ? t('continue.question') : t('continue.result')}
</span>
<span className="text-slate-400 normal-case font-normal">{resultExpanded ? '▼' : '▶'}</span>
</button>
@@ -129,7 +131,7 @@ export function ContinueWithPieceDialog({
{resolvePieceOptions(piecesQuery.data ?? []).map(p => (
<option key={p.name} value={p.name}>
{p.name}
{p.name === prevJob.pieceName ? ' (現在)' : ''}
{p.name === prevJob.pieceName ? ' ' + t('continue.current') : ''}
{p.custom ? ' [user]' : ''}
</option>
))}
@@ -138,7 +140,7 @@ export function ContinueWithPieceDialog({
<div className="flex flex-col gap-1">
<label htmlFor="continue-instruction" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
<span className="text-red-500">*</span>
{t('continue.newInstruction')} <span className="text-red-500">*</span>
</label>
<textarea
id="continue-instruction"
@@ -146,7 +148,7 @@ export function ContinueWithPieceDialog({
onChange={e => setInstruction(e.target.value)}
autoFocus
rows={5}
placeholder="例: output/manual.md を使ってサーバー foo.example.com をセットアップして"
placeholder={t('continue.instructionPlaceholder')}
className="px-3 py-2 rounded-md border border-hairline text-[13px] resize-y focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
/>
</div>
@@ -165,7 +167,7 @@ export function ContinueWithPieceDialog({
onClick={onClose}
className="px-3 py-1.5 text-xs font-medium rounded-md text-slate-600 hover:text-slate-900 hover:bg-surface-2 transition-colors"
>
{t('continue.cancel')}
</button>
<button
type="button"
@@ -173,7 +175,7 @@ export function ContinueWithPieceDialog({
disabled={submitDisabled}
className="px-3 py-1.5 text-xs font-semibold rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{continueMutation.isPending ? '起動中...' : 'Continue'}
{continueMutation.isPending ? t('continue.starting') : 'Continue'}
</button>
</div>
</div>
+18 -14
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DetailTabId } from '../../lib/urlState';
import { shareTask, unshareTask } from '../../api';
interface Tab { id: DetailTabId; label: string; }
interface Tab { id: DetailTabId; labelKey: string; }
interface DetailHeaderProps {
title: string;
@@ -31,6 +32,7 @@ interface DetailHeaderProps {
}
function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; shareToken: string | null; onShareChange?: () => void }) {
const { t } = useTranslation('detail');
const [copied, setCopied] = useState(false);
const qc = useQueryClient();
@@ -68,8 +70,8 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh
<button
onClick={() => shareMutation.mutate()}
disabled={shareMutation.isPending}
title={shareMutation.isPending ? '共有中...' : '公開リンクを発行'}
aria-label="公開リンクを発行"
title={shareMutation.isPending ? t('share.sharing') : t('share.publish')}
aria-label={t('share.publish')}
className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`}
>
{shareMutation.isPending ? (
@@ -100,8 +102,8 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh
<div className="flex items-center gap-1">
<button
onClick={handleCopy}
title={copied ? 'コピーしました' : '共有リンクをコピー'}
aria-label="共有リンクをコピー"
title={copied ? t('share.copied') : t('share.copy')}
aria-label={t('share.copy')}
className={`${iconBtnBase} ${copied ? 'border-emerald-200 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300' : 'border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface'}`}
>
{copied ? (
@@ -118,8 +120,8 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh
<button
onClick={() => unshareMutation.mutate()}
disabled={unshareMutation.isPending}
title="共有を停止"
aria-label="共有を停止"
title={t('share.stop')}
aria-label={t('share.stop')}
className={`${iconBtnBase} border-hairline bg-canvas text-slate-500 hover:text-red-700 dark:hover:text-red-300 hover:border-red-200 hover:bg-red-50 dark:hover:bg-red-500/15`}
>
{unshareMutation.isPending ? (
@@ -138,6 +140,7 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh
}
function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string | null; onClick: () => void }) {
const { t } = useTranslation('detail');
// Mirror the spec/backend TERMINAL list (worker maps abort outcomes to
// 'failed', so 'aborted' is intentionally absent).
const TERMINAL = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
@@ -148,8 +151,8 @@ function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string
<button
onClick={onClick}
disabled={!enabled}
title={enabled ? '別 piece で続ける' : 'タスクが進行中のため続行できません'}
aria-label="別 piece で続ける"
title={enabled ? t('continue.label') : t('continue.disabled')}
aria-label={t('continue.label')}
className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`}
>
{/* arrow → divider: 「次のフェーズへ進む」cue。FileBrowser の refresh
@@ -164,6 +167,7 @@ function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string
}
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue }: DetailHeaderProps) {
const { t } = useTranslation('detail');
// Mobile (< sm) hides the close button and tab bar because App.tsx
// renders its own mobile-level top tab bar with the same controls.
// Two close buttons / two tab bars on iPhone was visually redundant.
@@ -194,8 +198,8 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
{onWidthToggle && detailWidth && (
<button
onClick={onWidthToggle}
title={detailWidth === 'focused' ? '標準表示に戻る' : '集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)'}
aria-label={detailWidth === 'focused' ? '標準表示に戻る' : '集中モードに切替'}
title={detailWidth === 'focused' ? t('focus.toStandard') : t('focus.toFocused')}
aria-label={detailWidth === 'focused' ? t('focus.toStandard') : t('focus.toFocusedShort')}
aria-pressed={detailWidth === 'focused'}
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-500 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
@@ -214,7 +218,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
)}
<button
onClick={onClose}
aria-label="詳細パネルを閉じる"
aria-label={t('panel.close')}
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
@@ -223,7 +227,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
</button>
</div>
</div>
<div role="tablist" aria-label="詳細タブ" className="hidden sm:flex gap-4 -mb-px">
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex gap-4 -mb-px">
{tabs.map(tab => {
const active = activeTab === tab.id;
const pending = active && tabTransitionPending;
@@ -239,7 +243,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
}`}
>
{tab.label}
{t(tab.labelKey)}
{pending && (
<span
aria-hidden="true"
+23 -21
View File
@@ -1,4 +1,5 @@
import { useState, useDeferredValue } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { LocalTask, LocalFileEntry, SubtaskActivity, Visibility, fetchMyOrgs, updateLocalTask } from '../../api';
import { relativeTime } from '../../lib/utils';
@@ -45,13 +46,13 @@ interface LocalDetailPanelProps {
onShareChange?: () => void;
}
const LOCAL_TABS: Array<{ id: DetailTabId; label: string }> = [
{ id: 'overview', label: '概要' },
{ id: 'activity', label: '進捗' },
{ id: 'files', label: 'ファイル' },
{ id: 'trace', label: 'トレース' },
{ id: 'browser', label: 'ブラウザ' },
{ id: 'ssh', label: 'SSH' },
const LOCAL_TABS: Array<{ id: DetailTabId; labelKey: string }> = [
{ id: 'overview', labelKey: 'tabs.overview' },
{ id: 'activity', labelKey: 'tabs.activity' },
{ id: 'files', labelKey: 'tabs.files' },
{ id: 'trace', labelKey: 'tabs.trace' },
{ id: 'browser', labelKey: 'tabs.browser' },
{ id: 'ssh', labelKey: 'tabs.ssh' },
];
export function LocalDetailPanel({
@@ -61,6 +62,7 @@ export function LocalDetailPanel({
onRefresh, isRefreshing, subtaskActivities, onSubtaskFilePreview,
shareToken, onShareChange,
}: LocalDetailPanelProps) {
const { t } = useTranslation('detail');
// Deferred tab id for content rendering. The tab indicator (DetailHeader)
// uses `detailTab` (immediate) so the underline jumps on click. The heavy
// content area below uses `deferredDetailTab` so expensive panels
@@ -132,7 +134,7 @@ export function LocalDetailPanel({
const handleDelete = async () => {
if (!onDelete) return;
if (!window.confirm('このタスクを削除しますか?この操作は取り消せません。')) return;
if (!window.confirm(t('panel.deleteConfirm'))) return;
setDeleting(true);
try {
await onDelete();
@@ -148,7 +150,7 @@ export function LocalDetailPanel({
<div className="flex flex-col h-full overflow-hidden bg-surface">
<DetailHeader
title={`Task #${taskId}`}
subtitle="ローカルワークスペース"
subtitle={t('panel.subtitle')}
tabs={visibleTabs}
activeTab={detailTab}
tabTransitionPending={tabTransitionPending}
@@ -180,18 +182,18 @@ export function LocalDetailPanel({
{task && (
<>
<div className="mb-2 flex items-center gap-2 text-2xs text-slate-500 flex-wrap">
<span>: <b>{ownerDisplayName(task.ownerId, task.ownerName)}</b></span>
<span>{t('panel.author')}: <b>{ownerDisplayName(task.ownerId, task.ownerName)}</b></span>
<span>·</span>
<span>{relativeTime(task.createdAt)}</span>
{task.visibility === 'private' && <span>· 🔒 </span>}
{task.visibility === 'private' && <span>· 🔒 {t('visibility.private')}</span>}
{task.visibility === 'org' && <span>· 🏢 {task.visibilityScopeOrgName ?? 'org'}</span>}
{task.visibility === 'public' && <span>· 🌐 </span>}
{task.visibility === 'public' && <span>· 🌐 {t('visibility.public')}</span>}
{canEditVisibility && !editingVisibility && (
<button
className="ml-2 underline text-slate-500 hover:text-slate-700"
onClick={handleStartEdit}
>
{t('panel.change')}
</button>
)}
</div>
@@ -204,7 +206,7 @@ export function LocalDetailPanel({
checked={editVisibility === 'private'}
onChange={() => setEditVisibility('private')}
/>
🔒
🔒 {t('visibility.private')}
</label>
<label className="flex items-center gap-1">
<input
@@ -216,7 +218,7 @@ export function LocalDetailPanel({
}}
disabled={orgs.length === 0}
/>
🏢
🏢 {t('visibility.org')}
</label>
<label className="flex items-center gap-1">
<input
@@ -224,7 +226,7 @@ export function LocalDetailPanel({
checked={editVisibility === 'public'}
onChange={() => setEditVisibility('public')}
/>
🌐
🌐 {t('visibility.public')}
</label>
</div>
{editVisibility === 'org' && orgs.length > 1 && (
@@ -237,10 +239,10 @@ export function LocalDetailPanel({
</select>
)}
{editVisibility === 'org' && orgs.length === 1 && (
<div className="mt-1 text-2xs text-slate-500">: {orgs[0].orgName}</div>
<div className="mt-1 text-2xs text-slate-500">{t('visibility.sharedWith', { org: orgs[0].orgName })}</div>
)}
{editVisibility === 'org' && orgs.length === 0 && (
<div className="mt-1 text-2xs text-slate-400">使 Gitea </div>
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
)}
{editError && <div className="mt-1 text-2xs text-red-600">{editError}</div>}
<div className="mt-2 flex gap-2">
@@ -249,14 +251,14 @@ export function LocalDetailPanel({
onClick={() => void handleSaveVisibility()}
className="px-3 h-7 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep transition-colors"
>
{savingVisibility ? '保存中...' : '保存'}
{savingVisibility ? t('panel.saving') : t('panel.save')}
</button>
<button
disabled={savingVisibility}
onClick={() => { setEditingVisibility(false); setEditError(null); }}
className="px-3 h-7 border border-hairline rounded-md text-xs text-slate-600 hover:bg-surface transition-colors"
>
{t('panel.cancel')}
</button>
</div>
</div>
@@ -282,7 +284,7 @@ export function LocalDetailPanel({
onClick={handleDelete}
className="px-3 h-7 bg-canvas border border-red-200 text-red-700 dark:text-red-300 rounded-md text-xs font-medium disabled:opacity-50 hover:bg-red-50 dark:hover:bg-red-500/15 transition-colors"
>
{deleting ? '削除中...' : '削除'}
{deleting ? t('panel.deleting') : t('panel.delete')}
</button>
) : null}
</div>
+20 -20
View File
@@ -1,4 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { POLLING } from '../../../lib/constants.js';
import { usePictureInPicture } from '../../../lib/usePictureInPicture.js';
import { PipButton } from '../../browser/PipButton.js';
@@ -52,6 +53,7 @@ function useReleaseSession(taskId: number) {
* ここではタブ自体を全員に見せて構わない (見えないユーザーには available:false が返る)。
*/
export function BrowserTab({ taskId }: { taskId: number }) {
const { t } = useTranslation('detail');
const { data, isLoading, isError, error } = useTaskSession(taskId);
const release = useReleaseSession(taskId);
const pip = usePictureInPicture(data?.novncPath ?? null, `noVNC — Task #${taskId}`);
@@ -59,7 +61,7 @@ export function BrowserTab({ taskId }: { taskId: number }) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
{t('browser.loading')}
</div>
);
}
@@ -68,7 +70,7 @@ export function BrowserTab({ taskId }: { taskId: number }) {
const msg = error instanceof Error ? error.message : String(error);
return (
<div className="p-4 text-sm text-red-700 dark:text-red-300">
: {msg}
{t('browser.fetchFailed')}: {msg}
</div>
);
}
@@ -77,18 +79,16 @@ export function BrowserTab({ taskId }: { taskId: number }) {
if (data?.reason === 'novnc_not_installed') {
return (
<div className="bg-canvas border border-amber-300 rounded-md p-6 text-sm text-slate-700">
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2">noVNC Web (vnc.html) </p>
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2">{t('browser.novncNotInstalled.title')}</p>
<p className="text-xs leading-relaxed mb-2">
noVNC HTML/JS
<code className="mx-1 px-1 rounded bg-slate-100 font-mono text-2xs">vendor/noVNC/</code>
iframe
{t('browser.novncNotInstalled.body')}
</p>
<p className="text-xs leading-relaxed mb-2">
:
{t('browser.novncNotInstalled.setupIntro')}
</p>
<ul className="list-disc list-inside text-xs leading-relaxed space-y-1">
<li>bare metal / dev : <code className="px-1 rounded bg-slate-100 font-mono text-2xs">scripts/setup-novnc.sh</code> </li>
<li>Docker: 最新の Dockerfile (noVNC tarball builder ) </li>
<li>{t('browser.novncNotInstalled.bareMetal')}</li>
<li>{t('browser.novncNotInstalled.docker')}</li>
</ul>
</div>
);
@@ -96,9 +96,9 @@ export function BrowserTab({ taskId }: { taskId: number }) {
if (data?.reason === 'headless_mode') {
return (
<div className="bg-canvas border border-amber-300 rounded-md p-6 text-sm text-slate-700">
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2">headless Browser 使</p>
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2">{t('browser.headless.title')}</p>
<p className="text-xs leading-relaxed">
Settings Tools Browser Runtime Browser Session Mode novnc Xvfb/x11vnc/websockify
{t('browser.headless.body')}
</p>
</div>
);
@@ -106,18 +106,18 @@ export function BrowserTab({ taskId }: { taskId: number }) {
if (data?.reason === 'display_unavailable') {
return (
<div className="bg-canvas border border-amber-300 rounded-md p-6 text-sm text-slate-700">
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2"></p>
<p className="font-medium text-amber-800 dark:text-amber-300 mb-2">{t('browser.displayUnavailable.title')}</p>
<p className="text-xs leading-relaxed">
Xvfb/x11vnc/websockify
{t('browser.displayUnavailable.body')}
</p>
</div>
);
}
return (
<div className="bg-canvas border border-hairline rounded-md p-6 text-center text-sm text-slate-600">
<p className="font-medium text-slate-800 mb-1"></p>
<p className="font-medium text-slate-800 mb-1">{t('browser.noSession.title')}</p>
<p className="text-xs leading-relaxed">
BrowseWeb (5 )
{t('browser.noSession.body')}
</p>
</div>
);
@@ -139,20 +139,20 @@ export function BrowserTab({ taskId }: { taskId: number }) {
rel="noopener noreferrer"
className="text-2xs text-accent hover:underline"
>
{t('browser.openNewTab')}
</a>
<button
type="button"
onClick={() => {
if (window.confirm('このタスクのブラウザセッションを終了します。よろしいですか?')) {
if (window.confirm(t('browser.releaseConfirm'))) {
release.mutate();
}
}}
disabled={release.isPending}
className="px-2 py-1 rounded-md text-2xs border border-hairline bg-canvas hover:bg-surface text-slate-700 disabled:opacity-50"
title="セッションを destroy する。次回 BrowseWeb 実行時に再生成される"
title={t('browser.releaseTooltip')}
>
{release.isPending ? '終了中…' : 'セッション終了'}
{release.isPending ? t('browser.releasing') : t('browser.release')}
</button>
</div>
</div>
@@ -161,7 +161,7 @@ export function BrowserTab({ taskId }: { taskId: number }) {
className="flex-1 w-full flex items-center justify-center bg-slate-50 text-xs text-slate-500"
style={{ minHeight: '480px' }}
>
PiP
{t('browser.pipActive')}
</div>
) : (
<iframe
+18 -16
View File
@@ -1,4 +1,6 @@
import { 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';
@@ -22,27 +24,27 @@ function describeSessionError(code: string): { msg: string; hardStop: boolean }
switch (code) {
case 'host_key_not_verified':
return {
msg: '接続の host key を検証してください(Settings → SSH Connections → Test',
msg: i18n.t('detail:console.errors.hostKeyNotVerified'),
hardStop: false,
};
case 'no_grant':
return {
msg: 'この接続への権限がありません(admin に grant を依頼してください)',
msg: i18n.t('detail:console.errors.noGrant'),
hardStop: false,
};
case 'host_key_mismatch':
return {
msg: 'host key 不一致(MITM の可能性)。admin に連絡してください',
msg: i18n.t('detail:console.errors.hostKeyMismatch'),
hardStop: true,
};
case 'connection_disabled':
return { msg: 'この接続は無効化されています', hardStop: false };
return { msg: i18n.t('detail:console.errors.disabled'), hardStop: false };
case 'abuse_locked':
return { msg: 'この接続は一時的にロックされています(abuse 検知)', hardStop: false };
return { msg: i18n.t('detail:console.errors.abuseLocked'), hardStop: false };
case 'connection_not_found':
return { msg: '接続が見つかりません', hardStop: false };
return { msg: i18n.t('detail:console.errors.notFound'), hardStop: false };
default:
return { msg: `セッションを開始できませんでした: ${code}`, hardStop: false };
return { msg: i18n.t('detail:console.errors.startFailed', { code }), hardStop: false };
}
}
@@ -98,6 +100,7 @@ function ConnectionPicker({
taskId: number;
onStarted: () => void;
}) {
const { t } = useTranslation('detail');
const { data, isLoading, error } = useQuery({
queryKey: ['ssh', 'connections'],
queryFn: fetchConnections,
@@ -148,7 +151,7 @@ function ConnectionPicker({
if (code === 'connection_change_requires_force') {
setReplaceCandidate(effectiveId);
setErrMsg({
msg: '別の接続のセッションが既に存在します。置き換えて開始できます。',
msg: i18n.t('detail:console.errors.sessionExists'),
hardStop: false,
});
return;
@@ -165,8 +168,7 @@ function ConnectionPicker({
return (
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
<div className="max-w-md text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-4 leading-relaxed">
SSH <code className="font-mono">config.yaml</code> {' '}
<code className="font-mono">ssh.enabled: true</code>
{t('console.sshDisabled')}
</div>
</div>
);
@@ -176,18 +178,18 @@ function ConnectionPicker({
<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">SSH </h3>
<h3 className="text-sm font-semibold text-slate-100">{t('console.startTitle')}</h3>
<p className="text-2xs text-slate-400 mt-0.5">
AI
{t('console.startDesc')}
</p>
</div>
{isLoading && <div className="text-xs text-slate-400">Loading</div>}
{error && <div className="text-xs text-red-400">: {String(error)}</div>}
{error && <div className="text-xs text-red-400">{t('console.loadFailed')}: {String(error)}</div>}
{!isLoading && connections.length === 0 ? (
<div className="text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-3 leading-relaxed">
SSH Settings SSH Connections /grant
{t('console.noConnections')}
</div>
) : (
<>
@@ -210,7 +212,7 @@ function ConnectionPicker({
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 ? '開始中…' : 'セッション開始'}
{submitting ? t('console.starting') : t('console.startSession')}
</button>
{replaceCandidate && (
@@ -220,7 +222,7 @@ function ConnectionPicker({
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>
)}
</>
+5 -3
View File
@@ -1,4 +1,5 @@
import { LinkifiedText } from '../../../lib/linkified-text';
import { useTranslation } from 'react-i18next';
interface OutputTabProps {
outputPreviewName: string;
@@ -7,12 +8,13 @@ interface OutputTabProps {
}
export function OutputTab({ outputPreviewName, outputPreviewContent, onViewFull }: OutputTabProps) {
const { t } = useTranslation('detail');
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex justify-between items-center mb-2">
<div className="font-bold text-[13px] text-slate-800"></div>
<div className="font-bold text-[13px] text-slate-800">{t('output.title')}</div>
{outputPreviewName && (
<button onClick={onViewFull} className="text-2xs text-blue-600 font-bold hover:underline"></button>
<button onClick={onViewFull} className="text-2xs text-blue-600 font-bold hover:underline">{t('output.viewFull')}</button>
)}
</div>
{outputPreviewName ? (
@@ -28,7 +30,7 @@ export function OutputTab({ outputPreviewName, outputPreviewContent, onViewFull
/>
</>
) : (
<div className="text-[13px] text-slate-500"></div>
<div className="text-[13px] text-slate-500">{t('output.empty')}</div>
)}
</div>
);
+27 -30
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief } from '../../../api';
import { StatusBadge } from '../../shared/StatusBadge';
@@ -10,6 +11,7 @@ const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた'];
function FeedbackPanel({ task }: { task: LocalTask }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
const hasFeedback = !!task.feedbackRating;
@@ -50,7 +52,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-700"></span>
<span className="text-sm font-semibold text-slate-700">{t('feedback.title')}</span>
<span className={`text-lg ${task.feedbackRating === 'good' ? 'text-green-500' : 'text-red-500'}`}>
{task.feedbackRating === 'good' ? '👍' : '👎'}
</span>
@@ -59,7 +61,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
onClick={() => setEditing(true)}
className="text-xs text-slate-400 hover:text-slate-600"
>
{t('feedback.change')}
</button>
</div>
{task.feedbackTags && task.feedbackTags.length > 0 && (
@@ -78,7 +80,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="text-sm font-semibold text-slate-700 mb-2"></div>
<div className="text-sm font-semibold text-slate-700 mb-2">{t('feedback.title')}</div>
<div className="flex gap-2 mb-3">
<button
onClick={() => handleRatingClick('good')}
@@ -86,7 +88,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
rating === 'good' ? 'bg-green-50 dark:bg-green-500/15 border-green-300 dark:border-green-500/30 text-green-700 dark:text-green-300' : 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
👍
👍 {t('feedback.good')}
</button>
<button
onClick={() => handleRatingClick('bad')}
@@ -94,7 +96,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
rating === 'bad' ? 'bg-red-50 dark:bg-red-500/15 border-red-300 dark:border-red-500/30 text-red-700 dark:text-red-300' : 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
👎
👎 {t('feedback.bad')}
</button>
</div>
@@ -118,7 +120,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="コメント(任意)"
placeholder={t('feedback.commentPlaceholder')}
maxLength={1000}
rows={2}
className="w-full px-3 py-2 text-xs border border-slate-200 rounded-lg resize-none focus:outline-none focus:ring-1 focus:ring-accent-ring mb-2"
@@ -129,7 +131,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
onClick={() => { setEditing(false); setRating(task.feedbackRating ?? null); setSelectedTags(task.feedbackTags ?? []); setComment(task.feedbackComment ?? ''); }}
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-600"
>
{t('feedback.cancel')}
</button>
)}
<button
@@ -137,7 +139,7 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
disabled={mutation.isPending}
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
>
{mutation.isPending ? '送信中...' : '送信'}
{mutation.isPending ? t('feedback.submitting') : t('feedback.submit')}
</button>
</div>
</>
@@ -152,21 +154,17 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
* the user can edit them here to anchor or correct the agent. Always
* shown so the user can guide the agent before the conversation drifts.
*/
const MISSION_FIELDS: Array<{
key: keyof MissionBrief;
label: string;
placeholder: string;
emptyHint: string;
}> = [
{ key: 'goal', label: '目標', placeholder: 'このタスクの本質的な目標 (Markdown 可)', emptyHint: '未設定 — エージェントが最初に書きます' },
{ key: 'done', label: '完了', placeholder: '完了したマイルストーン (Markdown 箇条書き推奨)', emptyHint: 'まだ何も完了していません' },
{ key: 'open', label: '残タスク', placeholder: '残っている作業 / ブロッカー', emptyHint: '残タスク未記入' },
{ key: 'clarifications', label: '補足・制約', placeholder: '途中で追加された制約・補足', emptyHint: '補足なし' },
const MISSION_FIELDS: Array<{ key: keyof MissionBrief }> = [
{ key: 'goal' },
{ key: 'done' },
{ key: 'open' },
{ key: 'clarifications' },
];
const EMPTY_MISSION: MissionBrief = { goal: '', done: '', open: '', clarifications: '' };
function MissionCard({ task }: { task: LocalTask }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const current = task.missionBrief ?? EMPTY_MISSION;
const [editing, setEditing] = useState(false);
@@ -202,7 +200,7 @@ function MissionCard({ task }: { task: LocalTask }) {
<path d="M3 2v12M3 2h7l-1 2 1 2H3" />
</svg>
<span className="section-label">Mission Brief</span>
<span className="text-[10px] text-slate-400"> </span>
<span className="text-[10px] text-slate-400"> {t('mission.pinnedMemo')}</span>
</div>
{!editing ? (
<button
@@ -210,20 +208,20 @@ function MissionCard({ task }: { task: LocalTask }) {
onClick={() => { setDraft(current); setEditing(true); setError(null); }}
className="px-2 h-7 text-2xs font-medium border border-hairline bg-canvas text-slate-700 hover:bg-surface rounded-md transition-colors"
>
{t('mission.edit')}
</button>
) : null}
</div>
{editing ? (
<div className="flex flex-col gap-2.5">
{MISSION_FIELDS.map(({ key, label, placeholder }) => (
{MISSION_FIELDS.map(({ key }) => (
<div key={key}>
<label className="block text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-1">{label}</label>
<label className="block text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-1">{t(`mission.fields.${key}.label`)}</label>
<textarea
value={draft[key] ?? ''}
onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}
placeholder={placeholder}
placeholder={t(`mission.fields.${key}.placeholder`)}
rows={key === 'goal' ? 2 : 3}
className="w-full px-2.5 py-1.5 text-xs border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent transition-shadow font-mono leading-snug"
/>
@@ -237,7 +235,7 @@ function MissionCard({ task }: { task: LocalTask }) {
disabled={mutation.isPending}
className="px-3 h-7 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors disabled:opacity-50"
>
{t('mission.cancel')}
</button>
<button
type="button"
@@ -245,26 +243,25 @@ function MissionCard({ task }: { task: LocalTask }) {
disabled={mutation.isPending}
className="px-3 h-7 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
>
{mutation.isPending ? '保存中...' : '保存'}
{mutation.isPending ? t('mission.saving') : t('mission.save')}
</button>
</div>
</div>
) : isEmpty ? (
<div className="text-xs text-slate-500 leading-relaxed">
Mission Brief
/ /
{t('mission.emptyHelp')}
</div>
) : (
<div className="flex flex-col gap-2.5">
{MISSION_FIELDS.map(({ key, label, emptyHint }) => {
{MISSION_FIELDS.map(({ key }) => {
const value = current[key];
return (
<div key={key}>
<div className="text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-0.5">{label}</div>
<div className="text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-0.5">{t(`mission.fields.${key}.label`)}</div>
{value ? (
<div className="text-xs text-slate-800 whitespace-pre-wrap leading-snug font-mono">{value}</div>
) : (
<div className="text-2xs text-slate-400 italic">{emptyHint}</div>
<div className="text-2xs text-slate-400 italic">{t(`mission.fields.${key}.emptyHint`)}</div>
)}
</div>
);
@@ -1,4 +1,5 @@
import { LocalTask, SubtaskActivity } from '../../../api';
import { useTranslation } from 'react-i18next';
import { parseActivityLog } from '../../../lib/utils';
import { useLocalActivityLog } from '../../../hooks/useTaskDetail';
import { ActivityTimeline } from '../../activity/ActivityTimeline';
@@ -11,6 +12,7 @@ interface ProgressTabProps {
}
export function ProgressTab({ task, onViewFullLog, subtaskActivities }: ProgressTabProps) {
const { t } = useTranslation('detail');
const hasSubtasks = subtaskActivities && subtaskActivities.length > 0;
const activityLogQuery = useLocalActivityLog(task.id, true);
const activityLog = activityLogQuery.data ?? '';
@@ -21,11 +23,11 @@ export function ProgressTab({ task, onViewFullLog, subtaskActivities }: Progress
<div className="flex flex-col gap-3">
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex justify-between items-center mb-2">
<div className="font-bold text-[13px] text-slate-800"> Timeline</div>
<div className="text-2xs text-slate-400">{activityEvents.length} </div>
<div className="font-bold text-[13px] text-slate-800">{t('progress.timeline')}</div>
<div className="text-2xs text-slate-400">{activityEvents.length} {t('progress.events')}</div>
</div>
<div className="text-xs text-slate-500 mb-3">
{task.latestJob?.currentMovement ? `現在: ${task.latestJob.currentMovement}` : '現在の movement は取得待ちです'}
{task.latestJob?.currentMovement ? t('progress.current', { movement: task.latestJob.currentMovement }) : t('progress.currentPending')}
{task.latestJob?.currentActivity && ['running', 'dispatching'].includes(task.latestJob?.status ?? '') && (
<div className="text-2xs text-slate-400 mt-0.5 font-mono truncate">
{task.latestJob.currentActivity}
@@ -34,19 +36,19 @@ export function ProgressTab({ task, onViewFullLog, subtaskActivities }: Progress
</div>
<ActivityTimeline
events={activityEvents}
emptyLabel={logLoading ? '読み込み中...' : 'まだ進行情報がありません。'}
emptyLabel={logLoading ? t('progress.loading') : t('progress.noProgress')}
/>
</div>
{hasSubtasks && <SubtaskActivitySection subtaskActivities={subtaskActivities!} />}
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex justify-between items-center mb-3">
<div className="font-bold text-[13px] text-slate-800">Raw activity.log</div>
<button onClick={onViewFullLog} className="text-2xs text-blue-600 font-bold hover:underline"></button>
<button onClick={onViewFullLog} className="text-2xs text-blue-600 font-bold hover:underline">{t('progress.viewFull')}</button>
</div>
<pre className="text-xs whitespace-pre-wrap bg-slate-900 text-slate-100 rounded-xl p-3 min-h-[260px] max-h-[520px] overflow-auto font-mono">
{logLoading && !activityLog
? '(activity.log を読み込み中...)'
: (activityLog || '(activity.log がまだありません)').slice(-12000)}
? t('progress.logLoading')
: (activityLog || t('progress.logEmpty')).slice(-12000)}
</pre>
</div>
</div>
@@ -1,4 +1,5 @@
import { SubtaskActivity } from '../../../api';
import { useTranslation } from 'react-i18next';
import { statusTone, formatStatusLabel, parseActivityLog } from '../../../lib/utils';
import { ActivityTimeline } from '../../activity/ActivityTimeline';
@@ -49,6 +50,7 @@ function SubtaskActivitySummary({ activity }: { activity: SubtaskActivity }) {
}
export function SubtaskActivitySection({ subtaskActivities }: SubtaskActivitySectionProps) {
const { t } = useTranslation('detail');
if (subtaskActivities.length === 0) return null;
const completed = subtaskActivities.filter(
@@ -60,8 +62,8 @@ export function SubtaskActivitySection({ subtaskActivities }: SubtaskActivitySec
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-[13px] font-bold text-slate-800"></div>
<div className="text-xs text-slate-500">{completed}/{total} </div>
<div className="text-[13px] font-bold text-slate-800">{t('subtasks.activityTitle')}</div>
<div className="text-xs text-slate-500">{completed}/{total} {t('subtasks.done')}</div>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5 mb-4">
<div className="bg-accent h-1.5 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '../../../i18n';
import { useQuery } from '@tanstack/react-query';
import { POLLING } from '../../../lib/constants.js';
import { SubtaskInfo, SubtaskActivity, SubtaskFiles, fetchSubtaskFiles, subtaskFileRawUrl, fetchSubtaskActivity } from '../../../api';
@@ -29,15 +31,15 @@ interface SubtaskCardProps {
const ACTIVE_STATUSES = new Set(['running', 'waiting_human', 'waiting_subtasks']);
const CATEGORY_LABELS: Record<string, string> = {
output: '出力ファイル',
logs: 'ログ',
input: '入力ファイル',
output: 'Output files',
logs: 'Logs',
input: 'Input files',
};
const CATEGORY_ORDER = ['output', 'logs', 'input'];
function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: number; jobId: string; category: string; files: string[]; onFilePreview?: SubtaskFilePreviewHandler }) {
const label = CATEGORY_LABELS[category] ?? category;
const label = i18n.t('detail:subtasks.category.' + category, { defaultValue: CATEGORY_LABELS[category] ?? category });
return (
<div className="mt-2">
<div className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">{label}</div>
@@ -158,10 +160,10 @@ function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardPr
</div>
)}
{filesLoading && <div className="mt-3 text-xs text-slate-400">...</div>}
{filesLoading && <div className="mt-3 text-xs text-slate-400">{t('subtasks.filesLoading')}</div>}
{hasFiles && (
<div className="mt-3">
<div className="text-2xs font-semibold text-slate-500 mb-1"></div>
<div className="text-2xs font-semibold text-slate-500 mb-1">{t('subtasks.files')}</div>
{CATEGORY_ORDER.map(cat =>
categories[cat] && categories[cat].length > 0 ? (
<FileList key={cat} taskId={taskId} jobId={subtask.id} category={cat} files={categories[cat]} onFilePreview={onFilePreview} />
@@ -173,7 +175,7 @@ function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardPr
{subtask.children && subtask.children.length > 0 && (
<div className="mt-3">
<div className="text-2xs font-semibold text-slate-500 mb-1">
({subtask.childCompleted ?? 0}/{subtask.childCount ?? subtask.children.length} )
{t('subtasks.childTasks', { done: subtask.childCompleted ?? 0, total: subtask.childCount ?? subtask.children.length })}
</div>
<div className="flex flex-col gap-1.5 ml-2 border-l-2 border-indigo-100 pl-2">
{subtask.children.map(child => (
@@ -190,13 +192,14 @@ function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardPr
}
export function SubtasksPanel({ taskId, subtasks, subtaskCount, subtaskCompleted, subtaskActivities, onFilePreview }: SubtasksPanelProps) {
const { t } = useTranslation('detail');
const progressPct = subtaskCount > 0 ? Math.round((subtaskCompleted / subtaskCount) * 100) : 0;
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-bold text-slate-800"></div>
<div className="text-xs text-slate-500">{subtaskCompleted}/{subtaskCount} </div>
<div className="text-sm font-bold text-slate-800">{t('subtasks.title')}</div>
<div className="text-xs text-slate-500">{subtaskCompleted}/{subtaskCount} {t('subtasks.done')}</div>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5 mb-4">
<div className="bg-accent h-1.5 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
@@ -1,4 +1,5 @@
import { LocalTaskComment } from '../../../api';
import { useTranslation } from 'react-i18next';
import { MarkdownText } from '../../../lib/markdown-text';
// Comment kinds rendered here:
@@ -6,6 +7,7 @@ import { MarkdownText } from '../../../lib/markdown-text';
// `handoff` (system marker for /continue) → rendered as a horizontal
// divider instead of a card.
export function TimelineTab({ comments }: { comments: LocalTaskComment[] }) {
const { t } = useTranslation('detail');
return (
<div className="flex flex-col gap-2">
{comments.map(c => {
@@ -29,7 +31,7 @@ export function TimelineTab({ comments }: { comments: LocalTaskComment[] }) {
</div>
);
})}
{comments.length === 0 && <div className="text-[13px] text-slate-500"></div>}
{comments.length === 0 && <div className="text-[13px] text-slate-500">{t('timeline.empty')}</div>}
</div>
);
}
+7 -5
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { fetchLocalFileContent } from '../../../api';
@@ -195,6 +196,7 @@ interface TraceTabProps {
}
export function TraceTab({ taskId }: TraceTabProps) {
const { t } = useTranslation('detail');
const [refreshKey, setRefreshKey] = useState(0);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [enabledCategories, setEnabledCategories] = useState<Set<string>>(
@@ -323,18 +325,18 @@ export function TraceTab({ taskId }: TraceTabProps) {
}
if (isLoading) {
return <div className="text-[13px] text-slate-500 p-4">...</div>;
return <div className="text-[13px] text-slate-500 p-4">{t('trace.loading')}</div>;
}
if (error) {
return <div className="text-[13px] text-red-600 p-4">: {String(error)}</div>;
return <div className="text-[13px] text-red-600 p-4">{t('trace.error')}: {String(error)}</div>;
}
if (summary.events.length === 0) {
return (
<div className="text-xs text-slate-500 p-4 leading-relaxed">
<div className="section-label mb-1.5">no trace yet</div>
events.jsonl engine
{t('trace.empty')}
</div>
);
}
@@ -394,7 +396,7 @@ export function TraceTab({ taskId }: TraceTabProps) {
<button
onClick={() => setRefreshKey((k) => k + 1)}
className="h-7 w-7 flex items-center justify-center text-xs border border-hairline rounded-md text-slate-500 bg-canvas hover:bg-surface transition-colors"
title="手動更新(自動 5 秒ごとにも更新されます)"
title={t('trace.refreshTooltip')}
>
</button>
@@ -444,7 +446,7 @@ export function TraceTab({ taskId }: TraceTabProps) {
})}
</div>
<div className="text-[10px] text-slate-500 mt-1.5">
(cache hit max )
{t('trace.totalTime')}
</div>
</div>
)}
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { ConsoleSessionApi } from '../../../../hooks/useConsoleSession';
import { KEY_BYTES, type KeyId } from './keys';
@@ -8,14 +9,15 @@ interface Props {
const BUTTONS: Array<{ id: KeyId; label: string; ariaLabel: string }> = [
{ id: 'esc', label: 'Esc', ariaLabel: 'Esc' },
{ id: 'tab', label: 'Tab', ariaLabel: 'Tab' },
{ id: 'arrow-left', label: '←', ariaLabel: '' },
{ id: 'arrow-down', label: '↓', ariaLabel: '' },
{ id: 'arrow-up', label: '↑', ariaLabel: '' },
{ id: 'arrow-right', label: '→', ariaLabel: '' },
{ id: 'arrow-left', label: '←', ariaLabel: 'Left' },
{ id: 'arrow-down', label: '↓', ariaLabel: 'Down' },
{ id: 'arrow-up', label: '↑', ariaLabel: 'Up' },
{ id: 'arrow-right', label: '→', ariaLabel: 'Right' },
{ id: 'ctrl-c', label: '^C', ariaLabel: 'Ctrl+C' },
];
export function MobileKeyboardBar({ session }: Props) {
const { t } = useTranslation('detail');
const handleKey = (id: KeyId) => {
session.send(KEY_BYTES[id]);
};
@@ -32,7 +34,7 @@ export function MobileKeyboardBar({ session }: Props) {
return (
<div
role="toolbar"
aria-label="ターミナルキーボード補助"
aria-label={t('console.keyboardBar')}
className="flex gap-px bg-slate-900 border-t border-slate-700 px-1 flex-shrink-0"
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
@@ -40,7 +42,7 @@ export function MobileKeyboardBar({ session }: Props) {
<button
key={btn.id}
type="button"
aria-label={btn.ariaLabel}
aria-label={t('console.keyAria.' + btn.id, { defaultValue: btn.ariaLabel })}
onClick={() => handleKey(btn.id)}
className="h-11 flex-1 flex items-center justify-center text-sm font-mono text-slate-200 bg-slate-800 active:bg-slate-700 transition-colors rounded-sm"
>
@@ -49,7 +51,7 @@ export function MobileKeyboardBar({ session }: Props) {
))}
<button
type="button"
aria-label="ペースト"
aria-label={t('console.paste')}
onClick={handlePaste}
className="h-11 flex-1 flex items-center justify-center text-base text-slate-200 bg-slate-800 active:bg-slate-700 transition-colors rounded-sm"
>
@@ -1,4 +1,5 @@
import { useEffect, useState, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import type { TerminalViewHandle } from './TerminalView';
interface Props {
@@ -12,6 +13,7 @@ interface Props {
* accurate enough for human-facing UX.
*/
export function ScrollToBottomButton({ terminalRef }: Props) {
const { t } = useTranslation('detail');
const [scrolledUp, setScrolledUp] = useState(false);
useEffect(() => {
@@ -26,7 +28,7 @@ export function ScrollToBottomButton({ terminalRef }: Props) {
return (
<button
type="button"
aria-label="最新へスクロール"
aria-label={t('console.scrollLatest')}
onClick={() => terminalRef.current?.scrollToBottom()}
className="absolute bottom-3 right-3 z-10 w-11 h-11 rounded-full bg-blue-600 text-white shadow-lg flex items-center justify-center active:bg-blue-700 transition-colors"
>
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { AmazonData } from './types';
function StarRating({ rating }: { rating: number }) {
@@ -10,6 +11,7 @@ function StarRating({ rating }: { rating: number }) {
}
export function AmazonProductsCard({ data, onExpand }: { data: AmazonData; onExpand: () => void }) {
const { t } = useTranslation('embed');
const { query, products } = data;
return (
@@ -17,8 +19,8 @@ export function AmazonProductsCard({ data, onExpand }: { data: AmazonData; onExp
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-sm">&#128722;</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>Amazon : {query}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{products.length}</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.amazon', { query })}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: products.length })}</span>
</div>
{/* Horizontal scroll cards */}
@@ -59,7 +61,7 @@ export function AmazonProductsCard({ data, onExpand }: { data: AmazonData; onExp
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
style={{ fontSize: 11 }}
>
&#9660;
&#9660; {t('expand')}
</button>
</div>
</div>
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import type { AmazonData } from './types';
function StarRating({ rating, reviewCount }: { rating: number; reviewCount?: number }) {
const { t } = useTranslation('embed');
const full = Math.floor(rating);
const half = rating - full >= 0.5;
const stars: string[] = [];
@@ -9,18 +11,19 @@ function StarRating({ rating, reviewCount }: { rating: number; reviewCount?: num
return (
<span className="text-amber-400 text-sm">
{stars.join('')} {rating.toFixed(1)}
{reviewCount != null && <span className="text-slate-400 text-xs ml-1">({reviewCount.toLocaleString()})</span>}
{reviewCount != null && <span className="text-slate-400 text-xs ml-1">{t('reviewCount', { count: reviewCount.toLocaleString() })}</span>}
</span>
);
}
export function AmazonProductsDetail({ data }: { data: AmazonData }) {
const { t } = useTranslation('embed');
const { query, products } = data;
return (
<div className="p-6">
<h2 className="text-lg font-bold text-slate-800 mb-4">
&#128722; Amazon : {query}
&#128722; {t('searchResults.amazon', { query })}
</h2>
<div className="space-y-6">
@@ -60,7 +63,7 @@ export function AmazonProductsDetail({ data }: { data: AmazonData }) {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-amber-400 hover:bg-amber-500 text-slate-900 text-xs font-semibold rounded-lg no-underline transition-colors"
>
Amazon
{t('viewOn.amazon')}
</a>
<a
href={p.keepaDetailUrl}
@@ -68,7 +71,7 @@ export function AmazonProductsDetail({ data }: { data: AmazonData }) {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-lg no-underline transition-colors"
>
Keepa
{t('viewOn.keepa')}
</a>
</div>
</div>
@@ -76,10 +79,10 @@ export function AmazonProductsDetail({ data }: { data: AmazonData }) {
{/* Keepa price graph */}
<div className="mt-4 bg-slate-50 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-2">&#128200; (Keepa)</div>
<div className="text-xs text-slate-500 mb-2">&#128200; {t('priceHistory')}</div>
<img
src={p.keepaGraphUrl}
alt={`${p.title} 価格推移`}
alt={t('priceHistoryAlt', { title: p.title })}
className="w-full rounded"
loading="lazy"
/>
+3 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useCallback, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useBackdropClose } from '../../lib/useBackdropClose';
interface EmbedModalProps {
@@ -9,6 +10,7 @@ interface EmbedModalProps {
}
export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
const { t } = useTranslation('embed');
const backdrop = useBackdropClose(onClose);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
@@ -56,7 +58,7 @@ export function EmbedModal({ open, onClose, children }: EmbedModalProps) {
rounded-full text-slate-500 hover:text-slate-700
transition-colors cursor-pointer border-none text-lg
"
aria-label="閉じる"
aria-label={t('close')}
>
&#10005;
</button>
+5 -3
View File
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import type { MapData } from './types';
export function MapPlacesCard({ data, onExpand }: { data: MapData; onExpand: () => void }) {
const { t } = useTranslation('embed');
const { query, places } = data;
return (
@@ -8,8 +10,8 @@ export function MapPlacesCard({ data, onExpand }: { data: MapData; onExpand: ()
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-sm">&#128205;</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>: {query}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{places.length}</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.map', { query })}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: places.length })}</span>
</div>
{/* Place list */}
@@ -35,7 +37,7 @@ export function MapPlacesCard({ data, onExpand }: { data: MapData; onExpand: ()
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
style={{ fontSize: 11 }}
>
&#9660;
&#9660; {t('expandMap')}
</button>
</div>
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { MapData } from './types';
// Leaflet CDN を動的にロードする
@@ -33,6 +34,7 @@ function loadLeaflet(): Promise<void> {
declare const L: typeof import('leaflet');
export function MapPlacesDetail({ data }: { data: MapData }) {
const { t } = useTranslation('embed');
const { query, places } = data;
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<import('leaflet').Map | null>(null);
@@ -85,7 +87,7 @@ export function MapPlacesDetail({ data }: { data: MapData }) {
return (
<div className="p-6">
<h2 className="text-lg font-bold text-slate-800 mb-4">
&#128205; : {query}
&#128205; {t('searchResults.map', { query })}
</h2>
{/* Leaflet map */}
@@ -115,7 +117,7 @@ export function MapPlacesDetail({ data }: { data: MapData }) {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-lg no-underline transition-colors"
>
OpenStreetMap
{t('viewOn.osm')}
</a>
</div>
))}
+5 -3
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { XPostData } from './types';
function formatNumber(n: number): string {
@@ -7,6 +8,7 @@ function formatNumber(n: number): string {
}
export function XPostsCard({ data, onExpand }: { data: XPostData; onExpand: () => void }) {
const { t } = useTranslation('embed');
const { query, posts } = data;
return (
@@ -14,8 +16,8 @@ export function XPostsCard({ data, onExpand }: { data: XPostData; onExpand: () =
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className="font-bold text-slate-800" style={{ fontSize: 14 }}>&#120143;</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>X : {query}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{posts.length}</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.x', { query })}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: posts.length })}</span>
</div>
{/* Post list */}
@@ -58,7 +60,7 @@ export function XPostsCard({ data, onExpand }: { data: XPostData; onExpand: () =
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
style={{ fontSize: 11 }}
>
&#9660;
&#9660; {t('expand')}
</button>
</div>
</div>
+8 -6
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { XPostData } from './types';
function formatNumber(n: number): string {
@@ -18,12 +19,13 @@ function formatDate(iso: string): string {
}
export function XPostsDetail({ data }: { data: XPostData }) {
const { t } = useTranslation('embed');
const { query, posts } = data;
return (
<div className="p-6">
<h2 className="text-lg font-bold text-slate-800 mb-4">
<span style={{ fontSize: 20 }}>&#120143;</span> X : {query}
<span style={{ fontSize: 20 }}>&#120143;</span> {t('searchResults.x', { query })}
</h2>
<div className="space-y-4">
@@ -54,10 +56,10 @@ export function XPostsDetail({ data }: { data: XPostData }) {
{/* Metrics */}
<div className="flex gap-4 text-slate-400 mb-3" style={{ fontSize: 12 }}>
<span title="いいね">&#9829; {formatNumber(p.likes)}</span>
<span title="リポスト">&#128257; {formatNumber(p.retweets)}</span>
<span title="返信">&#128172; {formatNumber(p.replies)}</span>
<span title="表示">&#128065; {formatNumber(p.views)}</span>
<span title={t('metrics.likes')}>&#9829; {formatNumber(p.likes)}</span>
<span title={t('metrics.retweets')}>&#128257; {formatNumber(p.retweets)}</span>
<span title={t('metrics.replies')}>&#128172; {formatNumber(p.replies)}</span>
<span title={t('metrics.views')}>&#128065; {formatNumber(p.views)}</span>
</div>
{/* Link */}
@@ -67,7 +69,7 @@ export function XPostsDetail({ data }: { data: XPostData }) {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-900 hover:bg-slate-700 text-white text-xs font-semibold rounded-lg no-underline transition-colors"
>
X
{t('viewOn.x')}
</a>
</div>
))}
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import type { YouTubeData } from './types';
export function YouTubeVideosCard({ data, onExpand }: { data: YouTubeData; onExpand: () => void }) {
const { t } = useTranslation('embed');
const { query, videos } = data;
return (
@@ -8,8 +10,8 @@ export function YouTubeVideosCard({ data, onExpand }: { data: YouTubeData; onExp
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-sm">&#9654;</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>YouTube : {query}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{videos.length}</span>
<span className="font-semibold text-slate-700" style={{ fontSize: 13 }}>{t('searchResults.youtube', { query })}</span>
<span className="text-slate-400 ml-auto" style={{ fontSize: 11 }}>{t('count', { count: videos.length })}</span>
</div>
{/* Horizontal scroll thumbnails */}
@@ -62,7 +64,7 @@ export function YouTubeVideosCard({ data, onExpand }: { data: YouTubeData; onExp
className="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer bg-transparent border-none"
style={{ fontSize: 11 }}
>
&#9660;
&#9660; {t('expand')}
</button>
</div>
</div>
@@ -1,12 +1,14 @@
import { useTranslation } from 'react-i18next';
import type { YouTubeData } from './types';
export function YouTubeVideosDetail({ data }: { data: YouTubeData }) {
const { t } = useTranslation('embed');
const { query, videos } = data;
return (
<div className="p-6">
<h2 className="text-lg font-bold text-slate-800 mb-4">
&#9654; YouTube : {query}
&#9654; {t('searchResults.youtube', { query })}
</h2>
<div className="space-y-6">
@@ -55,7 +57,7 @@ export function YouTubeVideosDetail({ data }: { data: YouTubeData }) {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs font-semibold rounded-lg no-underline transition-colors"
>
YouTube
{t('viewOn.youtube')}
</a>
</div>
</div>
+12 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
import { isPreviewable, formatFileDate } from '../../lib/utils';
@@ -17,12 +18,13 @@ interface FileBrowserProps {
type FileSort = 'name' | 'newest';
const SORT_OPTIONS: Array<{ value: FileSort; label: string }> = [
{ value: 'name', label: '名前順' },
{ value: 'newest', label: '新しい順' },
const SORT_OPTIONS: Array<{ value: FileSort; labelKey: string }> = [
{ value: 'name', labelKey: 'sort.name' },
{ value: 'newest', labelKey: 'sort.newest' },
];
function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSort) => void }) {
const { t } = useTranslation('files');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
@@ -62,10 +64,10 @@ function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSo
ref={triggerRef}
type="button"
onClick={() => setOpen(v => !v)}
title={`並び順: ${current.label}`}
title={t('sort.tooltip', { mode: t(current.labelKey) })}
aria-haspopup="true"
aria-expanded={open}
aria-label={`並び順: ${current.label}`}
aria-label={t('sort.tooltip', { mode: t(current.labelKey) })}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:text-slate-900 hover:bg-surface-2'
}`}
@@ -99,7 +101,7 @@ function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSo
: 'text-slate-700 font-medium hover:bg-surface-2'
}`}
>
{o.label}
{t(o.labelKey)}
{selected && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 13l4 4L19 7" />
@@ -142,6 +144,7 @@ export function FileBrowser({
onRefresh,
isRefreshing,
}: FileBrowserProps) {
const { t } = useTranslation('files');
const SECTIONS = ['workspace', 'input', 'output', 'logs'] as const;
const [sort, setSort] = useState<FileSort>('name');
const sortedEntries = useMemo(() => sortEntries(entries, sort), [entries, sort]);
@@ -172,8 +175,8 @@ export function FileBrowser({
onClick={onRefresh}
disabled={isRefreshing}
className={`ml-auto ${iconBtn} disabled:opacity-50`}
title="ファイル一覧を更新"
aria-label="ファイル一覧を更新"
title={t('refresh')}
aria-label={t('refresh')}
>
<svg className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
@@ -272,7 +275,7 @@ export function FileBrowser({
</div>
))}
{entries.length === 0 && (
<div className="text-xs text-slate-500 px-1 py-2"></div>
<div className="text-xs text-slate-500 px-1 py-2">{t('empty')}</div>
)}
</div>
</div>
+20 -11
View File
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '../../i18n';
import { Marked, Renderer } from 'marked';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
@@ -229,6 +231,7 @@ interface MarkdownPreviewProps {
}
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false }: MarkdownPreviewProps): JSX.Element {
const { t } = useTranslation('files');
const truncated = content.slice(0, 100000);
const containerRef = useRef<HTMLDivElement>(null);
const [activeSlug, setActiveSlug] = useState<string>('');
@@ -346,7 +349,7 @@ export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = f
return (
<div className="flex gap-4 items-start">
<aside className="mdxg-outline hidden md:block flex-shrink-0 sticky top-0 max-h-[68vh] overflow-y-auto pr-2 border-r border-hairline" style={{ width: '200px' }}>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide px-2 py-1.5"></div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide px-2 py-1.5">{t('toc')}</div>
<nav>
{outline.map(h => (
<a
@@ -585,7 +588,7 @@ function renderJsonl(content: string): JSX.Element {
}
if (records.length === 0) {
return <p className="text-slate-400 text-sm"></p>;
return <p className="text-slate-400 text-sm">{i18n.t('files:records.empty')}</p>;
}
const columns = [...new Set(records.flatMap(r => Object.keys(r)))];
@@ -620,6 +623,7 @@ function renderJsonl(content: string): JSX.Element {
// --- FilePreview ---
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) {
const { t } = useTranslation('files');
const [mode, setMode] = useState<'view' | 'edit'>('view');
const [editContent, setEditContent] = useState(content);
const [saving, setSaving] = useState(false);
@@ -639,14 +643,14 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
const html = await buildPrintHtml(currentContent, { title: name, imageBaseUrl: markdownImageBaseUrl });
const win = window.open('', '_blank');
if (!win) {
setError('印刷ウィンドウを開けませんでした。ポップアップブロックを解除してください。');
setError(t('print.popupBlocked'));
return;
}
win.document.open();
win.document.write(html);
win.document.close();
} catch (err) {
setError(err instanceof Error ? err.message : '印刷の準備に失敗しました');
setError(err instanceof Error ? err.message : t('print.prepFailed'));
} finally {
setPrinting(false);
}
@@ -682,14 +686,14 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
onClick={() => { setMode('view'); setError(''); }}
className="px-3 h-8 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors"
>
{t('cancel')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 h-8 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 transition-colors"
>
{saving ? '保存中...' : '保存'}
{saving ? t('saving') : t('save')}
</button>
</div>
</div>
@@ -702,7 +706,12 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
return (
<iframe
src={imageSrc}
sandbox="allow-scripts allow-same-origin"
// No allow-same-origin / allow-scripts: workspace HTML is
// untrusted (agent- or web-sourced). The pair would let embedded
// scripts run on our origin and ride the viewer's session. Render
// the markup in an opaque, scriptless sandbox; the raw endpoint
// also sends `Content-Security-Policy: sandbox` as a second layer.
sandbox=""
className="w-full rounded-lg border-0"
style={{ height: '80vh' }}
title={name}
@@ -751,13 +760,13 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
<button
onClick={handlePrint}
disabled={printing}
title="ブラウザの印刷ダイアログから PDF として保存または印刷"
title={t('print.tooltip')}
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 5V2h8v3M4 11H2.5A1.5 1.5 0 0 1 1 9.5v-3A1.5 1.5 0 0 1 2.5 5h11A1.5 1.5 0 0 1 15 6.5v3a1.5 1.5 0 0 1-1.5 1.5H12M4 9.5h8v4.5H4z" />
</svg>
{printing ? '準備中...' : 'PDF / 印刷'}
{printing ? t('print.preparing') : t('print.button')}
</button>
)}
{canEdit && mode === 'view' && (
@@ -768,13 +777,13 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11.5 2.5l2 2L5 13l-2.5.5L3 11l8.5-8.5z" />
</svg>
{t('edit')}
</button>
)}
<button
onClick={onClose}
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors"
aria-label="プレビューを閉じる"
aria-label={t('close')}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
+4 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { PageId } from '../../lib/urlState';
import { useBackdropClose } from '../../lib/useBackdropClose';
@@ -96,6 +97,7 @@ export function NavDrawer({
logoUrl,
returnFocusRef,
}: NavDrawerProps) {
const { t } = useTranslation('layout');
const panelRef = useRef<HTMLDivElement>(null);
const firstItemRef = useRef<HTMLButtonElement>(null);
const backdrop = useBackdropClose(onClose);
@@ -163,7 +165,7 @@ export function NavDrawer({
id="nav-drawer"
role="dialog"
aria-modal="true"
aria-label="ナビゲーション"
aria-label={t('drawer.nav')}
aria-hidden={!open}
tabIndex={-1}
onKeyDown={onPanelKeyDown}
@@ -189,7 +191,7 @@ export function NavDrawer({
v{__APP_VERSION__}
</span>
</div>
<nav className="flex-1 overflow-y-auto py-2" aria-label="メインナビゲーション">
<nav className="flex-1 overflow-y-auto py-2" aria-label={t('nav.mainNav')}>
{visibleNav.map((item, idx) => {
const active = currentPage === item.id;
return (
+3 -1
View File
@@ -1,5 +1,6 @@
// ui/src/components/layout/ResizeHandle.tsx
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
interface ResizeHandleProps {
/** drag 中に呼ばれる。新しい chatPx を渡す。ref-based で React 再 render しない想定。 */
@@ -23,6 +24,7 @@ export function ResizeHandle({
minWorkspacePx,
handlePx,
}: ResizeHandleProps) {
const { t } = useTranslation('layout');
// latest-ref pattern: callback が render 毎に新しくなっても useEffect の
// listener を付け直さずに済む。これが無いと drag 中に listener が外れる。
const onResizeRef = useRef(onResize);
@@ -75,7 +77,7 @@ export function ResizeHandle({
<div
role="separator"
aria-orientation="vertical"
aria-label="Chat と Workspace の幅を調整"
aria-label={t('resize.chatWorkspace')}
onPointerDown={handlePointerDown}
onDoubleClick={onReset}
className="cursor-col-resize bg-transparent hover:bg-slate-300/60 transition-colors flex items-stretch group"
+9 -7
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { type ThemePref, isThemePref, readStoredTheme, setThemePref, THEME_CHANGE_EVENT } from '../../lib/theme';
const ICON_PROPS = {
@@ -13,10 +14,10 @@ const ICON_PROPS = {
'aria-hidden': true,
};
const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
const OPTIONS: Array<{ value: ThemePref; labelKey: string; icon: JSX.Element }> = [
{
value: 'system',
label: 'システム設定に合わせる',
labelKey: 'theme.system',
icon: (
<svg {...ICON_PROPS}>
<rect x="2" y="4" width="20" height="13" rx="2" />
@@ -26,7 +27,7 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
},
{
value: 'light',
label: 'ライト',
labelKey: 'theme.light',
icon: (
<svg {...ICON_PROPS}>
<circle cx="12" cy="12" r="4" />
@@ -36,7 +37,7 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
},
{
value: 'dark',
label: 'ダーク',
labelKey: 'theme.dark',
icon: (
<svg {...ICON_PROPS}>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
@@ -54,6 +55,7 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
* [data-theme] attribute correct on load and on OS changes.
*/
export function ThemeToggle() {
const { t } = useTranslation('layout');
const [pref, setPref] = useState<ThemePref>(() => readStoredTheme());
useEffect(() => {
@@ -75,7 +77,7 @@ export function ThemeToggle() {
return (
<div
role="group"
aria-label="テーマ"
aria-label={t('theme.label')}
className="inline-flex items-center gap-0.5 rounded-md border border-hairline bg-surface p-0.5"
>
{OPTIONS.map((opt) => {
@@ -85,9 +87,9 @@ export function ThemeToggle() {
key={opt.value}
type="button"
onClick={() => choose(opt.value)}
aria-label={opt.label}
aria-label={t(opt.labelKey)}
aria-pressed={active}
title={opt.label}
title={t(opt.labelKey)}
className={`flex items-center justify-center w-6 h-6 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
active
? 'bg-canvas text-slate-900 shadow-sm'
+20 -16
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App';
import { ThemeToggle } from './ThemeToggle';
@@ -18,15 +19,17 @@ interface TopBarProps {
onOpenCommandK?: () => void;
}
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
{ id: 'pieces', label: 'Pieces', adminOnly: false, requiresAuth: false },
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
{ id: 'help', label: 'ヘルプ', adminOnly: false, requiresAuth: false },
{ id: 'userfolder', label: 'ユーザーフォルダ', adminOnly: false, requiresAuth: false },
// labelKey resolves against the `layout` i18n namespace at render time (module
// scope can't call hooks). All consumers translate: TopBar, NavDrawer, App.tsx.
export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean; requiresAuth: boolean }> = [
{ id: 'tasks', labelKey: 'nav.tasks', adminOnly: false, requiresAuth: false },
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
{ id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false },
{ id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false },
{ id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false },
{ id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true },
{ id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false },
{ id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false },
];
export function estimateCollapseThreshold(navCount: number): number {
@@ -73,6 +76,7 @@ export function TopBar({
navDrawerOpen = false,
onOpenCommandK,
}: TopBarProps) {
const { t } = useTranslation('layout');
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
const [showPwChange, setShowPwChange] = useState(false);
@@ -92,7 +96,7 @@ export function TopBar({
ref={hamburgerButtonRef}
type="button"
onClick={onOpenDrawer}
aria-label="メニューを開く"
aria-label={t('nav.openMenu')}
aria-expanded={navDrawerOpen}
aria-haspopup="dialog"
aria-controls="nav-drawer"
@@ -118,7 +122,7 @@ export function TopBar({
</span>
{!compactMode && (
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label="メインナビゲーション">
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label={t('nav.mainNav')}>
{visibleNav.map(item => {
const active = currentPage === item.id;
return (
@@ -133,7 +137,7 @@ export function TopBar({
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
}`}
>
{item.label}
{t(item.labelKey)}
</button>
);
})}
@@ -146,8 +150,8 @@ export function TopBar({
<button
type="button"
onClick={onOpenCommandK}
aria-label="コマンドパレットを開く"
title="コマンドパレット (⌘K)"
aria-label={t('commandPalette.open')}
title={t('commandPalette.title')}
className="hidden sm:inline-flex items-center gap-1 px-2 h-7 rounded-md border border-hairline text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
<span aria-hidden>K</span>
@@ -177,13 +181,13 @@ export function TopBar({
onClick={() => setShowPwChange(true)}
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
{t('user.changePassword')}
</button>
<a
href="/auth/logout"
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
{t('user.logout')}
</a>
</div>
)}
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
/** drag 中に呼ばれる。upperPct (0..100) を渡す。 */
@@ -17,6 +18,7 @@ export function VerticalResizeHandle({
onResize, onResizeEnd, onReset, parentSelector,
minUpperPct = 20, minLowerPct = 15,
}: Props) {
const { t } = useTranslation('layout');
const onResizeRef = useRef(onResize);
const onResizeEndRef = useRef(onResizeEnd);
onResizeRef.current = onResize;
@@ -57,7 +59,7 @@ export function VerticalResizeHandle({
<div
role="separator"
aria-orientation="horizontal"
aria-label="タスクリストと情報パネルの高さを調整"
aria-label={t('resize.listPanel')}
onPointerDown={(e) => {
e.preventDefault();
draggingRef.current = true;
+14 -11
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { COLUMN_LIST, COLUMN_LABELS, SortMode, StatusColumn } from '../../lib/urlState';
interface FilterBarProps {
@@ -12,10 +13,10 @@ interface FilterBarProps {
onSearchChange: (q: string) => void;
}
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
{ value: 'updated', label: '新しい順' },
{ value: 'status', label: 'ステータス順' },
{ value: 'title', label: 'タイトル順' },
const SORT_OPTIONS: Array<{ value: SortMode; labelKey: string }> = [
{ value: 'updated', labelKey: 'sort.updated' },
{ value: 'status', labelKey: 'sort.status' },
{ value: 'title', labelKey: 'sort.title' },
];
function SortMenu({
@@ -25,6 +26,7 @@ function SortMenu({
sortMode: SortMode;
onSortChange: (sort: SortMode) => void;
}) {
const { t } = useTranslation('list');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
@@ -64,10 +66,10 @@ function SortMenu({
ref={triggerRef}
type="button"
onClick={() => setOpen(v => !v)}
title={`並び順: ${current.label}`}
title={t('sort.tooltip', { mode: t(current.labelKey) })}
aria-haspopup="true"
aria-expanded={open}
aria-label={`並び順: ${current.label}`}
aria-label={t('sort.tooltip', { mode: t(current.labelKey) })}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:bg-surface-2'
}`}
@@ -101,7 +103,7 @@ function SortMenu({
: 'text-slate-700 font-medium hover:bg-surface-2'
}`}
>
{o.label}
{t(o.labelKey)}
{selected && (
<svg
width="14"
@@ -136,6 +138,7 @@ export function FilterBar({
onSortChange,
onSearchChange,
}: FilterBarProps) {
const { t } = useTranslation('list');
return (
<div className="flex flex-col gap-2 pb-3 border-b border-hairline">
<div className="flex items-center gap-1.5 bg-canvas border border-hairline rounded-md pl-2.5 pr-1 h-8">
@@ -143,17 +146,17 @@ export function FilterBar({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
aria-label="検索"
aria-label={t('search.label')}
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
placeholder="検索..."
placeholder={t('search.placeholder')}
className="flex-1 bg-transparent border-0 outline-none text-[13px] text-slate-900 placeholder:text-slate-400 min-w-0"
/>
<div aria-hidden="true" className="w-px h-4 bg-hairline flex-shrink-0" />
<SortMenu sortMode={sortMode} onSortChange={onSortChange} />
</div>
<div role="tablist" aria-label="ステータスフィルター" className="flex gap-1 overflow-x-auto pb-1 scrollbar-none">
<div role="tablist" aria-label={t('statusFilter.label')} className="flex gap-1 overflow-x-auto pb-1 scrollbar-none">
<button
role="tab"
aria-selected={selectedStatus === 'all'}
@@ -164,7 +167,7 @@ export function FilterBar({
: 'border-hairline bg-canvas text-slate-600 hover:bg-surface'
}`}
>
<span className="text-slate-400 ml-0.5 font-mono tabular-nums">{totalCount}</span>
{t('status.all')} <span className="text-slate-400 ml-0.5 font-mono tabular-nums">{totalCount}</span>
</button>
{COLUMN_LIST.map(status => (
<button
+6 -4
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import type { LocalTask } from '../../api';
interface RailPanelProps {
@@ -36,13 +37,14 @@ export function RailPanel({
onOpenCreate,
onExitFocused,
}: RailPanelProps) {
const { t } = useTranslation('list');
// bg/border は App.tsx の grid cell 側に持たせる (list mode と同じ責務分割)。
return (
<div className="flex flex-col h-full overflow-hidden">
<button
onClick={onOpenCreate}
title="新規タスクを作成"
aria-label="新規タスクを作成"
title={t('newTask')}
aria-label={t('newTask')}
className="flex-shrink-0 flex items-center justify-center h-10 border-b border-hairline hover:bg-surface transition-colors text-slate-600"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
@@ -73,8 +75,8 @@ export function RailPanel({
</div>
<button
onClick={onExitFocused}
title="リスト表示に戻る"
aria-label="標準表示に戻る"
title={t('backToList')}
aria-label={t('backToList')}
className="flex-shrink-0 flex items-center justify-center h-10 border-t border-hairline hover:bg-surface transition-colors text-slate-500"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
+12 -9
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { LocalTask } from '../../api';
import { matchText } from '../../lib/utils';
import { COLUMN_LIST, SortMode, StatusColumn } from '../../lib/urlState';
@@ -43,6 +44,7 @@ function ScopeToggle({
allCount: number;
onScopeChange: (scope: TaskScope) => void;
}) {
const { t } = useTranslation('list');
const seg = (value: TaskScope, label: string, count: number) => (
<button
type="button"
@@ -58,9 +60,9 @@ function ScopeToggle({
</button>
);
return (
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label="タスクの表示範囲">
{seg('mine', '自分', mineCount)}
{seg('all', 'すべて', allCount)}
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
{seg('mine', t('scope.mine'), mineCount)}
{seg('all', t('scope.all'), allCount)}
</div>
);
}
@@ -83,6 +85,7 @@ export function TaskListPanel({
mode = 'list',
onExitFocused,
}: TaskListPanelProps) {
const { t } = useTranslation('list');
// Owner scope is the outermost filter: status counts / search / sort all
// operate on the scoped list so "自分" mode never counts others' tasks.
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all';
@@ -160,7 +163,7 @@ export function TaskListPanel({
>
<path d="M12 5v14M5 12h14" />
</svg>
{t('newRequest')}
</button>
{scopeEnabled && onScopeChange && (
<ScopeToggle
@@ -171,12 +174,12 @@ export function TaskListPanel({
/>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-500 px-0.5 pb-2.5 font-mono tabular-nums">
<span><span className="font-semibold text-slate-700">{totalCount}</span> </span>
<span><span className="font-semibold text-slate-700">{totalCount}</span> {t('counts.items')}</span>
<span aria-hidden="true" className="text-slate-300">·</span>
<span><span className="font-semibold text-emerald-600">{runningCount}</span> </span>
<span><span className="font-semibold text-amber-600">{waitingCount}</span> </span>
<span><span className="font-semibold text-emerald-600">{runningCount}</span> {t('counts.running')}</span>
<span><span className="font-semibold text-amber-600">{waitingCount}</span> {t('counts.waiting')}</span>
{failedCount > 0 && (
<span><span className="font-semibold text-red-600">{failedCount}</span> </span>
<span><span className="font-semibold text-red-600">{failedCount}</span> {t('counts.failed')}</span>
)}
</div>
<FilterBar
@@ -199,7 +202,7 @@ export function TaskListPanel({
/>
))}
{filtered.length === 0 && (
<div className="text-[13px] text-slate-500 px-2 py-3"></div>
<div className="text-[13px] text-slate-500 px-2 py-3">{t('empty')}</div>
)}
</div>
</div>
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const ask = config.ask ?? {};
const subtasks = config.subtasks ?? {};
@@ -13,19 +15,19 @@ export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
<div>
<FieldLabel>Ask: Max Per Job</FieldLabel>
<FieldInput type="number" value={ask.maxPerJob ?? ''} onChange={v => onChange('ask.maxPerJob', v ? Number(v) : undefined)} />
<HelpText>1 Job ASK</HelpText>
<HelpText>{t('askSubtasks.askMaxHelp')}</HelpText>
</div>
<div>
<FieldLabel>Subtasks: Max Depth</FieldLabel>
<FieldInput type="number" value={subtasks.maxDepth ?? ''} onChange={v => onChange('subtasks.maxDepth', v ? Number(v) : undefined)} />
<HelpText></HelpText>
<HelpText>{t('askSubtasks.maxDepthHelp')}</HelpText>
</div>
<div>
<FieldLabel>Subtasks: Max Per Parent</FieldLabel>
<FieldInput type="number" value={subtasks.maxPerParent ?? ''} onChange={v => onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} />
<HelpText>1 spawn デフォルト: 10</HelpText>
<HelpText>{t('askSubtasks.maxPerParentHelp')}</HelpText>
</div>
</div>
);
+24 -23
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
@@ -13,6 +14,7 @@ import type { SectionFormProps } from './types';
* mode — every visitor is treated as a local admin.
*/
export function AuthForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const auth = config.auth ?? {};
const providers = auth.providers ?? {};
const google = providers.google ?? {};
@@ -21,82 +23,81 @@ export function AuthForm({ config, onChange }: SectionFormProps) {
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Authentication</h2>
<h2 className="text-base font-semibold text-slate-800">{t('auth.title')}</h2>
<p className="text-[13px] text-slate-500">
providers <strong> admin</strong>
{t('auth.intro')}
</p>
<div>
<FieldLabel>Primary Provider</FieldLabel>
<FieldLabel>{t('auth.primaryProvider')}</FieldLabel>
<select
value={auth.primaryProvider ?? ''}
onChange={e => onChange('auth.primaryProvider', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
>
<option value=""></option>
<option value="google">google </option>
<option value="gitea">gitea </option>
<option value="local">local </option>
<option value="">{t('auth.primaryNone')}</option>
<option value="google">{t('auth.primaryGoogle')}</option>
<option value="gitea">{t('auth.primaryGitea')}</option>
<option value="local">{t('auth.primaryLocal')}</option>
</select>
<HelpText>OAuth + </HelpText>
<HelpText>{t('auth.primaryHelp')}</HelpText>
</div>
<div>
<FieldLabel>Admin Emails</FieldLabel>
<FieldLabel>{t('auth.adminEmails')}</FieldLabel>
<StringArrayEditor
value={auth.adminEmails ?? []}
onChange={v => onChange('auth.adminEmails', v)}
placeholder="[email protected]"
/>
<HelpText>admin </HelpText>
<HelpText>{t('auth.adminEmailsHelp')}</HelpText>
</div>
<div>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={auth.secureCookie === true}
onChange={e => onChange('auth.secureCookie', e.target.checked)} className="rounded" />
Secure CookieHTTPS Cookie
{t('auth.secureCookie')}
</label>
<HelpText>HTTPSHTTP </HelpText>
<HelpText>{t('auth.secureCookieHelp')}</HelpText>
</div>
<div>
<FieldLabel>Session Max Age (ms)</FieldLabel>
<FieldInput type="number" value={auth.sessionMaxAge ?? ''}
onChange={v => onChange('auth.sessionMaxAge', v ? Number(v) : undefined)} />
<HelpText></HelpText>
<HelpText>{t('auth.sessionMaxAgeHelp')}</HelpText>
</div>
<div>
<FieldLabel>Session Secret</FieldLabel>
<FieldInput type="password" value={auth.sessionSecret ?? ''}
onChange={v => onChange('auth.sessionSecret', v)} />
<HelpText></HelpText>
<HelpText>{t('auth.sessionSecretHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">email + password</h3>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('auth.localTitle')}</h3>
<div>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={local.enabled === true}
onChange={e => onChange('auth.local.enabled', e.target.checked)} className="rounded" />
{t('auth.localEnable')}
</label>
<HelpText> IdP email/password OAuth <code>primary_provider: local</code> local </HelpText>
<HelpText>{t('auth.localEnableHelp')}</HelpText>
</div>
<div>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={local.allowSignup === true}
onChange={e => onChange('auth.local.allowSignup', e.target.checked)} className="rounded" />
{t('auth.allowSignup')}
</label>
<HelpText> <strong></strong>admin </HelpText>
<HelpText>{t('auth.allowSignupHelp')}</HelpText>
</div>
<HelpText>
admin<code>bootstrap_admin</code>UI admin <code>config.yaml</code> <code>auth.local.bootstrap_admin</code> <code>id='local'</code> seed no-auth <em></em>
{t('auth.bootstrapHelp')}
</HelpText>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Google OAuth</h3>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('auth.googleTitle')}</h3>
<div>
<FieldLabel>Client ID</FieldLabel>
<FieldInput value={google.clientId ?? ''}
@@ -113,7 +114,7 @@ export function AuthForm({ config, onChange }: SectionFormProps) {
onChange={v => onChange('auth.providers.google.callbackUrl', v)} />
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Gitea OAuth</h3>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('auth.giteaTitle')}</h3>
<div>
<FieldLabel>Base URL</FieldLabel>
<FieldInput value={gitea.baseUrl ?? ''} placeholder="https://gitea.example.com"
+25 -24
View File
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
@@ -38,6 +39,7 @@ function AssetUploader({
/** Called after a successful upload/delete with the new URL (null when cleared). */
onChanged: (newUrl: string | null) => void;
}) {
const { t } = useTranslation('settings');
const fileRef = useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -47,7 +49,7 @@ function AssetUploader({
const handleFile = async (file: File) => {
setError(null);
if (file.size > MAX_SIZE[kind]) {
setError(`ファイルサイズが上限 ${Math.round(MAX_SIZE[kind] / 1024)}KB を超えています`);
setError(t('branding.sizeExceeded', { kb: Math.round(MAX_SIZE[kind] / 1024) }));
return;
}
try {
@@ -60,7 +62,7 @@ function AssetUploader({
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error ?? `アップロードに失敗しました (${res.status})`);
throw new Error(body.error ?? t('branding.uploadFailed', { status: res.status }));
}
onChanged(typeof body.url === 'string' ? body.url : null);
} catch (e) {
@@ -76,7 +78,7 @@ function AssetUploader({
try {
setBusy(true);
const res = await fetch(`/api/branding/upload?kind=${kind}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`削除に失敗しました (${res.status})`);
if (!res.ok) throw new Error(t('branding.deleteFailed', { status: res.status }));
onChanged(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -96,7 +98,7 @@ function AssetUploader({
{currentUrl ? (
<img src={currentUrl} alt="" className="h-full w-full object-contain" />
) : (
<span className="text-[10px] text-slate-400"></span>
<span className="text-[10px] text-slate-400">{t('branding.notSet')}</span>
)}
</div>
<div className="flex-1 min-w-0">
@@ -117,7 +119,7 @@ function AssetUploader({
disabled={busy}
className="px-2.5 h-7 text-2xs font-medium bg-canvas border border-hairline rounded-md text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
>
{currentUrl ? '差し替え' : 'アップロード'}
{currentUrl ? t('branding.replace') : t('branding.upload')}
</button>
{currentUrl && (
<button
@@ -126,12 +128,12 @@ function AssetUploader({
disabled={busy}
className="px-2.5 h-7 text-2xs font-medium text-red-700 dark:text-red-300 border border-red-200 bg-canvas hover:bg-red-50 dark:hover:bg-red-500/15 rounded-md disabled:opacity-50 transition-colors"
>
{t('branding.delete')}
</button>
)}
</div>
<div className="text-[10px] text-slate-400 mt-1 truncate font-mono">
{currentUrl ?? `${ACCEPT[kind]} / 最大 ${Math.round(MAX_SIZE[kind] / 1024)}KB`}
{currentUrl ?? t('branding.accept', { accept: ACCEPT[kind], kb: Math.round(MAX_SIZE[kind] / 1024) })}
</div>
</div>
</div>
@@ -141,6 +143,7 @@ function AssetUploader({
}
export function BrandingForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const branding = config.branding ?? {};
const primaryColor = branding.primaryColor ?? '';
const qc = useQueryClient();
@@ -155,32 +158,30 @@ export function BrandingForm({ config, onChange }: SectionFormProps) {
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Branding</h2>
<h2 className="text-base font-semibold text-slate-800">{t('branding.title')}</h2>
<p className="text-xs text-slate-500 -mt-3">
UI <code>config.yaml</code> <code>branding</code>
<code>data/branding/</code> <code>.gitignore</code>
<code> git pull</code>
{t('branding.intro')}
</p>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('branding.appName')}</FieldLabel>
<FieldInput
value={branding.appName ?? ''}
onChange={v => onChange('branding.appName', v)}
placeholder="MAESTRO"
/>
<HelpText>TopBar </HelpText>
<HelpText>{t('branding.appNameHelp')}</HelpText>
</div>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('branding.primaryColor')}</FieldLabel>
<div className="flex items-center gap-2">
<input
type="color"
value={primaryColor || '#2563eb'}
onChange={e => onChange('branding.primaryColor', e.target.value)}
className="h-9 w-12 rounded border border-slate-300 p-0 cursor-pointer"
aria-label="プライマリカラー"
aria-label={t('branding.primaryColor')}
/>
<input
type="text"
@@ -190,47 +191,47 @@ export function BrandingForm({ config, onChange }: SectionFormProps) {
className="flex-1 px-3 py-2 text-sm font-mono border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
/>
</div>
<HelpText>hex / rgb</HelpText>
<HelpText>{t('branding.primaryColorHelp')}</HelpText>
</div>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('branding.loginTitle')}</FieldLabel>
<FieldInput
value={branding.loginPageTitle ?? ''}
onChange={v => onChange('branding.loginPageTitle', v)}
placeholder="MAESTRO"
/>
<HelpText>使</HelpText>
<HelpText>{t('branding.loginTitleHelp')}</HelpText>
</div>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('branding.logo')}</FieldLabel>
<AssetUploader
kind="logo"
currentUrl={branding.logoUrl || null}
onChanged={handleAssetChange('logoUrl')}
/>
<HelpText>TopBar 使</HelpText>
<HelpText>{t('branding.logoHelp')}</HelpText>
</div>
<div>
<FieldLabel>Favicon</FieldLabel>
<FieldLabel>{t('branding.favicon')}</FieldLabel>
<AssetUploader
kind="favicon"
currentUrl={branding.faviconUrl || null}
onChanged={handleAssetChange('faviconUrl')}
/>
<HelpText></HelpText>
<HelpText>{t('branding.faviconHelp')}</HelpText>
</div>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('branding.footer')}</FieldLabel>
<FieldInput
value={branding.footerText ?? ''}
onChange={v => onChange('branding.footerText', v)}
placeholder="© 2026 Your Team"
/>
<HelpText></HelpText>
<HelpText>{t('branding.footerHelp')}</HelpText>
</div>
</div>
);
@@ -1,27 +1,29 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const browser = config.browser ?? {};
const tools = config.tools ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Browser</h2>
<h2 className="text-base font-semibold text-slate-800">{t('browser.title')}</h2>
<div>
<FieldLabel>Page Timeout (ms)</FieldLabel>
<FieldInput type="number" value={tools.browserPageTimeout ?? 60000}
onChange={v => onChange('tools.browserPageTimeout', Number(v))} />
<HelpText>デフォルト: 60000</HelpText>
<HelpText>{t('browser.pageTimeoutHelp')}</HelpText>
</div>
<div>
<FieldLabel>Action Timeout (ms)</FieldLabel>
<FieldInput type="number" value={tools.browserActionTimeout ?? 30000}
onChange={v => onChange('tools.browserActionTimeout', Number(v))} />
<HelpText>デフォルト: 30000</HelpText>
<HelpText>{t('browser.actionTimeoutHelp')}</HelpText>
</div>
<div>
@@ -34,8 +36,7 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
<option value="msedge">msedge (system Microsoft Edge)</option>
</select>
<HelpText>
Google <code>chrome</code>
<code>google-chrome</code>
{t('browser.channelHelp')}
</HelpText>
</div>
@@ -43,24 +44,21 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
<FieldLabel>Executable Path (optional)</FieldLabel>
<FieldInput value={browser.executablePath ?? ''}
onChange={v => onChange('browser.executablePath', v || undefined)} />
<HelpText>使 channel </HelpText>
<HelpText>{t('browser.execPathHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('browser.sessionsTitle')}</h3>
<div>
<FieldLabel>Browser Session Mode</FieldLabel>
<FieldLabel>{t('browser.sessionMode')}</FieldLabel>
<select value={browser.displayMode ?? 'headless'}
onChange={e => onChange('browser.displayMode', e.target.value)}
className="w-full h-9 px-2 text-[13px] border border-hairline rounded-md">
<option value="headless">Headless</option>
<option value="novnc">noVNCBrowser/CAPTCHA </option>
<option value="headless">{t('browser.headless')}</option>
<option value="novnc">{t('browser.novnc')}</option>
</select>
<HelpText>
<code>novnc</code>
<strong>Browser InteractiveBrowseCAPTCHA </strong>
<code>Xvfb</code> / <code>x11vnc</code> / <code>websockify</code> headless
<code>headless</code><strong>使</strong>
{t('browser.sessionModeHelp')}
</HelpText>
</div>
@@ -68,35 +66,35 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
<FieldLabel>Max CAPTCHA Pages</FieldLabel>
<FieldInput type="number" value={browser.maxCaptchaPages ?? 5}
onChange={v => onChange('browser.maxCaptchaPages', Number(v))} />
<HelpText>CAPTCHA Pool <code>novnc</code> デフォルト: 5</HelpText>
<HelpText>{t('browser.maxCaptchaHelp')}</HelpText>
</div>
<div>
<FieldLabel>VNC Base Port</FieldLabel>
<FieldInput type="number" value={browser.vncBasePort ?? 5900}
onChange={v => onChange('browser.vncBasePort', Number(v))} />
<HelpText>VNC デフォルト: 5900</HelpText>
<HelpText>{t('browser.vncPortHelp')}</HelpText>
</div>
<div>
<FieldLabel>Session Data Directory</FieldLabel>
<FieldInput value={browser.sessionDataDir ?? './data/browser-sessions'}
onChange={v => onChange('browser.sessionDataDir', v)} />
<HelpText>Cookie </HelpText>
<HelpText>{t('browser.sessionDirHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Sessions</FieldLabel>
<FieldInput type="number" value={browser.maxSessions ?? 3}
onChange={v => onChange('browser.maxSessions', Number(v))} />
<HelpText>デフォルト: 3</HelpText>
<HelpText>{t('browser.maxSessionsHelp')}</HelpText>
</div>
<div>
<FieldLabel>Task Session Idle TTL ()</FieldLabel>
<FieldLabel>{t('browser.idleTtl')}</FieldLabel>
<FieldInput type="number" value={browser.taskSessionIdleTtl ?? ''}
onChange={v => onChange('browser.taskSessionIdleTtl', v ? Number(v) : undefined)} />
<HelpText> CDP GCデフォルト: 300</HelpText>
<HelpText>{t('browser.idleTtlHelp')}</HelpText>
</div>
</div>
);
+14 -8
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useConfig } from '../../hooks/useConfig';
import { useUnsavedGuard } from '../../lib/unsavedGuard';
@@ -81,6 +82,7 @@ function countDiff(a: any, b: any): number {
}
export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
const { t } = useTranslation('settings');
// User-scoped sections use their own per-user APIs and should not load the
// admin /api/config draft. Render them stand-alone without the global save bar.
if (section === 'preferences') {
@@ -93,7 +95,7 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
return <div className="max-w-2xl"><MemoryLearningForm /></div>;
}
if (!isAdmin) {
return <div className="max-w-2xl text-sm text-slate-500"></div>;
return <div className="max-w-2xl text-sm text-slate-500">{t('configForm.adminOnly')}</div>;
}
// Local organizations: admin-managed via /api/admin/orgs (not config.yaml),
// so render stand-alone without the global save bar.
@@ -108,6 +110,7 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
}
function ConfigFormInner({ section }: ConfigFormProps) {
const { t } = useTranslation('settings');
const { data, isLoading, error, refetch } = useConfig();
const queryClient = useQueryClient();
@@ -117,6 +120,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const [toastIsError, setToastIsError] = useState(false);
// Sync fetched config into draft
useEffect(() => {
@@ -146,17 +150,19 @@ function ConfigFormInner({ section }: ConfigFormProps) {
try {
const result = await updateConfig(draft, etag);
if (result.conflict) {
if (confirm('設定が他で変更されました。再読み込みしますか?')) {
if (confirm(t('configForm.conflict'))) {
await refetch();
}
return;
}
await queryClient.invalidateQueries({ queryKey: ['config'] });
setIsDirty(false);
setToast('保存しました');
setToastIsError(false);
setToast(t('configForm.saved'));
setTimeout(() => setToast(null), 2000);
} catch (e: any) {
setToast(`エラー: ${e.message}`);
setToastIsError(true);
setToast(t('configForm.error', { msg: e.message }));
setTimeout(() => setToast(null), 3000);
} finally {
setSaving(false);
@@ -169,7 +175,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
useUnsavedGuard(dirtyCount > 0);
if (isLoading) return <div className="text-sm text-slate-400">Loading...</div>;
if (error) return <div className="text-sm text-red-500"></div>;
if (error) return <div className="text-sm text-red-500">{t('configForm.loadError')}</div>;
if (!draft) return null;
const formProps = { config: draft, onChange: handleChange, overriddenByEnv };
@@ -259,15 +265,15 @@ function ConfigFormInner({ section }: ConfigFormProps) {
}`}
>
{toast ? (
<span className={`text-2xs mr-auto ${toast.startsWith('エラー') ? 'text-red-600' : 'text-emerald-700 dark:text-emerald-300'}`}>
<span className={`text-2xs mr-auto ${toastIsError ? 'text-red-600' : 'text-emerald-700 dark:text-emerald-300'}`}>
{toast}
</span>
) : dirty ? (
<span className="text-xs mr-auto text-amber-800 dark:text-amber-300 flex items-center gap-1.5 font-medium min-w-0">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
<span className="truncate">
<span className="hidden sm:inline">: {dirtyCount} Save &amp; Apply</span>
<span className="sm:hidden"> {dirtyCount}</span>
<span className="hidden sm:inline">{t('configForm.unsaved', { count: dirtyCount })}</span>
<span className="sm:hidden">{t('configForm.unsavedShort', { count: dirtyCount })}</span>
</span>
</span>
) : null}
+11 -10
View File
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
export function ContextForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const ctx = config.context ?? {};
const thresholds = ctx.thresholds ?? [
{ ratio: 0.7, action: 'warn' },
@@ -11,8 +13,8 @@ export function ContextForm({ config, onChange }: SectionFormProps) {
];
const updateThreshold = (index: number, field: string, value: string | number) => {
const updated = thresholds.map((t: { ratio: number; action: string }, i: number) =>
i === index ? { ...t, [field]: field === 'ratio' ? Number(value) : value } : t
const updated = thresholds.map((th: { ratio: number; action: string }, i: number) =>
i === index ? { ...th, [field]: field === 'ratio' ? Number(value) : value } : th
);
onChange('context.thresholds', updated);
};
@@ -25,20 +27,20 @@ export function ContextForm({ config, onChange }: SectionFormProps) {
<FieldLabel>Limit Tokens</FieldLabel>
<FieldInput type="number" value={ctx.limitTokens ?? ''}
onChange={v => onChange('context.limitTokens', v ? Number(v) : undefined)}
placeholder="auto (Ollama API から取得)" />
<HelpText></HelpText>
placeholder={t('context.limitPlaceholder')} />
<HelpText>{t('context.limitHelp')}</HelpText>
</div>
<div>
<FieldLabel>Thresholds ()</FieldLabel>
<FieldLabel>{t('context.thresholdsLabel')}</FieldLabel>
<div className="space-y-2 mt-1">
{thresholds.map((t: { ratio: number; action: string }, i: number) => (
{thresholds.map((th: { ratio: number; action: string }, i: number) => (
<div key={i} className="flex gap-2 items-center">
<input type="number" step="0.01" min="0" max="1"
value={t.ratio}
value={th.ratio}
onChange={e => updateThreshold(i, 'ratio', e.target.value)}
className="w-20 px-2 py-1 text-sm border border-slate-300 rounded" />
<select value={t.action}
<select value={th.action}
onChange={e => updateThreshold(i, 'action', e.target.value)}
className="px-2 py-1 text-sm border border-slate-300 rounded">
<option value="warn">warn</option>
@@ -49,8 +51,7 @@ export function ContextForm({ config, onChange }: SectionFormProps) {
))}
</div>
<HelpText>
使ratio 01
warn: ログに警告を出力するのみ prompt: LLM force_transition: default_next
{t('context.thresholdsHelp')}
</HelpText>
</div>
</div>
+8 -6
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -10,10 +11,11 @@ import type { SectionFormProps } from './types';
* the underlying config keys keep working without a migration.
*/
export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
const { t } = useTranslation('settings');
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Execution</h2>
<HelpText>1 movement </HelpText>
<HelpText>{t('execution.intro')}</HelpText>
<div>
<FieldLabel>Concurrency</FieldLabel>
@@ -22,10 +24,10 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
value={config.concurrency ?? ''}
onChange={v => onChange('concurrency', v ? Number(v) : undefined)}
disabled={!!overriddenByEnv['concurrency']}
disabledReason="CONCURRENCY 環境変数で上書き中"
disabledReason={t('execution.concurrencyOverride')}
/>
{overriddenByEnv['concurrency'] && <EnvOverrideWarning />}
<HelpText></HelpText>
<HelpText>{t('execution.concurrencyHelp')}</HelpText>
</div>
<div>
@@ -35,7 +37,7 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
value={config.maxMovements ?? ''}
onChange={v => onChange('maxMovements', v ? Number(v) : undefined)}
/>
<HelpText>1 movement </HelpText>
<HelpText>{t('execution.maxMovementsHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Retry</h3>
@@ -47,7 +49,7 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
value={config.retry?.maxAttempts ?? 3}
onChange={v => onChange('retry.maxAttempts', Number(v))}
/>
<HelpText>デフォルト: 3</HelpText>
<HelpText>{t('execution.maxAttemptsHelp')}</HelpText>
</div>
<div>
@@ -61,7 +63,7 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
)
}
/>
<HelpText>デフォルト: 60, 300, 900</HelpText>
<HelpText>{t('execution.backoffHelp')}</HelpText>
</div>
</div>
);
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface CreateInput {
team: string;
@@ -23,6 +24,7 @@ interface Props {
* with the raw bearer; this dialog never displays it.
*/
export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }: Props) {
const { t } = useTranslation('settings');
const [team, setTeam] = useState('');
const [allowedModelsText, setAllowedModelsText] = useState('');
const [tokensBudgetText, setTokensBudgetText] = useState('');
@@ -72,7 +74,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
onSubmit={handleSubmit}
className="bg-surface rounded-lg shadow-xl max-w-md w-full mx-4 p-6"
>
<h3 className="text-lg font-semibold text-slate-800 mb-4"> Gateway Key </h3>
<h3 className="text-lg font-semibold text-slate-800 mb-4">{t('gateway.createDialog.title')}</h3>
<label className="block text-xs font-medium text-slate-600 mb-1">
team <span className="text-red-600">*</span>
@@ -88,7 +90,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
/>
<label className="block text-xs font-medium text-slate-600 mb-1">
Allowed models (1 / =)
{t('gateway.createDialog.allowedModelsLabel')}
</label>
<textarea
value={allowedModelsText}
@@ -108,7 +110,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
min="1"
value={tokensBudgetText}
onChange={(e) => setTokensBudgetText(e.target.value)}
placeholder="無制限"
placeholder={t('gateway.createDialog.unlimited')}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
</div>
@@ -121,7 +123,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
min="1"
value={rateLimitRpmText}
onChange={(e) => setRateLimitRpmText(e.target.value)}
placeholder="無制限"
placeholder={t('gateway.createDialog.unlimited')}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
</div>
@@ -145,7 +147,7 @@ export function GatewayKeyCreateDialog({ onCancel, onSubmit, submitting, error }
disabled={submitting}
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong disabled:opacity-50"
>
{submitting ? '発行中...' : '発行する'}
{submitting ? t('gateway.createDialog.issuing') : t('gateway.createDialog.issue')}
</button>
</div>
</form>
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
rawKey: string;
@@ -19,6 +20,7 @@ interface Props {
* The dialog is intentionally modal (overlay + focus trap via tabindex).
*/
export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props) {
const { t } = useTranslation('settings');
const [copied, setCopied] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
@@ -66,9 +68,7 @@ export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props)
try {
window.history.pushState({ aaoGatewayKeyTrap: true }, '', window.location.href);
} catch { /* ignore */ }
alert(
'API key has not been saved. Copy it and tick "保存しました" before navigating away.',
);
alert(t('gateway.rawKeyDialog.alertNotSaved'));
};
window.addEventListener('popstate', onPopState);
@@ -102,15 +102,14 @@ export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props)
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface rounded-lg shadow-xl max-w-lg w-full mx-4 p-6">
<h3 className="text-lg font-semibold text-slate-800 mb-1">
{reason === 'created' ? '新しい Gateway Key を発行しました' : 'Gateway Key をローテーションしました'}
{reason === 'created' ? t('gateway.rawKeyDialog.titleCreated') : t('gateway.rawKeyDialog.titleRotated')}
</h3>
<p className="text-xs text-slate-500 mb-4">team: {team}</p>
<div className="rounded border border-red-300 dark:border-red-500/30 bg-red-50 dark:bg-red-500/15 p-3 mb-3">
<p className="text-sm text-red-800 dark:text-red-300 font-medium"> </p>
<p className="text-sm text-red-800 dark:text-red-300 font-medium">{t('gateway.rawKeyDialog.warnTitle')}</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
LLM
Rotate
{t('gateway.rawKeyDialog.warnBody')}
</p>
</div>
@@ -142,7 +141,7 @@ export function GatewayKeyRawKeyDialog({ rawKey, team, reason, onClose }: Props)
className="mt-0.5"
/>
<span className="text-slate-700">
{t('gateway.rawKeyDialog.ackLabel')}
</span>
</label>
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { getGatewayKeyUsage } from '../../api';
interface Props {
@@ -19,6 +20,7 @@ function fmtTokens(n: number): string {
* lean.
*/
export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
const { t } = useTranslation('settings');
const { data, isLoading, error } = useQuery({
queryKey: ['gateway-key-usage', keyId],
queryFn: () => getGatewayKeyUsage(keyId),
@@ -39,7 +41,7 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
<div className="bg-surface rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-800">Key 使</h3>
<h3 className="text-lg font-semibold text-slate-800">{t('gateway.usagePanel.title')}</h3>
<p className="text-xs text-slate-500 font-mono">{keyId}</p>
</div>
<button
@@ -54,7 +56,7 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
{isLoading && <div className="text-sm text-slate-500">Loading</div>}
{error && (
<div className="text-sm text-red-600">: {String((error as Error).message ?? error)}</div>
<div className="text-sm text-red-600">{t('gateway.keys.fetchError', { msg: String((error as Error).message ?? error) })}</div>
)}
{data && (
@@ -63,7 +65,7 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
<div className="border border-hairline rounded p-3 mb-4">
<div className="flex justify-between items-baseline mb-2">
<span className="text-xs font-medium text-slate-600 uppercase tracking-wide">
({data.currentPeriod})
{t('gateway.usagePanel.thisMonth', { period: data.currentPeriod })}
</span>
<span className="text-xs text-slate-500">
Requests: {data.requestsThisMonth.toLocaleString()}
@@ -112,10 +114,10 @@ export function GatewayKeyUsagePanel({ keyId, onClose }: Props) {
{/* History bars */}
<div className="border border-hairline rounded p-3">
<div className="text-xs font-medium text-slate-600 uppercase tracking-wide mb-2">
12
{t('gateway.usagePanel.past12')}
</div>
{data.history.length === 0 ? (
<div className="text-sm text-slate-400 italic"></div>
<div className="text-sm text-slate-400 italic">{t('gateway.usagePanel.noHistory')}</div>
) : (
<div className="space-y-1.5">
{data.history.map((h) => {
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { GatewayKey } from '../../api';
import {
@@ -36,6 +37,7 @@ interface Props {
* participate in the surrounding form's draft/dirty/Save&Apply bar.
*/
export function GatewayKeysSection({ showToast }: Props) {
const { t } = useTranslation('settings');
const qc = useQueryClient();
const [teamFilter, setTeamFilter] = useState('');
const [activeOnly, setActiveOnly] = useState(false);
@@ -73,7 +75,7 @@ export function GatewayKeysSection({ showToast }: Props) {
setRawDialog({ rawKey: created.key, team: created.team, reason: 'created' });
}
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
notify('Gateway key を発行しました');
notify(t('gateway.keys.toast.created'));
} catch (e) {
setCreateError(e instanceof Error ? e.message : String(e));
} finally {
@@ -82,25 +84,25 @@ export function GatewayKeysSection({ showToast }: Props) {
}
async function handleRotate(row: GatewayKey): Promise<void> {
if (!confirm(`team=${row.team} のキーをローテーションしますか?\n旧キーは無効になります。`)) return;
if (!confirm(t('gateway.keys.confirmRotate', { team: row.team }))) return;
try {
const created = await rotateGatewayKey(row.id);
if (created.key) {
setRawDialog({ rawKey: created.key, team: created.team, reason: 'rotated' });
}
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
notify('Rotate しました');
notify(t('gateway.keys.toast.rotated'));
} catch (e) {
notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function handleRevoke(row: GatewayKey): Promise<void> {
if (!confirm(`team=${row.team} のキー (${row.keyPrefix}…) を Revoke しますか?\nこの操作は取り消せません。`)) return;
if (!confirm(t('gateway.keys.confirmRevoke', { team: row.team, prefix: row.keyPrefix }))) return;
try {
await revokeGatewayKey(row.id);
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
notify('Revoke しました');
notify(t('gateway.keys.toast.revoked'));
} catch (e) {
notify(e instanceof Error ? e.message : String(e), 'error');
}
@@ -110,7 +112,7 @@ export function GatewayKeysSection({ showToast }: Props) {
try {
await patchGatewayKey(id, patch);
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
notify('更新しました');
notify(t('gateway.keys.toast.updated'));
} catch (e) {
notify(e instanceof Error ? e.message : String(e), 'error');
}
@@ -175,7 +177,7 @@ export function GatewayKeysSection({ showToast }: Props) {
onClick={() => { setCreateError(null); setCreating(true); }}
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
>
+
{t('gateway.keys.newIssue')}
</button>
</div>
@@ -183,12 +185,12 @@ export function GatewayKeysSection({ showToast }: Props) {
{isLoading && <div className="p-3 text-sm text-slate-500">Loading</div>}
{error && (
<div className="p-3 text-sm text-red-600">
: {String((error as Error).message ?? error)}
{t('gateway.keys.fetchError', { msg: String((error as Error).message ?? error) })}
</div>
)}
{data && data.length === 0 && (
<div className="p-6 text-center text-sm text-slate-400">
+
{t('gateway.keys.empty')}
</div>
)}
{data && data.length > 0 && (
@@ -241,7 +243,7 @@ export function GatewayKeysSection({ showToast }: Props) {
disabled={isRevoked || isConfig}
onClick={() => setBudgetDraft({ id: row.id, value: row.tokensBudget?.toString() ?? '' })}
className="text-left disabled:cursor-not-allowed hover:underline"
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
title={isConfig ? t('gateway.keys.titleConfigManaged') : isRevoked ? 'revoked' : 'click to edit'}
>
{row.tokensBudget !== null ? row.tokensBudget.toLocaleString() : <span className="text-slate-400"></span>}
</button>
@@ -267,7 +269,7 @@ export function GatewayKeysSection({ showToast }: Props) {
disabled={isRevoked || isConfig}
onClick={() => setRpmDraft({ id: row.id, value: row.rateLimitRpm?.toString() ?? '' })}
className="text-left disabled:cursor-not-allowed hover:underline"
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
title={isConfig ? t('gateway.keys.titleConfigManaged') : isRevoked ? 'revoked' : 'click to edit'}
>
{row.rateLimitRpm !== null ? row.rateLimitRpm.toString() : <span className="text-slate-400"></span>}
</button>
@@ -294,7 +296,7 @@ export function GatewayKeysSection({ showToast }: Props) {
onClick={() => setUsagePanelId(row.id)}
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50"
>
{t('gateway.keys.detail')}
</button>
<button
type="button"
@@ -323,8 +325,7 @@ export function GatewayKeysSection({ showToast }: Props) {
</div>
<div className="text-xs text-slate-500">
Tokens budget UTC Rate limit (rpm) 60
config-import config.yaml
{t('gateway.keys.footer')}
</div>
{creating && (
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -170,6 +171,7 @@ function validateBackends(backends: GatewayBackend[]): Map<number, string[]> {
}
export function GatewayServerForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const gw: GatewayConfigShape = config.gateway ?? {};
const backends: GatewayBackend[] = Array.isArray(gw.backends) ? gw.backends : [];
@@ -210,7 +212,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
<div>
<h2 className="text-base font-semibold text-slate-800 mb-1">Gateway Server</h2>
<p className="text-xs text-slate-500">
AAO LLM Gateway <code>/v1/chat/completions</code> worker UI <strong></strong> ( process ) AAO <code>provider.workers[].endpoint</code> URL GPU
{t('gateway.server.intro')}
</p>
<div className="flex items-center gap-3 mt-2 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -220,7 +222,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
onChange={e => setEnabled(e.target.checked)}
className="rounded"
/>
<span className="font-medium text-slate-700">Enable Gateway</span>
<span className="font-medium text-slate-700">{t('gateway.server.enable')}</span>
</label>
<StatusBadge status={statusQuery.data} />
</div>
@@ -243,12 +245,11 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
onChange={v => setListenPort(parseNumberInput(v))}
/>
<HelpText>
<strong> process 使</strong>: worker UI (
{statusQuery.data?.sharedPort ?? '9876'}) <code>AAO_MODE=gateway</code> process
{t('gateway.server.listenPortHelp', { port: statusQuery.data?.sharedPort ?? '9876' })}
</HelpText>
</div>
<div className="text-xs text-slate-500 pt-1.5">
process deploy:{' '}
{t('gateway.server.separateDeploy')}{' '}
<code className="text-2xs">AAO_MODE=gateway scripts/gateway.sh start</code>
</div>
</div>
@@ -265,12 +266,12 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
</button>
</div>
<HelpText>
llama-server / Ollama / vLLM Gateway worker <strong>role</strong> backend (<code>roles</code> backend role )role backend <code>request.model</code> = <code>id</code>/<code>model</code> <br/>
<strong>api_key </strong>: <code>config.yaml</code> <code>${'${VAR}'}</code> env var literal env <code>config.yaml</code>
{t('gateway.server.backendsHelp1')}<br/>
{t('gateway.server.backendsHelp2')}
</HelpText>
{backends.length === 0 ? (
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 mt-2 text-center">
backend 1
{t('gateway.server.backendsEmpty')}
</div>
) : (
<div className="space-y-2 mt-2">
@@ -284,7 +285,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
<button
onClick={() => removeBackend(i)}
className="absolute top-1.5 right-2 text-slate-400 hover:text-red-500 text-lg leading-none"
title="この backend を削除"
title={t('gateway.server.removeBackendTitle')}
>
&times;
</button>
@@ -311,18 +312,16 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
/>
</div>
<div className="col-span-2">
<FieldLabel>roles ()</FieldLabel>
<FieldLabel>{t('gateway.server.rolesLabel')}</FieldLabel>
<FieldInput
value={rolesToInput(b.roles)}
onChange={v => updateBackend(i, 'roles', parseRolesInput(v))}
placeholder="quality, auto (空欄=全ロール)"
placeholder={t('gateway.server.rolesPlaceholder')}
/>
<HelpText>
backend (<code>auto</code> / <code>fast</code> / <code>quality</code> / <code>reflection</code>) worker role routing key Gateway role backend <strong></strong> () model GPU role
</HelpText>
<HelpText>{t('gateway.server.rolesHelp')}</HelpText>
</div>
<div>
<FieldLabel>api_key ()</FieldLabel>
<FieldLabel>{t('gateway.server.apiKeyLabel')}</FieldLabel>
<FieldInput
type="password"
value={b.apiKey ?? ''}
@@ -337,7 +336,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
the env var indirection is lost. */}
{typeof b.apiKey === 'string' && b.apiKey.trimStart().startsWith('${') && (
<p className="text-2xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded px-2 py-1 mt-1">
env var reference detected: 保存すると <code>{b.apiKey}</code> config.yaml env env config.yaml
{t('gateway.server.apiKeyEnvWarn', { key: b.apiKey })}
</p>
)}
</div>
@@ -359,8 +358,8 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
<h3 className="text-sm font-medium text-slate-700">Virtual Keys</h3>
</div>
<HelpText>
Gateway <code>sk-aao-*</code> bearer key rotaterevoke <br/>
<strong></strong>: Gateway Server Save &amp; Apply admin API (Save )
{t('gateway.server.virtualKeysHelp1')}<br/>
{t('gateway.server.virtualKeysHelp2')}
</HelpText>
<div className="mt-2">
<GatewayKeysSection />
@@ -379,7 +378,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
value={numberValue(gw.requestTimeoutSec, 600)}
onChange={v => setRequestTimeout(parseNumberInput(v))}
/>
<HelpText>chat budget (streaming )</HelpText>
<HelpText>{t('gateway.server.advRequestHelp')}</HelpText>
</div>
<div>
<FieldLabel>upstream_timeout_sec</FieldLabel>
@@ -388,7 +387,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
value={numberValue(gw.upstreamTimeoutSec, 30)}
onChange={v => setUpstreamTimeout(parseNumberInput(v))}
/>
<HelpText>1 chunk idle </HelpText>
<HelpText>{t('gateway.server.advUpstreamHelp')}</HelpText>
</div>
<div>
<FieldLabel>shutdown_graceful_sec</FieldLabel>
@@ -397,13 +396,11 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
value={numberValue(gw.shutdownGracefulSec, 30)}
onChange={v => setShutdownGraceful(parseNumberInput(v))}
/>
<HelpText>SIGTERM drain </HelpText>
<HelpText>{t('gateway.server.advShutdownHelp')}</HelpText>
</div>
</div>
<div className="mt-3 text-xs text-slate-500">
<p>
<strong>Hot reload:</strong> Save process gateway (backend / virtual_key bounce in-flight graceful drain )
</p>
<p>{t('gateway.server.hotReload')}</p>
</div>
</details>
</div>
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { NamespaceEditor } from './NamespaceEditor';
@@ -17,6 +18,7 @@ import type { SectionFormProps } from './types';
* but adding new namespaces is disabled in the editor.
*/
export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const tools = config.tools ?? {};
return (
@@ -25,7 +27,7 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
<h2 className="text-base font-semibold text-slate-800">Knowledge (DKS)</h2>
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30"
title="この機能は legacy です。新規の知識検索統合は MCP server 経由を推奨"
title={t('knowledgeDks.legacyBadgeTitle')}
>
LEGACY
</span>
@@ -35,16 +37,14 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
role="note"
className="rounded border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300"
>
DKS <strong>legacy</strong> {' '}
<strong>MCP server </strong> namespace
namespace {' '}
{t('knowledgeDks.noteBody')}
<a
href="/help"
className="underline text-amber-900 dark:text-amber-300 hover:text-amber-700 dark:hover:text-amber-300"
target="_blank"
rel="noopener noreferrer"
>
MCP
{t('knowledgeDks.mcpGuideLink')}
</a>
</div>
@@ -52,7 +52,7 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
<FieldLabel>Knowledge Service URL</FieldLabel>
<FieldInput value={tools.knowledgeServiceUrl ?? ''} onChange={v => onChange('tools.knowledgeServiceUrl', v)}
placeholder="http://dks-server:8100" />
<HelpText>Document Knowledge Server (DKS) API knowledge </HelpText>
<HelpText>{t('knowledgeDks.serviceUrlHelp')}</HelpText>
</div>
<div>
@@ -61,10 +61,10 @@ export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps)
value={tools.knowledgeNamespaces ?? {}}
onChange={v => onChange('tools.knowledgeNamespaces', v)}
addDisabled
addDisabledReason="新規 namespace 追加は MCP 経由を推奨"
addDisabledReason={t('knowledgeDks.addDisabledReason')}
addDisabledHref="/help"
/>
<HelpText>DKS API </HelpText>
<HelpText>{t('knowledgeDks.namespacesHelp')}</HelpText>
</div>
</div>
);
+26 -39
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
import { SecretInput } from './SecretInput';
@@ -99,6 +100,7 @@ function detectSelfLoop(endpoint: string | undefined): boolean {
* endpoint host looks like the current AAO instance
*/
export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
const { t } = useTranslation('settings');
const llm: LlmConfigShape = config.llm ?? {};
const workers: LlmWorker[] = Array.isArray(llm.workers) ? llm.workers : [];
const retry = llm.retry ?? {};
@@ -144,21 +146,17 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
return (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold text-slate-800 mb-1">LLM Workers</h2>
<h2 className="text-base font-semibold text-slate-800 mb-1">{t('llmWorkers.title')}</h2>
<p className="text-xs text-slate-500 leading-relaxed">
AAO <strong></strong> LLM (workers)
AAO gateway <em>LLM Gateway Server</em>
<br />
: <code>auto</code> ( job ) / <code>fast</code> · <code>quality</code>
( profile) / <code>reflection</code> (reflection ) /{' '}
<code>title</code> ()
{t('llmWorkers.intro')}<br />
{t('llmWorkers.rolesHelp')}
</p>
</div>
<div className="space-y-3">
{workers.length === 0 && (
<div className="text-xs text-slate-500 border border-dashed border-slate-200 rounded p-4 text-center">
worker 1
{t('llmWorkers.empty')}
</div>
)}
@@ -173,7 +171,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
<button
onClick={() => moveWorker(i, -1)}
disabled={i === 0}
title="上に移動"
title={t('llmWorkers.moveUp')}
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
>
@@ -181,14 +179,14 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
<button
onClick={() => moveWorker(i, 1)}
disabled={i === workers.length - 1}
title="下に移動"
title={t('llmWorkers.moveDown')}
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
>
</button>
<button
onClick={() => removeWorker(i)}
title="この worker を削除"
title={t('llmWorkers.removeWorker')}
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
>
&times;
@@ -202,7 +200,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
</div>
<div>
<FieldLabel>Connection type</FieldLabel>
<FieldLabel>{t('llmWorkers.connectionType')}</FieldLabel>
<select
value={w.connectionType ?? (w.proxy === true ? 'aao_gateway' : 'direct')}
onChange={e => {
@@ -228,7 +226,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
value={w.endpoint ?? ''}
onChange={v => updateWorker(i, { endpoint: v })}
disabled={!!endpointOverridden}
disabledReason="OLLAMA_BASE_URL 環境変数で上書き中"
disabledReason={t('llmWorkers.endpointOverride')}
placeholder={
isGateway
? 'http://gateway.example.com:9876/v1'
@@ -238,30 +236,20 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
{endpointOverridden && <EnvOverrideWarning />}
{showSelfLoop && (
<p className="text-2xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded px-2 py-1 mt-1">
endpoint (self-loop)
{t('llmWorkers.selfLoopWarn')}
</p>
)}
</div>
<div className="col-span-2">
<FieldLabel>API key{isGateway ? ' (必須)' : ' (任意)'}</FieldLabel>
<FieldLabel>{isGateway ? t('llmWorkers.apiKeyRequired') : t('llmWorkers.apiKeyOptional')}</FieldLabel>
<SecretInput
rawValue={w.apiKey ?? ''}
onChange={v => updateWorker(i, { apiKey: v === '' ? '' : v })}
placeholder={isGateway ? 'sk-aao-...' : 'sk-... (任意)'}
placeholder={isGateway ? 'sk-aao-...' : t('llmWorkers.apiKeyOptionalPlaceholder')}
/>
<HelpText>
{isGateway ? (
<>
AAO <em>LLM Gateway Server</em> {' '}
<code>sk-aao-*</code>
</>
) : (
<>
Bearer Ollama OK
</>
)}
{isGateway ? t('llmWorkers.apiKeyGatewayHelp') : t('llmWorkers.apiKeyDirectHelp')}
</HelpText>
</div>
@@ -275,8 +263,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
/>
{modelOverridden && <EnvOverrideWarning />}
<HelpText>
endpoint <code>/models</code> dropdown
(auth proxy )
{t('llmWorkers.modelHelp')}
</HelpText>
</div>
@@ -290,7 +277,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
</div>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('llmWorkers.maxConcurrency')}</FieldLabel>
<FieldInput
type="number"
value={w.maxConcurrency ?? 1}
@@ -306,11 +293,11 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
onChange={e => updateWorker(i, { enabled: e.target.checked })}
className="rounded"
/>
{t('llmWorkers.enabled')}
</label>
<label
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
title="VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用"
title={t('llmWorkers.vlmTitle')}
>
<input
type="checkbox"
@@ -330,12 +317,12 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
onClick={addWorker}
className="px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft"
>
+ Worker
{t('llmWorkers.addWorker')}
</button>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
Global LLM Settings
{t('llmWorkers.globalTitle')}
</h3>
<div>
@@ -345,11 +332,11 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
value={llm.timeoutMinutes ?? 10}
onChange={v => onChange('llm.timeoutMinutes', Number(v))}
/>
<HelpText>LLM ()デフォルト: 10</HelpText>
<HelpText>{t('llmWorkers.timeoutHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
Retry (per-call HTTP)
{t('llmWorkers.retryTitle')}
</h3>
<div>
@@ -359,7 +346,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
value={retry.maxAttempts ?? 3}
onChange={v => onChange('llm.retry.maxAttempts', Number(v))}
/>
<HelpText>1 LLM API </HelpText>
<HelpText>{t('llmWorkers.maxAttemptsHelp')}</HelpText>
</div>
<div>
@@ -372,7 +359,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
}}
placeholder="2000"
/>
<HelpText> (ms)</HelpText>
<HelpText>{t('llmWorkers.backoffHelp')}</HelpText>
</div>
<div>
@@ -385,7 +372,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
}}
placeholder="429"
/>
<HelpText> HTTP </HelpText>
<HelpText>{t('llmWorkers.retryableStatusHelp')}</HelpText>
</div>
</div>
);
+20 -20
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -14,17 +15,17 @@ interface McpRuntimeConfig {
}
export function McpForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const mcp: Partial<McpRuntimeConfig> = config.mcp ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">MCP</h2>
<p className="text-xs text-slate-500">
MCP (Model Context Protocol)
MCP URL
{t('mcp.intro')}
</p>
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200"></h3>
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">{t('mcp.securityTitle')}</h3>
<div>
<label className="flex items-center gap-2 text-sm text-slate-700">
@@ -34,58 +35,57 @@ export function McpForm({ config, onChange }: SectionFormProps) {
onChange={e => onChange('mcp.allowPrivateAddresses', e.target.checked)}
className="rounded"
/>
IP (self-hosted / localhost MCP )
{t('mcp.allowPrivate')}
</label>
<HelpText>
localhostLAN (192.168.x.x, 10.x.x.x ) MCP
SSRF 使デフォルト: 無効
{t('mcp.allowPrivateHelp')}
</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200"> / </h3>
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">{t('mcp.timeoutTitle')}</h3>
<div>
<FieldLabel> ()</FieldLabel>
<FieldLabel>{t('mcp.callTimeout')}</FieldLabel>
<FieldInput type="number" value={mcp.callTimeoutSeconds ?? 60}
onChange={v => onChange('mcp.callTimeoutSeconds', Number(v))} />
<HelpText>MCP 1 デフォルト: 60</HelpText>
<HelpText>{t('mcp.callTimeoutHelp')}</HelpText>
</div>
<div>
<FieldLabel> TTL ()</FieldLabel>
<FieldLabel>{t('mcp.cacheTtl')}</FieldLabel>
<FieldInput type="number" value={mcp.toolCacheTtlSeconds ?? 600}
onChange={v => onChange('mcp.toolCacheTtlSeconds', Number(v))} />
<HelpText>MCP デフォルト: 600</HelpText>
<HelpText>{t('mcp.cacheTtlHelp')}</HelpText>
</div>
<div>
<FieldLabel>OAuth pending state TTL ()</FieldLabel>
<FieldLabel>{t('mcp.oauthTtl')}</FieldLabel>
<FieldInput type="number" value={mcp.oauthPendingTtlMinutes ?? 10}
onChange={v => onChange('mcp.oauthPendingTtlMinutes', Number(v))} />
<HelpText>MCP OAuth pending デフォルト: 10</HelpText>
<HelpText>{t('mcp.oauthTtlHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200"></h3>
<h3 className="text-sm font-medium text-slate-600 pt-2 border-t border-slate-200">{t('mcp.capacityTitle')}</h3>
<div>
<FieldLabel> 1 (MB)</FieldLabel>
<FieldLabel>{t('mcp.maxBinary')}</FieldLabel>
<FieldInput type="number" value={mcp.maxBinarySizeMb ?? 20}
onChange={v => onChange('mcp.maxBinarySizeMb', Number(v))} />
<HelpText>MCP 1 MBデフォルト: 20</HelpText>
<HelpText>{t('mcp.maxBinaryHelp')}</HelpText>
</div>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('mcp.maxFiles')}</FieldLabel>
<FieldInput type="number" value={mcp.maxOutputFilesPerJob ?? 10}
onChange={v => onChange('mcp.maxOutputFilesPerJob', Number(v))} />
<HelpText>1 MCP デフォルト: 10</HelpText>
<HelpText>{t('mcp.maxFilesHelp')}</HelpText>
</div>
<div>
<FieldLabel> (MB)</FieldLabel>
<FieldLabel>{t('mcp.maxTotal')}</FieldLabel>
<FieldInput type="number" value={mcp.maxOutputSizeMbPerJob ?? 200}
onChange={v => onChange('mcp.maxOutputSizeMbPerJob', Number(v))} />
<HelpText>1 MCP MBデフォルト: 200</HelpText>
<HelpText>{t('mcp.maxTotalHelp')}</HelpText>
</div>
</div>
);
@@ -6,7 +6,9 @@
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
import i18n from '../../i18n';
// ── API types ─────────────────────────────────────────────────────────────────
@@ -65,13 +67,13 @@ async function fetchHistoryPage(cursor?: string): Promise<HistoryPage> {
const params = new URLSearchParams({ limit: '20' });
if (cursor) params.set('before', cursor);
const res = await fetch(`/api/local/reflection/history?${params}`);
if (!res.ok) throw new Error(`履歴の読み込みに失敗しました (${res.status})`);
if (!res.ok) throw new Error(i18n.t('memoryLearning.err.loadHistory', { ns: 'settings', status: res.status }));
return res.json();
}
async function fetchSnapshotDetail(snapshotId: string): Promise<SnapshotDetail> {
const res = await fetch(`/api/local/reflection/history/${encodeURIComponent(snapshotId)}`);
if (!res.ok) throw new Error(`スナップショットの読み込みに失敗しました (${res.status})`);
if (!res.ok) throw new Error(i18n.t('memoryLearning.err.loadSnapshot', { ns: 'settings', status: res.status }));
return res.json();
}
@@ -89,22 +91,24 @@ async function revertSnapshot(snapshotId: string): Promise<{ reverted: boolean }
async function fetchMetrics(days: number = 30): Promise<ReflectionMetrics> {
const res = await fetch(`/api/local/reflection/metrics?days=${days}`);
if (!res.ok) throw new Error(`メトリクスの読み込みに失敗しました (${res.status})`);
if (!res.ok) throw new Error(i18n.t('memoryLearning.err.loadMetrics', { ns: 'settings', status: res.status }));
return res.json();
}
// ── Shared UI primitives ──────────────────────────────────────────────────────
const OUTCOME_LABELS: Record<string, { label: string; cls: string }> = {
applied: { label: '適用済み', cls: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-800 dark:text-emerald-300' },
partial: { label: '一部適用', cls: 'bg-yellow-100 dark:bg-yellow-500/15 text-yellow-800 dark:text-yellow-300' },
abstained: { label: '学習なし', cls: 'bg-slate-100 text-slate-600' },
rejected: { label: '却下', cls: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-300' },
failed: { label: '失敗', cls: 'bg-red-200 dark:bg-red-500/20 text-red-900 dark:text-red-300' },
const OUTCOME_CLS: Record<string, string> = {
applied: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-800 dark:text-emerald-300',
partial: 'bg-yellow-100 dark:bg-yellow-500/15 text-yellow-800 dark:text-yellow-300',
abstained: 'bg-slate-100 text-slate-600',
rejected: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-300',
failed: 'bg-red-200 dark:bg-red-500/20 text-red-900 dark:text-red-300',
};
function OutcomeBadge({ outcome }: { outcome: string }) {
const { label, cls } = OUTCOME_LABELS[outcome] ?? { label: outcome, cls: 'bg-slate-100 text-slate-600' };
const { t } = useTranslation('settings');
const cls = OUTCOME_CLS[outcome] ?? 'bg-slate-100 text-slate-600';
const label = OUTCOME_CLS[outcome] ? t(`memoryLearning.outcome.${outcome}`) : outcome;
return (
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${cls}`}>
{label}
@@ -126,6 +130,7 @@ function formatTs(ts: string): string {
// ── SnapshotCard ──────────────────────────────────────────────────────────────
function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onReverted: () => void }) {
const { t } = useTranslation('settings');
const [expanded, setExpanded] = useState(false);
const [confirmRevert, setConfirmRevert] = useState(false);
const [revertDone, setRevertDone] = useState<boolean | null>(null);
@@ -173,7 +178,7 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
)}
{item.reverted && (
<span className="text-[10px] px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded">
revert済み
{t('memoryLearning.snapshot.reverted')}
</span>
)}
{detailQuery.data && <OutcomeBadge outcome={detailQuery.data.outcome} />}
@@ -185,11 +190,11 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
{expanded && (
<div className="border-t border-hairline bg-slate-50 px-3 py-3 space-y-3">
{detailQuery.isLoading && (
<div className="text-xs text-slate-400"></div>
<div className="text-xs text-slate-400">{t('memoryLearning.snapshot.loadingDetail')}</div>
)}
{detailQuery.error && (
<div className="text-xs text-red-600">
: {String(detailQuery.error)}
{t('memoryLearning.snapshot.detailLoadError', { err: String(detailQuery.error) })}
</div>
)}
{detailQuery.data && (() => {
@@ -211,7 +216,7 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
{d.reasoning && (
<div>
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
{t('memoryLearning.snapshot.reasoning')}
</div>
<p className="text-xs text-slate-700 whitespace-pre-wrap">{d.reasoning}</p>
</div>
@@ -220,12 +225,13 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
{d.rejections && d.rejections.length > 0 && (
<div>
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
{t('memoryLearning.snapshot.rejectionsTitle')}
</div>
<ul className="space-y-0.5">
{d.rejections.map((r, i) => (
<li key={i} className="text-2xs text-red-700 dark:text-red-300">
<span className="font-mono">{r.code}</span>
{/* 既知コードは翻訳ラベル、未知コードは生 ID にフォールバック (#469) */}
<span>{t(`memoryLearning.rejection.${r.code}`, { defaultValue: r.code })}</span>
{r.name && <span className="text-slate-500 ml-1">({r.name})</span>}
</li>
))}
@@ -236,7 +242,7 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
{d.diff && (
<div>
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
{t('memoryLearning.snapshot.changes')}
</div>
<pre className="text-2xs text-slate-700 bg-canvas border border-hairline rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap">
{d.diff}
@@ -253,17 +259,17 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
{d.pieceEdited && d.pieceBeforeYaml && d.pieceAfterYaml && (
<div>
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
Piece
{t('memoryLearning.snapshot.pieceDiff')}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.before')}</div>
<pre className="text-[10px] bg-canvas border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
{d.pieceBeforeYaml}
</pre>
</div>
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.after')}</div>
<pre className="text-[10px] bg-canvas border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
{d.pieceAfterYaml}
</pre>
@@ -276,10 +282,10 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
{!item.reverted && (
<div className="pt-1">
{revertDone === true && (
<span className="text-xs text-emerald-700 dark:text-emerald-300"> revert </span>
<span className="text-xs text-emerald-700 dark:text-emerald-300">{t('memoryLearning.snapshot.revertedOk')}</span>
)}
{revertDone === false && (
<span className="text-xs text-slate-500"> revert </span>
<span className="text-xs text-slate-500">{t('memoryLearning.snapshot.alreadyReverted')}</span>
)}
{revertDone === null && !confirmRevert && (
<button
@@ -287,13 +293,13 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
onClick={() => setConfirmRevert(true)}
className="px-2.5 h-7 text-2xs text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 hover:bg-amber-100 dark:hover:bg-amber-500/15 rounded transition-colors"
>
revert
{t('memoryLearning.snapshot.revertBtn')}
</button>
)}
{revertDone === null && confirmRevert && (
<div className="flex items-center gap-2">
<span className="text-xs text-amber-800 dark:text-amber-300">
{t('memoryLearning.snapshot.revertConfirmQ')}
</span>
<button
type="button"
@@ -301,14 +307,14 @@ function SnapshotCard({ item, onReverted }: { item: SnapshotIndexEntry; onRevert
disabled={revertMutation.isPending}
className="px-2.5 h-7 text-2xs font-semibold bg-red-600 text-white hover:bg-red-700 rounded disabled:opacity-50 transition-colors"
>
{revertMutation.isPending ? 'revert 中…' : 'revert を確定'}
{revertMutation.isPending ? t('memoryLearning.snapshot.reverting') : t('memoryLearning.snapshot.revertConfirm')}
</button>
<button
type="button"
onClick={() => setConfirmRevert(false)}
className="px-2.5 h-7 text-2xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded transition-colors"
>
{t('memoryLearning.cancel')}
</button>
</div>
)}
@@ -337,6 +343,7 @@ function BeforeAfterDiff({
beforeFiles: Record<string, string>;
afterFiles: Record<string, string>;
}) {
const { t } = useTranslation('settings');
const allNames = Array.from(
new Set([...Object.keys(beforeFiles), ...Object.keys(afterFiles)]),
).sort();
@@ -353,7 +360,7 @@ function BeforeAfterDiff({
return (
<div>
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-1">
{t('memoryLearning.diff.title')}
</div>
{allNames.length > 1 && (
<div className="flex gap-1 mb-2 flex-wrap">
@@ -375,28 +382,28 @@ function BeforeAfterDiff({
)}
{isAdded && (
<div className="text-2xs text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-500/15 border border-emerald-200 dark:border-emerald-500/30 rounded px-2 py-1 mb-1">
{t('memoryLearning.diff.added')}
</div>
)}
{isRemoved && (
<div className="text-2xs text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded px-2 py-1 mb-1">
{t('memoryLearning.diff.removed')}
</div>
)}
<div className="grid grid-cols-2 gap-2">
{!isAdded && (
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.before')}</div>
<pre className="text-[10px] bg-canvas border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
{before ?? '(空)'}
{before ?? t('memoryLearning.empty')}
</pre>
</div>
)}
{!isRemoved && (
<div className={isAdded ? 'col-span-2' : ''}>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-[10px] text-slate-400 mb-0.5">{t('memoryLearning.after')}</div>
<pre className="text-[10px] bg-canvas border border-hairline rounded px-2 py-1 overflow-auto max-h-40 whitespace-pre-wrap">
{after ?? '(空)'}
{after ?? t('memoryLearning.empty')}
</pre>
</div>
)}
@@ -408,6 +415,7 @@ function BeforeAfterDiff({
// ── MetricsSummary ────────────────────────────────────────────────────────────
function MetricsSummary() {
const { t } = useTranslation('settings');
const { data, isLoading, error } = useQuery<ReflectionMetrics>({
queryKey: ['reflection-metrics', 30],
queryFn: () => fetchMetrics(30),
@@ -415,13 +423,13 @@ function MetricsSummary() {
});
if (isLoading) {
return <div className="text-xs text-slate-400 px-4 py-3"></div>;
return <div className="text-xs text-slate-400 px-4 py-3">{t('memoryLearning.metrics.loading')}</div>;
}
if (error) {
return (
<div className="text-xs text-red-600 px-4 py-3">
: {String(error)}
{t('memoryLearning.metrics.loadError', { err: String(error) })}
</div>
);
}
@@ -436,15 +444,15 @@ function MetricsSummary() {
return (
<div className="px-4 py-3 bg-slate-50 border-t border-hairline rounded-b-lg">
<div className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide mb-2">
30
{t('memoryLearning.metrics.summary30')}
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{[
{ label: '合計実行回数', value: String(totalRuns) },
{ label: '適用率', value: `${appliedPct}%` },
{ label: '学習なし率', value: `${abstainPct}%` },
{ label: 'Tokens', value: totalTokens > 1000 ? `${Math.round(totalTokens / 1000)}k` : String(totalTokens) },
{ label: 'Piece 編集', value: String(data.pieceEdits) },
{ label: t('memoryLearning.metrics.totalRuns'), value: String(totalRuns) },
{ label: t('memoryLearning.metrics.appliedRate'), value: `${appliedPct}%` },
{ label: t('memoryLearning.metrics.abstainRate'), value: `${abstainPct}%` },
{ label: t('memoryLearning.metrics.tokens'), value: totalTokens > 1000 ? `${Math.round(totalTokens / 1000)}k` : String(totalTokens) },
{ label: t('memoryLearning.metrics.pieceEdits'), value: String(data.pieceEdits) },
].map(({ label, value }) => (
<div
key={label}
@@ -457,7 +465,7 @@ function MetricsSummary() {
</div>
{totalRuns === 0 && (
<p className="text-2xs text-slate-400 mt-2">
reflection reflection
{t('memoryLearning.metrics.noRuns')}
</p>
)}
</div>
@@ -467,14 +475,15 @@ function MetricsSummary() {
// ── ReflectionTimelinePanel ───────────────────────────────────────────────────
const OUTCOME_FILTER_OPTIONS = [
{ value: 'applied', label: '適用済み' },
{ value: 'partial', label: '一部適用' },
{ value: 'abstained', label: '学習なし' },
{ value: 'rejected', label: '却下' },
{ value: 'failed', label: '失敗' },
{ value: 'applied' },
{ value: 'partial' },
{ value: 'abstained' },
{ value: 'rejected' },
{ value: 'failed' },
];
function ReflectionTimelinePanel() {
const { t } = useTranslation('settings');
const qc = useQueryClient();
// Filters (client-side — the backend doesn't support filtering natively)
@@ -513,9 +522,9 @@ function ReflectionTimelinePanel() {
return (
<div className="rounded-lg border border-hairline">
<div className="px-4 py-3 border-b border-hairline bg-surface rounded-t-lg">
<h3 className="text-sm font-semibold text-slate-800">Reflection </h3>
<h3 className="text-sm font-semibold text-slate-800">{t('memoryLearning.timeline.title')}</h3>
<p className="text-2xs text-slate-500 mt-0.5">
reflection revert
{t('memoryLearning.timeline.subtitle')}
</p>
{/* Filters */}
@@ -527,11 +536,11 @@ function ReflectionTimelinePanel() {
onChange={e => setIncludeReverted(e.target.checked)}
className="rounded"
/>
revert
{t('memoryLearning.timeline.showReverted')}
</label>
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-2xs text-slate-500">:</span>
<span className="text-2xs text-slate-500">{t('memoryLearning.timeline.resultLabel')}</span>
{OUTCOME_FILTER_OPTIONS.map(opt => (
<label key={opt.value} className="flex items-center gap-1 text-2xs text-slate-600 cursor-pointer">
<input
@@ -552,7 +561,7 @@ function ReflectionTimelinePanel() {
}
}}
/>
{opt.label}
{t(`memoryLearning.outcome.${opt.value}`)}
</label>
))}
{outcomeFilter.length > 0 && (
@@ -561,7 +570,7 @@ function ReflectionTimelinePanel() {
onClick={() => setOutcomeFilter([])}
className="text-[10px] text-accent underline"
>
{t('memoryLearning.timeline.reset')}
</button>
)}
</div>
@@ -570,20 +579,20 @@ function ReflectionTimelinePanel() {
<div className="p-3 space-y-2">
{isLoading && (
<div className="text-xs text-slate-400 text-center py-4"></div>
<div className="text-xs text-slate-400 text-center py-4">{t('memoryLearning.loading')}</div>
)}
{error && (
<div className="text-xs text-red-600 px-2">
: {String(error)}
{t('memoryLearning.timeline.loadError', { err: String(error) })}
</div>
)}
{!isLoading && filteredItems.length === 0 && (
<div className="text-center py-6">
<p className="text-xs text-slate-400"> reflection </p>
<p className="text-xs text-slate-400">{t('memoryLearning.timeline.emptyTitle')}</p>
<p className="text-2xs text-slate-400 mt-1">
reflection
{t('memoryLearning.timeline.emptyHint')}
</p>
</div>
)}
@@ -600,7 +609,7 @@ function ReflectionTimelinePanel() {
disabled={isFetchingNextPage}
className="px-3 h-8 text-xs text-slate-600 border border-hairline bg-canvas hover:bg-surface rounded-md disabled:opacity-50 transition-colors"
>
{isFetchingNextPage ? '読み込み中…' : 'さらに表示'}
{isFetchingNextPage ? t('memoryLearning.loading') : t('memoryLearning.timeline.loadMore')}
</button>
</div>
)}
@@ -614,12 +623,12 @@ function ReflectionTimelinePanel() {
// ── MemoryLearningForm (root export) ──────────────────────────────────────────
export function MemoryLearningForm() {
const { t } = useTranslation('settings');
return (
<div className="space-y-6">
<h2 className="text-base font-semibold text-slate-800">Reflection </h2>
<h2 className="text-base font-semibold text-slate-800">{t('memoryLearning.rootTitle')}</h2>
<p className="text-xs text-slate-500 -mt-4">
reflectionrevert
memory/
{t('memoryLearning.rootIntro')}
</p>
<ReflectionTimelinePanel />
+8 -8
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
@@ -27,6 +28,7 @@ function MetricsBlock({
config: any;
onChange: (path: string, value: any) => void;
}) {
const { t } = useTranslation('settings');
const root = path.split('.').reduce((acc: any, key) => (acc ?? {})[key], config) ?? {};
return (
<section className="space-y-4 border border-hairline rounded-md p-4">
@@ -39,7 +41,7 @@ function MetricsBlock({
checked={root.enabled === true}
onChange={e => onChange(`${path}.enabled`, e.target.checked)}
/>
<span></span>
<span>{t('metrics.enable')}</span>
</label>
</div>
@@ -50,7 +52,7 @@ function MetricsBlock({
onChange={v => onChange(`${path}.prefix`, v || undefined)}
placeholder={prefixDefault}
/>
<HelpText>Prometheus metric prefix: <code>{prefixDefault}</code></HelpText>
<HelpText>{t('metrics.prefixHelp', { prefix: prefixDefault })}</HelpText>
</div>
<div>
@@ -62,8 +64,7 @@ function MetricsBlock({
placeholder="env:METRICS_BEARER_TOKEN"
/>
<HelpText>
<code>/metrics</code> Bearer token
<code>env:NAME</code>
{t('metrics.bearerHelp')}
</HelpText>
</div>
@@ -74,20 +75,19 @@ function MetricsBlock({
onChange={v => onChange(`${path}.allowedHosts`, v)}
placeholder="127.0.0.1 / ::1 / localhost"
/>
<HelpText> host (IP / hostname) token </HelpText>
<HelpText>{t('metrics.allowedHostsHelp')}</HelpText>
</div>
</section>
);
}
export function MetricsForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Metrics</h2>
<HelpText>
LLM Worker AAO Gateway Server Prometheus metrics
config v2 <code className="font-mono">llm.metrics</code> {' '}
<code className="font-mono">gateway.metrics</code>
{t('metrics.intro')}
</HelpText>
<MetricsBlock
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MovementForm } from './MovementForm';
export interface MovementAccordionProps {
@@ -11,6 +12,7 @@ export interface MovementAccordionProps {
}
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove, disabled = false }: MovementAccordionProps) {
const { t } = useTranslation('settings');
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const movementNames = movements.map((m) => m.name ?? '');
@@ -74,7 +76,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
<button
type="button"
onClick={() => {
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
if (confirm(t('movementAccordion.confirmDelete', { name: movement.name }))) {
onRemove(i);
if (expandedIndex === i) setExpandedIndex(null);
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { ToolTagInput } from './ToolTagInput';
import { RulesTable } from './RulesTable';
@@ -12,6 +13,7 @@ export interface MovementFormProps {
}
export function MovementForm({ movement, movementNames, onChange, disabled = false }: MovementFormProps) {
const { t } = useTranslation('settings');
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
@@ -67,7 +69,7 @@ export function MovementForm({ movement, movementNames, onChange, disabled = fal
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> Write / Edit LLM </HelpText>
<HelpText>{t('movement.editHelp')}</HelpText>
</div>
{/* instruction */}
@@ -80,7 +82,7 @@ export function MovementForm({ movement, movementNames, onChange, disabled = fal
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono ${disabledClass}`}
/>
<HelpText>LLM Markdown 使</HelpText>
<HelpText>{t('movement.instructionHelp')}</HelpText>
</div>
{/* allowed_tools */}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface NamespaceEditorProps {
value: Record<string, { apiKey: string }>;
@@ -22,6 +23,7 @@ export function NamespaceEditor({
addDisabledReason,
addDisabledHref,
}: NamespaceEditorProps) {
const { t } = useTranslation('settings');
const [newName, setNewName] = useState('');
const [newApiKey, setNewApiKey] = useState('');
@@ -90,8 +92,8 @@ export function NamespaceEditor({
disabled={addDisabled}
title={disabledTitle}
className="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed disabled:hover:bg-slate-200"
aria-label={addDisabled ? '新規追加は無効化されています' : '新規追加'}
>+ </button>
aria-label={addDisabled ? t('namespaceEditor.addAriaDisabled') : t('namespaceEditor.addAria')}
>{t('namespaceEditor.add')}</button>
{addDisabled && addDisabledHref && (
<a
href={addDisabledHref}
@@ -99,7 +101,7 @@ export function NamespaceEditor({
rel="noopener noreferrer"
className="px-2 py-1.5 text-xs text-accent underline self-center"
title={disabledTitle}
>MCP </a>
>{t('namespaceEditor.mcpGuide')}</a>
)}
</div>
</div>
+10 -8
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -7,41 +8,42 @@ import type { SectionFormProps } from './types';
* of a subscribed note is injected into the agent's context per job.
*/
export function NotesForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const inject = config.notes?.inject ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Notes Injection</h2>
<p className="text-[13px] text-slate-500">
context
{t('notes.intro')}
</p>
<div>
<FieldLabel>Per-Note Max (KB)</FieldLabel>
<FieldInput type="number" value={inject.perNoteMaxKb ?? ''}
onChange={v => onChange('notes.inject.perNoteMaxKb', v ? Number(v) : undefined)} />
<HelpText>1 デフォルト: 8 KB</HelpText>
<HelpText>{t('notes.perNoteHelp')}</HelpText>
</div>
<div>
<FieldLabel>Total Max (KB)</FieldLabel>
<FieldInput type="number" value={inject.totalMaxKb ?? ''}
onChange={v => onChange('notes.inject.totalMaxKb', v ? Number(v) : undefined)} />
<HelpText>デフォルト: 32 KB</HelpText>
<HelpText>{t('notes.totalHelp')}</HelpText>
</div>
<div>
<FieldLabel>Over-Budget Strategy</FieldLabel>
<FieldLabel>{t('notes.overBudgetLabel')}</FieldLabel>
<select
value={inject.overBudgetStrategy ?? 'skip_remaining'}
onChange={e => onChange('notes.inject.overBudgetStrategy', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
>
<option value="skip_remaining">skip_remaining</option>
<option value="truncate_last">truncate_last</option>
<option value="degrade_to_search">degrade_to_search</option>
<option value="skip_remaining">{t('notes.skipRemaining')}</option>
<option value="truncate_last">{t('notes.truncateLast')}</option>
<option value="degrade_to_search">{t('notes.degradeToSearch')}</option>
</select>
<HelpText>デフォルト: skip_remaining</HelpText>
<HelpText>{t('notes.overBudgetHelp')}</HelpText>
</div>
</div>
);
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import {
isNotificationSupported,
@@ -32,21 +33,16 @@ import {
} from '../../api';
import { HelpText } from './HelpText';
const EVENT_LABELS: Array<{ key: NotifyEventType; label: string }> = [
{ key: 'running', label: 'タスク開始 (running)' },
{ key: 'succeeded', label: 'タスク完了 (succeeded)' },
{ key: 'failed', label: 'タスク失敗 (failed / aborted)' },
{ key: 'waiting_human', label: 'ユーザー回答待ち (waiting_human)' },
];
const EVENT_KEYS: NotifyEventType[] = ['running', 'succeeded', 'failed', 'waiting_human'];
type PushAvailability =
| { kind: 'supported' }
| { kind: 'needs-pwa-ios' }
| { kind: 'unsupported'; reason: string };
| { kind: 'unsupported' };
function evaluatePushAvailability(): PushAvailability {
if (!isPushSupported()) {
return { kind: 'unsupported', reason: 'お使いのブラウザは Web Push API に対応していません' };
return { kind: 'unsupported' };
}
if (isIOS() && !isStandalonePWA()) {
return { kind: 'needs-pwa-ios' };
@@ -55,6 +51,7 @@ function evaluatePushAvailability(): PushAvailability {
}
export function NotificationsForm() {
const { t } = useTranslation('settings');
const supported = isNotificationSupported();
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
getNotificationPermission(),
@@ -179,8 +176,8 @@ export function NotificationsForm() {
return (
<div className="space-y-4">
<section>
<h3 className="text-sm font-bold text-slate-900"></h3>
<HelpText>使 Notification API </HelpText>
<h3 className="text-sm font-bold text-slate-900">{t('notifications.v1UnsupportedTitle')}</h3>
<HelpText>{t('notifications.v1Unsupported')}</HelpText>
</section>
</div>
);
@@ -195,7 +192,7 @@ export function NotificationsForm() {
const handleTestV1 = () => {
const opts = buildNotificationOptions(
{ id: 0, title: 'テスト通知', pieceName: 'ブラウザ通知は正常に動作しています' },
{ id: 0, title: t('notifications.testTitle'), pieceName: t('notifications.testBody') },
'succeeded',
);
createNotification(opts, () => { /* no-op */ });
@@ -263,31 +260,31 @@ export function NotificationsForm() {
};
const v1StatusBadge = (() => {
if (permission === 'granted' && enabled) return '✅ 有効化済み';
if (permission === 'granted' && !enabled) return '⏸ 一時停止中';
if (permission === 'denied') return '🚫 ブラウザで拒否';
return '❌ 未許可';
if (permission === 'granted' && enabled) return t('notifications.status.enabled');
if (permission === 'granted' && !enabled) return t('notifications.status.paused');
if (permission === 'denied') return t('notifications.status.denied');
return t('notifications.status.notAllowed');
})();
return (
<div className="space-y-6">
{/* ── V1: 前面通知 ── */}
{/* ── V1: foreground notifications ── */}
<section>
<h3 className="text-sm font-bold text-slate-900"> (V1: 前面表示)</h3>
<p className="mt-1 text-[13px] text-slate-700">: {v1StatusBadge}</p>
<h3 className="text-sm font-bold text-slate-900">{t('notifications.v1Title')}</h3>
<p className="mt-1 text-[13px] text-slate-700">{t('notifications.statusLabel', { status: v1StatusBadge })}</p>
{permission === 'default' && (
<button
onClick={handleEnable}
className="mt-2 px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px]"
>
{t('notifications.enableButton')}
</button>
)}
{permission === 'denied' && (
<HelpText>
{t('notifications.deniedHelp')}
</HelpText>
)}
@@ -298,7 +295,7 @@ export function NotificationsForm() {
checked={enabled}
onChange={e => void setEnabled(e.target.checked)}
/>
( ON/OFF)
{t('notifications.masterToggle')}
</label>
)}
@@ -308,31 +305,30 @@ export function NotificationsForm() {
disabled={!enabled}
className="mt-2 px-3 py-1.5 rounded border border-slate-300 text-[13px]"
>
()
{t('notifications.testV1')}
</button>
)}
</section>
{/* ── V2: モバイル / バックグラウンド通知 ── */}
{/* ── V2: mobile / background notifications ── */}
<section>
<h3 className="text-sm font-bold text-slate-900">📱 / (V2)</h3>
<h3 className="text-sm font-bold text-slate-900">{t('notifications.v2Title')}</h3>
{pushAvailable.kind === 'unsupported' && (
<HelpText>{pushAvailable.reason}</HelpText>
<HelpText>{t('notifications.pushUnsupported')}</HelpText>
)}
{pushAvailable.kind === 'needs-pwa-ios' && (
<HelpText>
iOS Safari
{t('notifications.iosPwaHelp')}
</HelpText>
)}
{pushAvailable.kind === 'supported' && (
<div className="mt-2 space-y-2">
<p className="text-[13px] text-slate-700">
: {hasLocalSubscription ? '✅ このデバイスで購読中' : '❌ このデバイスは未購読'}
{subscriptions.length > 0 && ` (合計 ${subscriptions.length} デバイス)`}
{hasLocalSubscription ? t('notifications.deviceSubscribed') : t('notifications.deviceNotSubscribed')}
{subscriptions.length > 0 && t('notifications.deviceCount', { count: subscriptions.length })}
</p>
<div className="flex gap-2">
<button
@@ -340,7 +336,7 @@ export function NotificationsForm() {
disabled={busy || hasLocalSubscription || !enabled}
className="px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px] disabled:opacity-50"
>
{t('notifications.subscribe')}
</button>
{hasLocalSubscription && (
<button
@@ -348,7 +344,7 @@ export function NotificationsForm() {
disabled={busy}
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
>
{t('notifications.unsubscribe')}
</button>
)}
<button
@@ -356,23 +352,23 @@ export function NotificationsForm() {
disabled={busy || subscriptions.length === 0}
className="px-3 py-1.5 rounded border border-slate-300 text-[13px]"
>
()
{t('notifications.testV2')}
</button>
</div>
{subscriptions.length > 0 && (
<div className="mt-2 border border-slate-200 rounded">
<p className="px-3 py-1 text-[12px] text-slate-600 border-b border-slate-200">
{t('notifications.deviceListTitle')}
</p>
{subscriptions.map(s => (
<div key={s.id} className="flex items-center justify-between px-3 py-2 text-[13px] border-b border-slate-100 last:border-b-0">
<div>
<div className="truncate max-w-md">{s.userAgent ?? '(unknown)'}</div>
<div className="truncate max-w-md">{s.userAgent ?? t('notifications.unknownDevice')}</div>
<div className="text-[11px] text-slate-500">
{s.endpointHost} {new Date(s.createdAt).toLocaleString('ja-JP')}
{s.failureCount > 0 && (
<span className="ml-2 text-red-600"> {s.failureCount} failures</span>
<span className="ml-2 text-red-600">{t('notifications.failures', { count: s.failureCount })}</span>
)}
</div>
</div>
@@ -381,7 +377,7 @@ export function NotificationsForm() {
disabled={busy}
className="ml-2 px-2 py-1 text-[11px] text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 rounded"
>
{t('notifications.removeDevice')}
</button>
</div>
))}
@@ -395,25 +391,25 @@ export function NotificationsForm() {
checked={includeDetails}
onChange={e => void setIncludeDetails(e.target.checked)}
/>
piece
{t('notifications.includeDetails')}
<span className="text-[11px] text-slate-500">
(OFF: #N )
{t('notifications.includeDetailsHint')}
</span>
</label>
)}
{pushFatal && (
<p className="text-[12px] text-red-700 dark:text-red-300">: {pushFatal}</p>
<p className="text-[12px] text-red-700 dark:text-red-300">{t('notifications.error', { msg: pushFatal })}</p>
)}
</div>
)}
</section>
{/* ── 通知するイベント (V1 + V2 共通) ── */}
{/* ── events to notify (V1 + V2 shared) ── */}
<section>
<h3 className="text-sm font-bold text-slate-900"></h3>
<h3 className="text-sm font-bold text-slate-900">{t('notifications.eventsTitle')}</h3>
<div className="mt-2 space-y-1">
{EVENT_LABELS.map(({ key, label }) => (
{EVENT_KEYS.map(key => (
<label key={key} className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
@@ -421,16 +417,16 @@ export function NotificationsForm() {
onChange={() => void toggleEvent(key)}
disabled={!enabled}
/>
{label}
{t(`notifications.events.${key}`)}
</label>
))}
</div>
</section>
<HelpText>
V1 () <br />
V2 ( / ) HTTPS + PWA <br />
owner
{t('notifications.footer1')}<br />
{t('notifications.footer2')}<br />
{t('notifications.footer3')}
</HelpText>
</div>
);
+16 -14
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { HelpText } from './HelpText';
@@ -20,6 +21,7 @@ async function jget<T>(url: string): Promise<T> {
}
export function OrgsForm() {
const { t } = useTranslation('settings');
const qc = useQueryClient();
const orgsQ = useQuery<LocalOrg[]>({
queryKey: ['admin', 'orgs'],
@@ -80,8 +82,7 @@ export function OrgsForm() {
<div>
<h2 className="text-base font-semibold text-slate-800 mb-1">Organizations</h2>
<p className="text-xs text-slate-500">
/ <code>org</code>
Gitea
{t('orgs.intro')}
</p>
</div>
@@ -92,18 +93,18 @@ export function OrgsForm() {
<input
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="新しい組織名"
placeholder={t('orgs.newNamePlaceholder')}
className="flex-1 h-9 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
/>
<button type="submit" disabled={!newName.trim() || createMut.isPending} className="px-3 h-9 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90 whitespace-nowrap">
+
{t('orgs.create')}
</button>
</form>
{orgsQ.isLoading && <div className="text-xs text-slate-500">...</div>}
{orgsQ.isLoading && <div className="text-xs text-slate-500">{t('orgs.loading')}</div>}
{!orgsQ.isLoading && orgs.length === 0 && (
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 text-center">
{t('orgs.empty')}
</div>
)}
@@ -115,13 +116,13 @@ export function OrgsForm() {
users={users}
userLabel={userLabel}
onRename={(name) => renameMut.mutate({ id: org.id, name })}
onDelete={() => { if (confirm(`組織「${org.name}」を削除しますか?\nこの組織に共有されているタスク等は private に戻ります。`)) deleteMut.mutate(org.id); }}
onDelete={() => { if (confirm(t('orgs.confirmDelete', { name: org.name }))) deleteMut.mutate(org.id); }}
onAddMember={(userId) => addMemberMut.mutate({ id: org.id, userId })}
onRemoveMember={(userId) => removeMemberMut.mutate({ id: org.id, userId })}
/>
))}
</div>
<HelpText>/</HelpText>
<HelpText>{t('orgs.memberHint')}</HelpText>
</div>
);
}
@@ -135,6 +136,7 @@ function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRem
onAddMember: (userId: string) => void;
onRemoveMember: (userId: string) => void;
}) {
const { t } = useTranslation('settings');
const [name, setName] = useState(org.name);
const memberIds = new Set(org.members.map(m => m.userId));
const addable = users.filter(u => !memberIds.has(u.id));
@@ -150,25 +152,25 @@ function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRem
className="flex-1 h-8 px-2 text-[13px] font-medium border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
/>
<button type="button" onClick={onDelete} className="px-2.5 h-8 rounded-md text-xs font-medium border border-red-200 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 whitespace-nowrap">
{t('orgs.delete')}
</button>
</div>
<div className="text-2xs text-slate-500 mb-1.5">{org.members.length}</div>
<div className="text-2xs text-slate-500 mb-1.5">{t('orgs.membersCount', { count: org.members.length })}</div>
<div className="flex flex-wrap gap-1.5 mb-2.5">
{org.members.length === 0 && <span className="text-2xs text-slate-400"></span>}
{org.members.length === 0 && <span className="text-2xs text-slate-400">{t('orgs.noMembers')}</span>}
{org.members.map(m => (
<span key={m.userId} className="inline-flex items-center gap-1.5 pl-2 pr-1 h-6 rounded border border-hairline bg-surface text-slate-700 text-2xs">
{userLabel(m.userId)}
{m.role === 'owner' && <span className="text-[9px] text-blue-600">owner</span>}
<button type="button" onClick={() => onRemoveMember(m.userId)} title="削除" className="text-slate-400 hover:text-red-500 leading-none px-0.5">×</button>
<button type="button" onClick={() => onRemoveMember(m.userId)} title={t('orgs.removeMemberTitle')} className="text-slate-400 hover:text-red-500 leading-none px-0.5">×</button>
</span>
))}
</div>
<div className="flex items-center gap-2">
<select value={pick} onChange={e => setPick(e.target.value)} className="flex-1 h-8 px-2 text-xs border border-hairline rounded-md bg-canvas">
<option value="">...</option>
<option value="">{t('orgs.addMemberPlaceholder')}</option>
{addable.map(u => <option key={u.id} value={u.id}>{u.name || u.email}</option>)}
</select>
<button
@@ -177,7 +179,7 @@ function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRem
onClick={() => { if (pick) { onAddMember(pick); setPick(''); } }}
className="px-3 h-8 rounded-md text-xs font-medium border border-accent/60 text-accent hover:bg-accent-soft disabled:opacity-40 whitespace-nowrap"
>
{t('orgs.add')}
</button>
</div>
</div>
+11 -11
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -11,13 +12,14 @@ import type { SectionFormProps } from './types';
* `storage.*`.
*/
export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
const { t } = useTranslation('settings');
const storage = config.storage ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Paths &amp; Storage</h2>
<HelpText>
config v2 <code className="font-mono">storage.*</code>
{t('pathsStorage.intro')}
</HelpText>
<div>
@@ -26,10 +28,10 @@ export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionF
value={storage.worktreeDir ?? ''}
onChange={v => onChange('storage.worktreeDir', v || undefined)}
disabled={!!overriddenByEnv['storage.worktreeDir'] || !!overriddenByEnv['worktreeDir']}
disabledReason="WORKTREE_DIR 環境変数で上書き中"
disabledReason={t('pathsStorage.worktreeOverride')}
/>
{(overriddenByEnv['storage.worktreeDir'] || overriddenByEnv['worktreeDir']) && <EnvOverrideWarning />}
<HelpText></HelpText>
<HelpText>{t('pathsStorage.worktreeHelp')}</HelpText>
</div>
<div>
@@ -39,7 +41,7 @@ export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionF
onChange={v => onChange('storage.customPiecesDir', v || undefined)}
placeholder="/path/to/your/custom-pieces"
/>
<HelpText> pieces/ Piece pieces/ 使</HelpText>
<HelpText>{t('pathsStorage.customPiecesHelp')}</HelpText>
</div>
<div>
@@ -49,32 +51,30 @@ export function PathsStorageForm({ config, onChange, overriddenByEnv }: SectionF
onChange={v => onChange('storage.userFolderRoot', v || undefined)}
placeholder="./data/users"
/>
<HelpText></HelpText>
<HelpText>{t('pathsStorage.userFolderHelp')}</HelpText>
</div>
<div>
<FieldLabel>Task Upload (MB)</FieldLabel>
<FieldLabel>{t('pathsStorage.taskUploadLabel')}</FieldLabel>
<FieldInput
type="number"
value={storage.taskUploadMaxSizeMb ?? 50}
onChange={v => onChange('storage.taskUploadMaxSizeMb', v ? Number(v) : undefined)}
/>
<HelpText>
<code>POST /api/local/tasks</code> <code>POST /api/local/tasks/:id/comments</code>
body 11000 MB 50
{t('pathsStorage.taskUploadHelp')}
</HelpText>
</div>
<div>
<FieldLabel>Trash Retention ()</FieldLabel>
<FieldLabel>{t('pathsStorage.trashLabel')}</FieldLabel>
<FieldInput
type="number"
value={storage.trashRetentionDays ?? 30}
onChange={v => onChange('storage.trashRetentionDays', v ? Number(v) : undefined)}
/>
<HelpText>
<code>data/users/&#123;userId&#125;/trash/</code>
0 30
{t('pathsStorage.trashHelp')}
</HelpText>
</div>
</div>
+17 -13
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { stringify, parse } from 'yaml';
import { usePiece } from '../../hooks/usePieces';
@@ -20,6 +21,7 @@ export interface PieceEditorProps {
}
export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps) {
const { t } = useTranslation('settings');
const { data: fetchResult, isLoading, error } = usePiece(name, source);
// Use the server-resolved source as the authoritative value; fall back to the
// prop only while the fetch hasn't completed yet (avoids flicker on known paths).
@@ -32,6 +34,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const [toastIsError, setToastIsError] = useState(false);
// YAML editing mode
const [editMode, setEditMode] = useState<'visual' | 'yaml'>('visual');
@@ -47,9 +50,10 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
}
}, [piece]); // piece is derived from fetchResult above
const showToast = (msg: string, duration = 2000) => {
const showToast = (msg: string, opts: { isError?: boolean; duration?: number } = {}) => {
setToastIsError(opts.isError ?? false);
setToast(msg);
setTimeout(() => setToast(null), duration);
setTimeout(() => setToast(null), opts.duration ?? 2000);
};
const handleMetaChange = useCallback((field: string, value: any) => {
@@ -122,7 +126,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
try {
const parsed = parse(yamlText);
if (!parsed || typeof parsed !== 'object') {
setYamlError('YAML のパースに失敗しました');
setYamlError(t('pieceEditor.yamlParseFailed'));
return;
}
setDraft(parsed);
@@ -157,11 +161,11 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
try {
saveData = parse(yamlText);
if (!saveData || typeof saveData !== 'object') {
showToast('エラー: YAML のパースに失敗しました', 3000);
showToast(t('pieceEditor.toastSaveYamlParse'), { isError: true, duration: 3000 });
return;
}
} catch (e: any) {
showToast(`エラー: YAML パースエラー — ${e.message}`, 3000);
showToast(t('pieceEditor.toastSaveYamlError', { msg: e.message }), { isError: true, duration: 3000 });
return;
}
}
@@ -178,27 +182,27 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
if (editMode === 'yaml') {
setDraft(saveData);
}
showToast('保存しました');
showToast(t('pieceEditor.toastSaved'));
} catch (e: any) {
showToast(`エラー: ${e.message}`, 3000);
showToast(t('pieceEditor.toastError', { msg: e.message }), { isError: true, duration: 3000 });
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
if (!confirm(t('pieceEditor.confirmDelete', { name }))) return;
try {
await deletePiece(name, effectiveSource);
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
} catch (e: any) {
showToast(`エラー: ${e.message}`, 3000);
showToast(t('pieceEditor.toastError', { msg: e.message }), { isError: true, duration: 3000 });
}
};
if (isLoading) return <div className="text-sm text-slate-400">Loading...</div>;
if (error) return <div className="text-sm text-red-500">Piece </div>;
if (error) return <div className="text-sm text-red-500">{t('pieceEditor.loadError')}</div>;
if (!draft) return null;
// Non-admins cannot edit built-in or global-custom pieces — read-only view.
@@ -231,7 +235,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
)}
{readonly && (
<span className="px-2 py-1 text-xs text-slate-400 bg-slate-100 rounded border border-slate-200">
{t('pieceEditor.readonly')}
</span>
)}
</div>
@@ -302,7 +306,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
style={{ minHeight: '500px', tabSize: 2 }}
/>
<p className="text-xs text-slate-400 mt-1">
YAML Visual
{t('pieceEditor.yamlHelp')}
</p>
</div>
)}
@@ -311,7 +315,7 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
{!readonly && (
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
{toast && (
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
<span className={`text-xs mr-auto ${toastIsError ? 'text-red-500' : 'text-green-600'}`}>
{toast}
</span>
)}
+6 -4
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
export interface PieceMetaFormProps {
@@ -8,6 +9,7 @@ export interface PieceMetaFormProps {
}
export function PieceMetaForm({ piece, onChange, movementNames, disabled = false }: PieceMetaFormProps) {
const { t } = useTranslation('settings');
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
@@ -22,7 +24,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
readOnly
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg bg-slate-50 text-slate-500 outline-none cursor-not-allowed"
/>
<HelpText>使</HelpText>
<HelpText>{t('pieceMeta.nameHelp')}</HelpText>
</div>
{/* description */}
@@ -48,7 +50,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
disabled={disabled}
className={`w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
/>
<HelpText>1 movement </HelpText>
<HelpText>{t('pieceMeta.maxMovementsHelp')}</HelpText>
</div>
{/* initial_movement */}
@@ -65,7 +67,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
<option key={name} value={name}>{name}</option>
))}
</select>
<HelpText> movement </HelpText>
<HelpText>{t('pieceMeta.initialMovementHelp')}</HelpText>
</div>
{/* triggers.keywords */}
@@ -85,7 +87,7 @@ export function PieceMetaForm({ piece, onChange, movementNames, disabled = false
disabled={disabled}
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
/>
<HelpText> piece </HelpText>
<HelpText>{t('pieceMeta.keywordsHelp')}</HelpText>
</div>
</div>
);
+30 -11
View File
@@ -1,15 +1,25 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchMyOrgs, Visibility } from '../../api';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, type SupportedLanguage } from '../../i18n';
const LANGUAGE_LABELS: Record<SupportedLanguage, string> = { en: 'English', ja: '日本語' };
export function PreferencesForm({ user }: { user: { defaultVisibility: Visibility; defaultVisibilityOrgId: string | null } }) {
const { t, i18n } = useTranslation('settings');
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs });
const qc = useQueryClient();
const [vis, setVis] = useState<Visibility>(user.defaultVisibility);
const [orgId, setOrgId] = useState<string | null>(user.defaultVisibilityOrgId);
useEffect(() => { setVis(user.defaultVisibility); setOrgId(user.defaultVisibilityOrgId); }, [user]);
const changeLanguage = (lng: string) => {
void i18n.changeLanguage(lng);
try { localStorage.setItem(LANGUAGE_STORAGE_KEY, lng); } catch { /* storage may be unavailable */ }
};
const save = useMutation({
mutationFn: async () => {
const res = await fetch('/api/users/me/preferences', {
@@ -25,15 +35,24 @@ export function PreferencesForm({ user }: { user: { defaultVisibility: Visibilit
return (
<div className="space-y-4">
<section>
<h3 className="text-sm font-bold text-slate-900"></h3>
<h3 className="text-sm font-bold text-slate-900">{t('preferences.language.title')}</h3>
<div className="mt-2 flex gap-3 text-[13px]">
<label><input type="radio" checked={vis === 'private'} onChange={() => setVis('private')} /> 🔒 </label>
<label><input type="radio" checked={vis === 'org'} onChange={() => setVis('org')} disabled={orgs.length === 0} /> 🏢 </label>
<label><input type="radio" checked={vis === 'public'} onChange={() => setVis('public')} /> 🌐 </label>
{SUPPORTED_LANGUAGES.map(lng => (
<label key={lng}>
<input type="radio" name="ui-language" checked={i18n.resolvedLanguage === lng} onChange={() => changeLanguage(lng)} /> {LANGUAGE_LABELS[lng]}
</label>
))}
</div>
<HelpText>
🔒 非公開: 自分のみ閲覧可能 🏢 組織: 同じ Gitea org 🌐 公開: ログイン中の全ユーザーが閲覧可能
</HelpText>
<HelpText>{t('preferences.language.help')}</HelpText>
</section>
<section>
<h3 className="text-sm font-bold text-slate-900">{t('preferences.visibility.title')}</h3>
<div className="mt-2 flex gap-3 text-[13px]">
<label><input type="radio" checked={vis === 'private'} onChange={() => setVis('private')} /> 🔒 {t('preferences.visibility.private')}</label>
<label><input type="radio" checked={vis === 'org'} onChange={() => setVis('org')} disabled={orgs.length === 0} /> 🏢 {t('preferences.visibility.org')}</label>
<label><input type="radio" checked={vis === 'public'} onChange={() => setVis('public')} /> 🌐 {t('preferences.visibility.public')}</label>
</div>
<HelpText>{t('preferences.visibility.help')}</HelpText>
{vis === 'org' && (
<select value={orgId ?? ''} onChange={e => setOrgId(e.target.value)} className="mt-2 px-2 py-1 border rounded text-[13px]">
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
@@ -41,19 +60,19 @@ export function PreferencesForm({ user }: { user: { defaultVisibility: Visibilit
)}
</section>
<section>
<h3 className="text-sm font-bold text-slate-900"> Gitea </h3>
<h3 className="text-sm font-bold text-slate-900">{t('preferences.orgs.title')}</h3>
<ul className="mt-2 text-[13px] text-slate-700 list-disc pl-5">
{orgs.map(o => <li key={o.orgId}>{o.orgName}</li>)}
{orgs.length === 0 && <li className="text-slate-400"> Gitea </li>}
{orgs.length === 0 && <li className="text-slate-400">{t('preferences.orgs.none')}</li>}
</ul>
<p className="mt-2 text-2xs text-slate-500"></p>
<p className="mt-2 text-2xs text-slate-500">{t('preferences.orgs.refreshHint')}</p>
</section>
<button
onClick={() => save.mutate()}
disabled={save.isPending}
className="px-3 py-1.5 rounded bg-accent text-accent-fg text-[13px]"
>
{save.isPending ? '保存中…' : '設定を保存'}
{save.isPending ? t('preferences.saving') : t('preferences.save')}
</button>
{save.isError && <div className="text-red-600 text-xs">{String(save.error)}</div>}
</div>
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -9,14 +10,14 @@ import type { SectionFormProps } from './types';
* subscription, not whether the server feature is enabled at all).
*/
export function PushNotificationsForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const push = config.notifications?.push ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Web Push (Server)</h2>
<h2 className="text-base font-semibold text-slate-800">{t('pushNotifications.title')}</h2>
<p className="text-[13px] text-slate-500">
V2Web PushHTTPS iOS PWA
🔔 Notifications
{t('pushNotifications.intro')}
</p>
<div>
@@ -27,51 +28,51 @@ export function PushNotificationsForm({ config, onChange }: SectionFormProps) {
onChange={e => onChange('notifications.push.enabled', e.target.checked)}
className="rounded"
/>
Web Push
{t('pushNotifications.enableLabel')}
</label>
<HelpText>デフォルト: 無効 opt-in</HelpText>
<HelpText>{t('pushNotifications.enableHelp')}</HelpText>
</div>
<div>
<FieldLabel>VAPID Subject</FieldLabel>
<FieldLabel>{t('pushNotifications.subjectLabel')}</FieldLabel>
<FieldInput value={push.vapidSubject ?? ''} placeholder="https://example.com/"
onChange={v => onChange('notifications.push.vapidSubject', v || undefined)} />
<HelpText>RFC 8292 VAPID subject mailto: より運用 URL </HelpText>
<HelpText>{t('pushNotifications.subjectHelp')}</HelpText>
</div>
<div>
<FieldLabel>VAPID Current Key Path</FieldLabel>
<FieldLabel>{t('pushNotifications.currentPathLabel')}</FieldLabel>
<FieldInput value={push.vapidCurrentPath ?? ''} placeholder="./data/secrets/vapid.json"
onChange={v => onChange('notifications.push.vapidCurrentPath', v || undefined)} />
<HelpText> VAPID mode 0600 </HelpText>
<HelpText>{t('pushNotifications.currentPathHelp')}</HelpText>
</div>
<div>
<FieldLabel>VAPID History Dir</FieldLabel>
<FieldLabel>{t('pushNotifications.historyDirLabel')}</FieldLabel>
<FieldInput value={push.vapidHistoryDir ?? ''} placeholder="./data/secrets/vapid-history"
onChange={v => onChange('notifications.push.vapidHistoryDir', v || undefined)} />
<HelpText> VAPID 退</HelpText>
<HelpText>{t('pushNotifications.historyDirHelp')}</HelpText>
</div>
<div>
<FieldLabel>Payload Max Bytes</FieldLabel>
<FieldLabel>{t('pushNotifications.payloadMaxLabel')}</FieldLabel>
<FieldInput type="number" value={push.payloadMaxBytes ?? ''}
onChange={v => onChange('notifications.push.payloadMaxBytes', v ? Number(v) : undefined)} />
<HelpText> 4096デフォルト: 3072</HelpText>
<HelpText>{t('pushNotifications.payloadMaxHelp')}</HelpText>
</div>
<div>
<FieldLabel>Queue Concurrency</FieldLabel>
<FieldLabel>{t('pushNotifications.queueConcurrencyLabel')}</FieldLabel>
<FieldInput type="number" value={push.queueConcurrency ?? ''}
onChange={v => onChange('notifications.push.queueConcurrency', v ? Number(v) : undefined)} />
<HelpText>デフォルト: 8</HelpText>
<HelpText>{t('pushNotifications.queueConcurrencyHelp')}</HelpText>
</div>
<div>
<FieldLabel>Per-Send Timeout (ms)</FieldLabel>
<FieldLabel>{t('pushNotifications.perSendTimeoutLabel')}</FieldLabel>
<FieldInput type="number" value={push.perSendTimeoutMs ?? ''}
onChange={v => onChange('notifications.push.perSendTimeoutMs', v ? Number(v) : undefined)} />
<HelpText>1 デフォルト: 10000</HelpText>
<HelpText>{t('pushNotifications.perSendTimeoutHelp')}</HelpText>
</div>
</div>
);
+21 -46
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -14,6 +15,7 @@ import type { SectionFormProps } from './types';
* jobs are silently skipped.
*/
export function ReflectionForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const reflection = config.reflection ?? {};
// Step 7 (design 2026-05-21): read v2 `llm.workers`. The v2 API contract
// already strips the legacy `provider` block from GET /api/config, so
@@ -30,10 +32,7 @@ export function ReflectionForm({ config, onChange }: SectionFormProps) {
<h2 className="text-base font-semibold text-slate-800">Reflection (Hermes mode)</h2>
<p className="text-xs text-slate-600 leading-relaxed">
LLM memory
(<code className="font-mono text-2xs">data/users/{'{userId}'}/memory/</code>)
custom piece snapshot
Memory &amp; Learning revert
{t('reflection.intro')}
</p>
<div>
@@ -44,29 +43,16 @@ export function ReflectionForm({ config, onChange }: SectionFormProps) {
onChange={e => onChange('reflection.enabled', e.target.checked)}
className="rounded"
/>
<span className="font-semibold">Reflection </span>
<span className="font-semibold">{t('reflection.enableLabel')}</span>
</label>
<HelpText>
ON reflection memory
デフォルト: 無効
</HelpText>
<HelpText>{t('reflection.enableHelp')}</HelpText>
</div>
{enabled && !hasReflectionWorker && (
<div className="rounded-md border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300">
<div className="font-semibold mb-1"> Reflection worker </div>
<div>
Reflection <code className="font-mono">roles</code>
<code className="font-mono">reflection</code> worker
enqueue <strong>LLM Workers</strong> worker
:
</div>
<pre className="mt-2 text-2xs font-mono bg-canvas border border-amber-200 rounded p-2 overflow-auto">{`id: reflection-1
connection_type: direct
endpoint: http://localhost:11434/v1
model: qwen2.5:3b # cheap モデル推奨
roles: [reflection]
max_concurrency: 1`}</pre>
<div className="font-semibold mb-1">{t('reflection.workerWarn.title')}</div>
<div>{t('reflection.workerWarn.body')}</div>
<pre className="mt-2 text-2xs font-mono bg-canvas border border-amber-200 rounded p-2 overflow-auto">{t('reflection.workerWarn.snippet')}</pre>
</div>
)}
@@ -78,13 +64,9 @@ max_concurrency: 1`}</pre>
onChange={e => onChange('reflection.workerRequired', e.target.checked)}
className="rounded"
/>
reflection worker
{t('reflection.workerRequiredLabel')}
</label>
<HelpText>
ON: <code className="font-mono">roles: [reflection]</code> worker
reflection enqueue ()OFF enabled
worker
</HelpText>
<HelpText>{t('reflection.workerRequiredHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Caps</h3>
@@ -96,7 +78,7 @@ max_concurrency: 1`}</pre>
value={reflection.maxMemoryChangesPerJob ?? 3}
onChange={v => onChange('reflection.maxMemoryChangesPerJob', Number(v))}
/>
<HelpText>1 reflection memory entry デフォルト: 3</HelpText>
<HelpText>{t('reflection.maxMemHelp')}</HelpText>
</div>
<div>
@@ -106,7 +88,7 @@ max_concurrency: 1`}</pre>
value={reflection.maxEntryBodyBytes ?? 8192}
onChange={v => onChange('reflection.maxEntryBodyBytes', Number(v))}
/>
<HelpText>memory entry body semantic validator rejectデフォルト: 8192</HelpText>
<HelpText>{t('reflection.maxBodyHelp')}</HelpText>
</div>
<div>
@@ -116,7 +98,7 @@ max_concurrency: 1`}</pre>
value={reflection.pieceEditCooldownHours ?? 24}
onChange={v => onChange('reflection.pieceEditCooldownHours', Number(v))}
/>
<HelpText> piece cooldownデフォルト: 24 (24h 2 3 )</HelpText>
<HelpText>{t('reflection.cooldownHelp')}</HelpText>
</div>
<div>
@@ -126,7 +108,7 @@ max_concurrency: 1`}</pre>
value={reflection.activityLogMaxBytes ?? 4096}
onChange={v => onChange('reflection.activityLogMaxBytes', Number(v))}
/>
<HelpText>reflection LLM activity log デフォルト: 4096</HelpText>
<HelpText>{t('reflection.activityLogHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Budget</h3>
@@ -138,7 +120,7 @@ max_concurrency: 1`}</pre>
value={reflection.perUserDailyBudgetTokens ?? 200000}
onChange={v => onChange('reflection.perUserDailyBudgetTokens', Number(v))}
/>
<HelpText>1 1 reflection token reflection enqueue デフォルト: 200000</HelpText>
<HelpText>{t('reflection.dailyBudgetHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Snapshot &amp; Retention</h3>
@@ -150,7 +132,7 @@ max_concurrency: 1`}</pre>
value={reflection.snapshotRetentionDays ?? 90}
onChange={v => onChange('reflection.snapshotRetentionDays', Number(v))}
/>
<HelpText>reflection-history snapshot デフォルト: 90</HelpText>
<HelpText>{t('reflection.snapRetentionHelp')}</HelpText>
</div>
<div>
@@ -160,7 +142,7 @@ max_concurrency: 1`}</pre>
value={reflection.snapshotMaxBytesPerUser ?? 100 * 1024 * 1024}
onChange={v => onChange('reflection.snapshotMaxBytesPerUser', Number(v))}
/>
<HelpText>1 snapshot (bytes)デフォルト: 100 MiB</HelpText>
<HelpText>{t('reflection.snapMaxUserHelp')}</HelpText>
</div>
<div>
@@ -170,7 +152,7 @@ max_concurrency: 1`}</pre>
value={reflection.snapshotMaxBytesPerEntry ?? 1 * 1024 * 1024}
onChange={v => onChange('reflection.snapshotMaxBytesPerEntry', Number(v))}
/>
<HelpText>1 snapshot (bytes)デフォルト: 1 MiB</HelpText>
<HelpText>{t('reflection.snapMaxEntryHelp')}</HelpText>
</div>
<div>
@@ -181,12 +163,9 @@ max_concurrency: 1`}</pre>
onChange={e => onChange('reflection.storeLlmRaw', e.target.checked)}
className="rounded"
/>
LLM
{t('reflection.storeLlmRawLabel')}
</label>
<HelpText>
ON <code className="font-mono">llm-raw.json</code> snapshot
OFF ()
</HelpText>
<HelpText>{t('reflection.storeLlmRawHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Monitoring</h3>
@@ -198,11 +177,7 @@ max_concurrency: 1`}</pre>
value={reflection.abstainRateFloor ?? 0.3}
onChange={v => onChange('reflection.abstainRateFloor', v ? Number(v) : undefined)}
/>
<HelpText>
abstain () ()
デフォルト: 0.3 warn
max_memory_changes_per_job
</HelpText>
<HelpText>{t('reflection.abstainFloorHelp')}</HelpText>
</div>
</div>
);
+4 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
const SPECIAL_TARGETS = ['COMPLETE', 'ASK', 'ABORT', 'WAIT_SUBTASKS'];
@@ -10,6 +11,7 @@ export interface RulesTableProps {
}
export function RulesTable({ rules, movementNames, onChange, disabled = false }: RulesTableProps) {
const { t } = useTranslation('settings');
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
@@ -48,7 +50,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
onChange={(e) => updateRule(i, 'condition', e.target.value)}
disabled={disabled}
className={`w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
placeholder="条件..."
placeholder={t('rules.conditionPlaceholder')}
/>
</td>
<td className="pr-2 pb-1">
@@ -88,7 +90,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
+ Add Rule
</button>
)}
<HelpText>LLM transition </HelpText>
<HelpText>{t('rules.help')}</HelpText>
</div>
);
}
+22 -24
View File
@@ -1,44 +1,46 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
export function SafetyForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const safety = config.safety ?? {};
const historySummarization = safety.historySummarization ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Safety</h2>
<h2 className="text-base font-semibold text-slate-800">{t('safety.title')}</h2>
<div>
<FieldLabel>Max Iterations</FieldLabel>
<FieldInput type="number" value={safety.maxIterations ?? 200}
onChange={v => onChange('safety.maxIterations', Number(v))} />
<HelpText>1 movement デフォルト: 200</HelpText>
<HelpText>{t('safety.maxIterHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Revisits</FieldLabel>
<FieldInput type="number" value={safety.maxRevisits ?? 3}
onChange={v => onChange('safety.maxRevisits', Number(v))} />
<HelpText> movement デフォルト: 3</HelpText>
<HelpText>{t('safety.maxRevisitsHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Tool Loop Repeats</FieldLabel>
<FieldInput type="number" value={safety.maxToolLoopRepeats ?? 5}
onChange={v => onChange('safety.maxToolLoopRepeats', Number(v))} />
<HelpText> movement 2デフォルト: 51</HelpText>
<HelpText>{t('safety.maxToolLoopHelp')}</HelpText>
</div>
<div>
<FieldLabel>Prompt Guard Ratio</FieldLabel>
<FieldInput type="number" value={safety.promptGuardRatio ?? 0.8}
onChange={v => onChange('safety.promptGuardRatio', v ? Number(v) : undefined)} />
<HelpText> prompt 0.50.95デフォルト: 0.8</HelpText>
<HelpText>{t('safety.promptGuardHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Bash </h3>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('safety.bashSandboxTitle')}</h3>
<div>
<FieldLabel>Bash Sandbox Mode</FieldLabel>
@@ -47,11 +49,11 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
onChange={e => onChange('safety.bashSandbox', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
>
<option value="auto">autobwrap sandboxed hardened-whitelist</option>
<option value="always">alwayssandboxed bwrap fail</option>
<option value="off">off exec</option>
<option value="auto">{t('safety.bashSandboxAuto')}</option>
<option value="always">{t('safety.bashSandboxAlways')}</option>
<option value="off">{t('safety.bashSandboxOff')}</option>
</select>
<HelpText>Bash デフォルト: auto</HelpText>
<HelpText>{t('safety.bashSandboxHelp')}</HelpText>
</div>
<div>
@@ -62,9 +64,9 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
onChange={e => onChange('safety.bashUnrestricted', e.target.checked)}
className="rounded"
/>
Bash
{t('safety.bashUnrestricted')}
</label>
<HelpText>bwrap (rw)(ro) bind-mountbwrap user-namespace デフォルト: 無効</HelpText>
<HelpText>{t('safety.bashUnrestrictedHelp')}</HelpText>
</div>
<div>
@@ -75,19 +77,15 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
onChange={e => onChange('safety.bashAllowNetwork', e.target.checked)}
className="rounded"
/>
bash / python / npm
{t('safety.bashAllowNetwork')}
</label>
<HelpText>
<span className="text-red-700 dark:text-red-300 font-medium"> :</span>{' '}
bashpythonnpm <code>--unshare-net</code>
pip / npm install / curl 使
<strong>SSRF</strong>
デフォルト: 無効
Bash Sandbox Mode <code>off</code> bwrap sandboxed
<span className="text-red-700 dark:text-red-300 font-medium">{t('safety.bashAllowNetworkWarnLabel')}</span>
{t('safety.bashAllowNetworkHelp')}
</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">History Summarization</h3>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">{t('safety.historyTitle')}</h3>
<div>
<label className="flex items-center gap-2 text-sm text-slate-700">
@@ -97,23 +95,23 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
onChange={e => onChange('safety.historySummarization.enabled', e.target.checked)}
className="rounded"
/>
{t('safety.historyEnable')}
</label>
<HelpText> context デフォルト: 有効</HelpText>
<HelpText>{t('safety.historyEnableHelp')}</HelpText>
</div>
<div>
<FieldLabel>Tail Turns</FieldLabel>
<FieldInput type="number" value={historySummarization.tailTurns ?? 2}
onChange={v => onChange('safety.historySummarization.tailTurns', Number(v))} />
<HelpText> assistant+tool デフォルト: 2</HelpText>
<HelpText>{t('safety.tailTurnsHelp')}</HelpText>
</div>
<div>
<FieldLabel>Preserve Recent Budget</FieldLabel>
<FieldInput type="number" value={historySummarization.preserveRecentBudget ?? 8000}
onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} />
<HelpText>デフォルト: 8000</HelpText>
<HelpText>{t('safety.preserveRecentHelp')}</HelpText>
</div>
</div>
);
@@ -1,9 +1,11 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
import type { SectionFormProps } from './types';
export function SearchFilterForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const sf = config.searchFilter ?? {};
const autoBlock = sf.autoBlock ?? {};
@@ -16,23 +18,23 @@ export function SearchFilterForm({ config, onChange }: SectionFormProps) {
<h2 className="text-base font-semibold text-slate-800">Search Filter</h2>
<div>
<FieldLabel>Blocked Patterns ()</FieldLabel>
<FieldLabel>{t('searchFilter.blockedLabel')}</FieldLabel>
<StringArrayEditor
value={sf.blockedPatterns ?? []}
onChange={v => onChange('searchFilter.blockedPatterns', v)}
placeholder="regex pattern"
/>
<HelpText>WebSearch </HelpText>
<HelpText>{t('searchFilter.blockedHelp')}</HelpText>
</div>
<div>
<FieldLabel>Auto Block ()</FieldLabel>
<FieldLabel>{t('searchFilter.autoBlockLabel')}</FieldLabel>
<div className="space-y-2 mt-1">
{([
['privateIp', 'プライベートIP', autoBlock.privateIp],
['internalDomain', '内部ドメイン', autoBlock.internalDomain],
['email', 'メールアドレス', autoBlock.email],
['phone', '電話番号', autoBlock.phone],
['privateIp', t('searchFilter.autoBlockPrivateIp'), autoBlock.privateIp],
['internalDomain', t('searchFilter.autoBlockInternalDomain'), autoBlock.internalDomain],
['email', t('searchFilter.autoBlockEmail'), autoBlock.email],
['phone', t('searchFilter.autoBlockPhone'), autoBlock.phone],
] as const).map(([key, label, checked]) => (
<label key={key} className="flex items-center gap-2 text-sm text-slate-700">
<input
@@ -45,7 +47,7 @@ export function SearchFilterForm({ config, onChange }: SectionFormProps) {
</label>
))}
</div>
<HelpText></HelpText>
<HelpText>{t('searchFilter.autoBlockHelp')}</HelpText>
</div>
</div>
);
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
interface SettingsSidebarProps {
activeSection?: string;
onSelectSection: (section: string) => void;
@@ -24,7 +26,7 @@ const CONFIG_GROUPS = [
sections: [
{ id: 'preferences', label: 'Preferences' },
{ id: 'notifications', label: '🔔 Notifications' },
{ id: 'memory-learning', label: '🧠 Reflection 履歴' },
{ id: 'memory-learning', label: '🧠 Reflection history', labelKey: 'memoryLearning.navLabel' },
],
},
{
@@ -121,6 +123,7 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS
.flatMap(g => g.sections.map(s => s.id));
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
const { t } = useTranslation('settings');
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly);
return (
@@ -137,7 +140,7 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: Set
? 'bg-accent-soft text-accent font-semibold'
: 'text-slate-700 hover:bg-surface'
}`}>
{s.label}
{'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label}
</button>
))}
</div>
+6 -4
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import type { SshAuditRow } from '../../lib/ssh-types';
@@ -44,6 +45,7 @@ const ACTION_HINTS = [
];
export function SshAuditLog() {
const { t } = useTranslation('settings');
const [filters, setFilters] = useState<Filters>({
action: '',
ownerId: '',
@@ -63,7 +65,7 @@ export function SshAuditLog() {
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-slate-900"></h3>
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.audit.title')}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-2xs">
<label className="block">
@@ -123,9 +125,9 @@ export function SshAuditLog() {
disabled={isFetching}
className="px-2 h-6 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"
>
{isFetching ? '更新中…' : '再読み込み'}
{isFetching ? t('ssh.audit.refreshing') : t('ssh.audit.reload')}
</button>
<span className="text-2xs text-slate-500">{data?.length ?? 0} (limit {filters.limit})</span>
<span className="text-2xs text-slate-500">{t('ssh.audit.countDisplay', { count: data?.length ?? 0, limit: filters.limit })}</span>
</div>
{isLoading && <div className="text-xs text-slate-400">Loading</div>}
@@ -162,7 +164,7 @@ export function SshAuditLog() {
</tr>
))}
{(data ?? []).length === 0 && !isLoading && (
<tr><td colSpan={6} className="px-2 py-4 text-center text-slate-400"></td></tr>
<tr><td colSpan={6} className="px-2 py-4 text-center text-slate-400">{t('ssh.audit.empty')}</td></tr>
)}
</tbody>
</table>
+29 -74
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -11,6 +12,7 @@ import type { SectionFormProps } from './types';
* sibling subtabs of `SshForm`.
*/
export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenByEnv }: SectionFormProps) {
const { t } = useTranslation('settings');
const ssh = config.ssh ?? {};
const console_ = ssh.console ?? {};
@@ -24,13 +26,9 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
onChange={e => onChange('ssh.enabled', e.target.checked)}
className="rounded"
/>
SSH
{t('ssh.config.enableLabel')}
</label>
<HelpText>
OFF SshExec / SshUpload / SshDownload / SshConsole*
API router <code className="font-mono">MCP_ENCRYPTION_KEY</code>
( ON subsystem disabled )
</HelpText>
<HelpText>{t('ssh.config.enableHelp')}</HelpText>
</div>
<div>
@@ -41,13 +39,9 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
onChange={e => onChange('ssh.allowPrivateAddresses', e.target.checked)}
className="rounded"
/>
/ loopback
{t('ssh.config.allowPrivateLabel')}
</label>
<HelpText>
self-hosted / LAN OFF <code className="font-mono">10.x</code>,
<code className="font-mono">192.168.x</code>, <code className="font-mono">127.0.0.1</code>
private reject
</HelpText>
<HelpText>{t('ssh.config.allowPrivateHelp')}</HelpText>
</div>
<div>
@@ -58,12 +52,9 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
onChange={e => onChange('ssh.adminBypassesGrants', e.target.checked)}
className="rounded"
/>
Admin grant
{t('ssh.config.adminBypassLabel')}
</label>
<HelpText>
ON: admin role user per-connection grant
()OFF: admin grant
</HelpText>
<HelpText>{t('ssh.config.adminBypassHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
@@ -71,16 +62,13 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
</h3>
<div>
<FieldLabel>Call timeout ()</FieldLabel>
<FieldLabel>{t('ssh.config.callTimeout')}</FieldLabel>
<FieldInput
type="number"
value={ssh.callTimeoutSeconds ?? 30}
onChange={v => onChange('ssh.callTimeoutSeconds', Number(v))}
/>
<HelpText>
SshExec / SshUpload / SshDownload wall-clock (TCP connect + auth + )
30SshConsole* ( idle/duration cap )
</HelpText>
<HelpText>{t('ssh.config.callTimeoutHelp')}</HelpText>
</div>
<div>
@@ -90,10 +78,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
value={ssh.maxOutputBytes ?? 32768}
onChange={v => onChange('ssh.maxOutputBytes', Number(v))}
/>
<HelpText>
SshExec stdout/stderr truncate
<code className="font-mono">truncated_stdout: true</code> 32768 (32 KiB)
</HelpText>
<HelpText>{t('ssh.config.maxOutputHelp')}</HelpText>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -114,32 +99,26 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
/>
</div>
</div>
<HelpText>SshUpload / SshDownload reject</HelpText>
<HelpText>{t('ssh.config.transferSizeHelp')}</HelpText>
<div>
<FieldLabel>Audit retention ()</FieldLabel>
<FieldLabel>{t('ssh.config.auditRetention')}</FieldLabel>
<FieldInput
type="number"
value={ssh.auditRetentionDays ?? 90}
onChange={v => onChange('ssh.auditRetentionDays', Number(v))}
/>
<HelpText>
<code className="font-mono">ssh_audit_log</code> Audit tab
"Prune"
</HelpText>
<HelpText>{t('ssh.config.auditRetentionHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
Abuse detection
</h3>
<HelpText>
/ /
<code className="font-mono">abuse_locked</code> reject
</HelpText>
<HelpText>{t('ssh.config.abuseHelp')}</HelpText>
<div className="grid grid-cols-3 gap-3">
<div>
<FieldLabel>Window ()</FieldLabel>
<FieldLabel>{t('ssh.config.abuseWindow')}</FieldLabel>
<FieldInput
type="number"
value={ssh.abuseWindowMinutes ?? 10}
@@ -155,7 +134,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
/>
</div>
<div>
<FieldLabel>Lock duration ()</FieldLabel>
<FieldLabel>{t('ssh.config.abuseLock')}</FieldLabel>
<FieldInput
type="number"
value={ssh.abuseLockMinutes ?? 30}
@@ -165,7 +144,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
</div>
<h3 className="text-sm font-medium text-slate-600 mt-6 pt-3 border-t border-slate-200">
Interactive Console (SSH / SshConsole* tools)
{t('ssh.config.consoleSectionTitle')}
</h3>
<div>
@@ -176,39 +155,29 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
onChange={e => onChange('ssh.console.enabled', e.target.checked)}
className="rounded"
/>
Console
{t('ssh.config.consoleEnableLabel')}
</label>
<HelpText>
OFF SshConsole* tools <code className="font-mono">SSH</code>
"SSH 機能を有効化する" <code className="font-mono">MCP_ENCRYPTION_KEY</code>
</HelpText>
<HelpText>{t('ssh.config.consoleEnableHelp')}</HelpText>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FieldLabel>Idle timeout ()</FieldLabel>
<FieldLabel>{t('ssh.config.idleTimeout')}</FieldLabel>
<FieldInput
type="number"
value={console_.idleTimeoutSeconds ?? 1800}
onChange={v => onChange('ssh.console.idleTimeoutSeconds', Number(v))}
/>
<HelpText>
I/O auto-close AI
activity 1800 (30 )
</HelpText>
<HelpText>{t('ssh.config.idleTimeoutHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max session duration ()</FieldLabel>
<FieldLabel>{t('ssh.config.maxSessionDuration')}</FieldLabel>
<FieldInput
type="number"
value={console_.maxSessionDurationSeconds ?? 14400}
onChange={v => onChange('ssh.console.maxSessionDurationSeconds', Number(v))}
/>
<HelpText>
1 Idle close
14400 (4 )
</HelpText>
<HelpText>{t('ssh.config.maxDurationHelp')}</HelpText>
</div>
</div>
@@ -219,10 +188,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
value={console_.scrollbackBytes ?? 524288}
onChange={v => onChange('ssh.console.scrollbackBytes', Number(v))}
/>
<HelpText>
PTY
replay 524288 (512 KiB)
</HelpText>
<HelpText>{t('ssh.config.scrollbackHelp')}</HelpText>
</div>
<div>
@@ -232,10 +198,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
value={console_.maxSessionsPerConnection ?? 3}
onChange={v => onChange('ssh.console.maxSessionsPerConnection', Number(v))}
/>
<HelpText>
使
<code className="font-mono">session_cap_evict</code> close
</HelpText>
<HelpText>{t('ssh.config.maxSessionsHelp')}</HelpText>
</div>
<div>
@@ -245,10 +208,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
value={console_.maxInputBytesPerSend ?? 16384}
onChange={v => onChange('ssh.console.maxInputBytesPerSend', Number(v))}
/>
<HelpText>
1 <code className="font-mono">SshConsoleSend</code>
16384 (16 KiB)
</HelpText>
<HelpText>{t('ssh.config.maxInputHelp')}</HelpText>
</div>
<div>
@@ -258,10 +218,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
value={console_.autoInjectScreenLines ?? 24}
onChange={v => onChange('ssh.console.autoInjectScreenLines', Number(v))}
/>
<HelpText>
LLM iteration system prompt screen
AI context 24
</HelpText>
<HelpText>{t('ssh.config.autoInjectHelp')}</HelpText>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -282,9 +239,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
/>
</div>
</div>
<HelpText>
PTY resize
</HelpText>
<HelpText>{t('ssh.config.ptySizeHelp')}</HelpText>
</div>
);
}
+4 -4
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SshGlobalConnectionsForm } from './SshGlobalConnectionsForm';
import { SshGrantsForm } from './SshGrantsForm';
import { SshMasterKeyRotationForm } from './SshMasterKeyRotationForm';
@@ -33,16 +34,15 @@ interface Props extends SectionFormProps {
* very different shapes (form / lists / per-row CRUD / mode / table).
*/
export function SshForm({ config, onChange, overriddenByEnv, showToast }: Props) {
const { t } = useTranslation('settings');
const [tab, setTab] = useState<SubTab>('config');
return (
<div className="space-y-4">
<header>
<h2 className="text-base font-semibold text-slate-900 mb-1">SSH </h2>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('ssh.header.title')}</h2>
<p className="text-2xs text-slate-500 leading-relaxed">
SSH / / (grants) / /
SSH <code className="font-mono">User Folder</code> {' '}
<code className="font-mono">ssh-connections/</code>
{t('ssh.header.desc')}
</p>
</header>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { SshConnection, TestResponse } from '../../lib/ssh-types';
import { SshConnectionForm } from '../userfolder/SshConnectionForm';
@@ -62,6 +63,7 @@ interface Props {
}
export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
const { t } = useTranslation('settings');
const qc = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['ssh', 'admin', 'connections'],
@@ -91,7 +93,7 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
onSuccess: (resp) => {
invalidate();
setCreating(false);
showToast?.('グローバル接続を作成しました', 'success');
showToast?.(t('ssh.connections.toast.created'), 'success');
if (resp.publicKey) {
setPubKeyDialog({
publicKey: resp.publicKey,
@@ -112,11 +114,11 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
if (publicKey) {
setPubKeyDialog({ publicKey, label, freshlyGenerated: false });
} else {
showToast?.('公開鍵の取得に失敗しました', 'error');
showToast?.(t('ssh.connections.toast.pubKeyFailed'), 'error');
}
},
onError: (e) => {
showToast?.(e instanceof Error ? e.message : '公開鍵取得失敗', 'error');
showToast?.(e instanceof Error ? e.message : t('ssh.connections.toast.pubKeyFailedShort'), 'error');
},
});
const patchMutation = useMutation({
@@ -125,29 +127,29 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
onSuccess: () => {
invalidate();
setEditingId(null);
showToast?.('グローバル接続を更新しました', 'success');
showToast?.(t('ssh.connections.toast.updated'), 'success');
},
});
const disableMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
patchJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/disable`, { reason }),
onSuccess: () => { invalidate(); showToast?.('接続を無効化しました', 'success'); },
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.disabled'), 'success'); },
});
const enableMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
patchJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/enable`, { reason }),
onSuccess: () => { invalidate(); showToast?.('接続を有効化しました', 'success'); },
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.enabled'), 'success'); },
});
const deleteMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
deleteJson(`/api/ssh/admin/globals/${encodeURIComponent(id)}`, { reason }),
onSuccess: () => { invalidate(); showToast?.('接続を削除しました', 'success'); },
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.deleted'), 'success'); },
});
const forceUnlockMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
postJson(`/api/ssh/admin/connections/${encodeURIComponent(id)}/force-unlock`, { reason }),
onSuccess: () => { invalidate(); showToast?.('アビューズロックを解除しました', 'success'); },
onError: (e) => { showToast?.(e instanceof Error ? e.message : 'unlock 失敗', 'error'); },
onSuccess: () => { invalidate(); showToast?.(t('ssh.connections.toast.unlocked'), 'success'); },
onError: (e) => { showToast?.(e instanceof Error ? e.message : t('ssh.connections.toast.unlockFailed'), 'error'); },
});
const testMutation = useMutation({
mutationFn: async (id: string): Promise<{ id: string; resp: TestResponse }> => {
@@ -158,14 +160,14 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
onSuccess: ({ id, resp }) => {
invalidate();
if (resp.verdict === 'pass') {
showToast?.(`ホストキーは一致しています (${resp.fingerprint.slice(0, 20)}…)`, 'success');
showToast?.(t('ssh.connections.toast.hostKeyMatch', { fp: resp.fingerprint.slice(0, 20) }), 'success');
} else if (resp.verdict === 'first_observe' || resp.verdict === 'mismatch') {
setTestResult({ id, test: resp, replaceMode: resp.verdict === 'mismatch' });
} else if (resp.verdict === 'alg_not_allowed') {
showToast?.('ホストキーのアルゴリズムが許可リストにありません', 'error');
showToast?.(t('ssh.connections.toast.algNotAllowed'), 'error');
}
},
onError: (e) => { showToast?.(e instanceof Error ? e.message : 'テスト失敗', 'error'); },
onError: (e) => { showToast?.(e instanceof Error ? e.message : t('ssh.connections.toast.testFailed'), 'error'); },
});
async function handleVerify(connId: string, args: { fingerprint: string; token: string; reason?: string }) {
@@ -178,15 +180,13 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
});
if (!res.ok) throw new Error(await parseError(res));
invalidate();
showToast?.('ホストキーを検証しました', 'success');
showToast?.(t('ssh.connections.toast.hostKeyVerified'), 'success');
}
if (data?.sshDisabled) {
return (
<div className="text-xs text-slate-600 bg-surface border border-hairline rounded-md p-3 leading-relaxed">
SSH <code className="font-mono">config.yaml</code> {' '}
<code className="font-mono">ssh.enabled: true</code> <code className="font-mono">MCP_ENCRYPTION_KEY</code>{' '}
{t('ssh.connections.disabledSubsystem')}
</div>
);
}
@@ -196,14 +196,14 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-900"> ({globals.length})</h3>
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.connections.listTitle', { count: globals.length })}</h3>
<button
type="button"
onClick={() => { setCreating(true); setEditingId(null); }}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
disabled={creating}
>
+
{t('ssh.connections.addButton')}
</button>
</div>
@@ -212,7 +212,7 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
{creating && (
<section className="border border-accent/40 rounded-md bg-canvas p-4">
<h4 className="text-xs font-semibold text-slate-700 mb-2"></h4>
<h4 className="text-xs font-semibold text-slate-700 mb-2">{t('ssh.connections.newConnTitle')}</h4>
<SshConnectionForm
existing={null}
adminContext
@@ -223,7 +223,7 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
)}
{globals.length === 0 && !creating && !isLoading && (
<div className="text-xs text-slate-400 px-3 py-4"></div>
<div className="text-xs text-slate-400 px-3 py-4">{t('ssh.connections.empty')}</div>
)}
<ul className="divide-y divide-hairline">
@@ -258,33 +258,33 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
)}
</div>
{c.disabledByAdminReason && (
<div className="text-2xs text-red-700 dark:text-red-300 mt-0.5">: {c.disabledByAdminReason}</div>
<div className="text-2xs text-red-700 dark:text-red-300 mt-0.5">{t('ssh.common.reasonLabel', { reason: c.disabledByAdminReason })}</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end max-w-[280px]">
<button onClick={() => testMutation.mutate(c.id)} className={btnCls} disabled={testMutation.isPending && testMutation.variables === c.id}>
{testMutation.isPending && testMutation.variables === c.id ? 'テスト中…' : 'Test'}
{testMutation.isPending && testMutation.variables === c.id ? t('ssh.connections.testing') : t('ssh.connections.test')}
</button>
<button
onClick={() => showPubKeyMutation.mutate({ id: c.id, label: c.label })}
disabled={showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id}
title="authorized_keys に貼る公開鍵を表示"
title={t('ssh.connections.pubKeyTitle')}
className={btnCls}
>
{showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id ? '取得中…' : '公開鍵'}
{showPubKeyMutation.isPending && showPubKeyMutation.variables?.id === c.id ? t('ssh.connections.fetching') : t('ssh.connections.pubKey')}
</button>
<button onClick={() => { setEditingId(c.id); setCreating(false); }} className={btnCls}>
{t('ssh.connections.edit')}
</button>
<button onClick={() => setReasonForOp({ kind: 'forceUnlock', conn: c })} className={btnCls}>
force-unlock
</button>
{c.disabledByAdmin ? (
<button onClick={() => setReasonForOp({ kind: 'enable', conn: c })} className={btnCls}></button>
<button onClick={() => setReasonForOp({ kind: 'enable', conn: c })} className={btnCls}>{t('ssh.connections.enable')}</button>
) : (
<button onClick={() => setReasonForOp({ kind: 'disable', conn: c })} className={btnCls}></button>
<button onClick={() => setReasonForOp({ kind: 'disable', conn: c })} className={btnCls}>{t('ssh.connections.disable')}</button>
)}
<button onClick={() => setReasonForOp({ kind: 'delete', conn: c })} className={btnDangerCls}></button>
<button onClick={() => setReasonForOp({ kind: 'delete', conn: c })} className={btnDangerCls}>{t('ssh.connections.delete')}</button>
</div>
</div>
{editingId === c.id && (
@@ -304,10 +304,10 @@ export function SshGlobalConnectionsForm({ showToast, onChange }: Props) {
{reasonForOp && (
<ReasonModal
title={
reasonForOp.kind === 'delete' ? `削除: ${reasonForOp.conn.label}` :
reasonForOp.kind === 'disable' ? `無効化: ${reasonForOp.conn.label}` :
reasonForOp.kind === 'enable' ? `有効化: ${reasonForOp.conn.label}` :
`force-unlock: ${reasonForOp.conn.label}`
reasonForOp.kind === 'delete' ? t('ssh.connections.opTitle.delete', { label: reasonForOp.conn.label }) :
reasonForOp.kind === 'disable' ? t('ssh.connections.opTitle.disable', { label: reasonForOp.conn.label }) :
reasonForOp.kind === 'enable' ? t('ssh.connections.opTitle.enable', { label: reasonForOp.conn.label }) :
t('ssh.connections.opTitle.forceUnlock', { label: reasonForOp.conn.label })
}
warning={reasonForOp.kind === 'delete'}
onCancel={() => setReasonForOp(null)}
@@ -351,6 +351,7 @@ interface ReasonModalProps {
}
function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
const { t } = useTranslation('settings');
const [reason, setReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -383,19 +384,19 @@ function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
value={reason}
onChange={e => setReason(e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-hairline rounded"
placeholder="監査ログに残す理由を記述"
placeholder={t('ssh.common.reasonModalPlaceholder')}
autoFocus
/>
{error && <div className="text-xs text-red-600">{error}</div>}
</div>
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
<button onClick={onCancel} disabled={submitting} className={btnCls}></button>
<button onClick={onCancel} disabled={submitting} className={btnCls}>{t('ssh.common.cancel')}</button>
<button
onClick={handleSubmit}
disabled={submitting || reason.trim().length < 8}
className={`px-3 h-7 text-xs font-semibold rounded-md disabled:opacity-50 ${warning ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-accent text-accent-fg hover:bg-accent-deep'}`}
>
{submitting ? '送信中…' : '実行'}
{submitting ? t('ssh.common.submitting') : t('ssh.common.run')}
</button>
</div>
</div>
@@ -411,6 +412,7 @@ const btnDangerCls = 'px-2 h-7 text-2xs text-red-600 border border-hairline roun
* that ask "give me the connection_id" can be answered by clicking once.
*/
function CopyableUuid({ value }: { value: string }) {
const { t } = useTranslation('settings');
const [copied, setCopied] = useState(false);
async function copy() {
try {
@@ -425,10 +427,10 @@ function CopyableUuid({ value }: { value: string }) {
<button
type="button"
onClick={copy}
title={`クリックで UUID をコピー: ${value}`}
title={t('ssh.connections.copyTooltip', { value })}
className="font-mono hover:underline cursor-pointer text-slate-600 hover:text-accent-deep"
>
{copied ? '✓ コピーしました' : value}
{copied ? t('ssh.connections.copied') : value}
</button>
);
}
+33 -33
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { SshConnection, SshGrant, SshGrantSubjectType } from '../../lib/ssh-types';
@@ -68,6 +69,7 @@ interface Props {
* UI groups grants by global connection so it's easy to see who can use what.
*/
export function SshGrantsForm({ showToast }: Props) {
const { t } = useTranslation('settings');
const qc = useQueryClient();
const connQuery = useQuery({ queryKey: ['ssh', 'admin', 'connections'], queryFn: fetchAdminConnections, staleTime: 15_000 });
const grantsQuery = useQuery({ queryKey: ['ssh', 'admin', 'grants'], queryFn: fetchAdminGrants, staleTime: 15_000 });
@@ -80,7 +82,7 @@ export function SshGrantsForm({ showToast }: Props) {
mutationFn: (body: Record<string, unknown>) => postJson('/api/ssh/admin/grants', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'grants'] });
showToast?.('Grant を作成しました', 'success');
showToast?.(t('ssh.grants.toast.created'), 'success');
setShowCreate(false);
},
});
@@ -89,7 +91,7 @@ export function SshGrantsForm({ showToast }: Props) {
deleteJson(`/api/ssh/admin/grants/${encodeURIComponent(id)}`, { reason }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'grants'] });
showToast?.('Grant を削除しました', 'success');
showToast?.(t('ssh.grants.toast.deleted'), 'success');
},
});
@@ -104,14 +106,10 @@ export function SshGrantsForm({ showToast }: Props) {
if (sshDisabled) {
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-slate-900"> (grants)</h3>
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.grants.title')}</h3>
<div className="border border-amber-200 dark:border-amber-500/30 rounded-md bg-amber-50 dark:bg-amber-500/15 p-4 text-xs text-amber-900 dark:text-amber-300">
<div className="font-semibold mb-1">SSH </div>
<div>
<code className="font-mono">config.yaml</code> <code className="font-mono">ssh.enabled: true</code>
<code className="font-mono">MCP_ENCRYPTION_KEY</code> (64 hex chars) export
<code className="font-mono">docs/ssh.md</code>
</div>
<div className="font-semibold mb-1">{t('ssh.grants.disabledTitle')}</div>
<div>{t('ssh.grants.disabledBody')}</div>
</div>
</div>
);
@@ -120,19 +118,19 @@ export function SshGrantsForm({ showToast }: Props) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-900"> (grants)</h3>
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.grants.title')}</h3>
<button
type="button"
onClick={() => setShowCreate(true)}
disabled={globalConns.length === 0}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
>
+ Grant
{t('ssh.grants.issueButton')}
</button>
</div>
{globalConns.length === 0 && (
<div className="text-xs text-slate-400 px-3 py-2">
grant
{t('ssh.grants.needConnFirst')}
</div>
)}
@@ -157,10 +155,10 @@ export function SshGrantsForm({ showToast }: Props) {
{c.username}@{c.host}:{c.port}
</div>
</div>
<span className="text-2xs text-slate-500 font-mono">{grants.length} grants</span>
<span className="text-2xs text-slate-500 font-mono">{t('ssh.grants.grantsCount', { count: grants.length })}</span>
</header>
{grants.length === 0 ? (
<div className="px-3 py-3 text-2xs text-slate-400">grant </div>
<div className="px-3 py-3 text-2xs text-slate-400">{t('ssh.grants.noGrants')}</div>
) : (
<ul className="divide-y divide-hairline">
{grants.map(g => (
@@ -177,15 +175,15 @@ export function SshGrantsForm({ showToast }: Props) {
)}
</div>
<div className="text-2xs text-slate-500 mt-0.5">
: {g.reason}
{g.expiresAt && <> · : <span className="font-mono">{g.expiresAt}</span></>}
{t('ssh.common.reasonLabel', { reason: g.reason })}
{g.expiresAt && <> · {t('ssh.grants.expiresInline', { at: g.expiresAt })}</>}
</div>
</div>
<button
onClick={() => setReasonForDelete(g)}
className="px-2 h-6 text-2xs text-red-600 border border-hairline rounded hover:bg-red-50 dark:hover:bg-red-500/15"
>
{t('ssh.grants.revoke')}
</button>
</li>
))}
@@ -198,7 +196,7 @@ export function SshGrantsForm({ showToast }: Props) {
{reasonForDelete && (
<ReasonModal
title={`Grant を取り消す`}
title={t('ssh.grants.revokeTitle')}
warning
onCancel={() => setReasonForDelete(null)}
onSubmit={async (reason) => {
@@ -220,6 +218,7 @@ interface CreateGrantFormProps {
}
function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGrantFormProps) {
const { t } = useTranslation('settings');
const [connectionId, setConnectionId] = useState(connections[0]?.id ?? '');
const [subjectType, setSubjectType] = useState<SshGrantSubjectType>('user');
const [subjectId, setSubjectId] = useState('');
@@ -263,16 +262,16 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
return (
<form onSubmit={handleSubmit} className="border border-accent/40 rounded-md bg-canvas p-4 space-y-3">
<h4 className="text-xs font-semibold text-slate-700">Grant </h4>
<h4 className="text-xs font-semibold text-slate-700">{t('ssh.grants.createTitle')}</h4>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Global connection</div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.globalConnection')}</div>
<select value={connectionId} onChange={e => setConnectionId(e.target.value)} className={inputCls}>
{connections.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</label>
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Subject</div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.subject')}</div>
<div className="flex gap-1">
<select
value={subjectType}
@@ -286,7 +285,7 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
type="text"
value={subjectId}
onChange={e => setSubjectId(e.target.value)}
placeholder={subjectType === 'user' ? 'gitea ユーザー ID' : 'org ID'}
placeholder={subjectType === 'user' ? t('ssh.grants.subjectUserPlaceholder') : t('ssh.grants.subjectOrgPlaceholder')}
className="flex-1 min-w-0 text-xs px-2 py-1.5 border border-hairline rounded font-mono"
required
/>
@@ -302,20 +301,20 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
className="mt-0.5"
/>
<span>
<span className="font-semibold"> (applies_to_all_pieces)</span>
<span className="font-semibold">{t('ssh.grants.appliesAll')}</span>
<span className="block text-2xs text-amber-700 dark:text-amber-300">
grant piece 使
{t('ssh.grants.appliesAllWarn')}
</span>
</span>
</label>
{!appliesToAll && (
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Piece name</div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.pieceName')}</div>
<input
type="text"
value={pieceName}
onChange={e => setPieceName(e.target.value)}
placeholder="piece 名 (例: db-maintenance)"
placeholder={t('ssh.grants.pieceNamePlaceholder')}
className={inputCls + ' font-mono'}
list="ssh-grant-piece-list"
required
@@ -328,7 +327,7 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
</div>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Expires at (, ISO8601)</div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">{t('ssh.grants.expiresLabel')}</div>
<input
type="text"
value={expiresAt}
@@ -343,7 +342,7 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
type="text"
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="運用上の理由"
placeholder={t('ssh.grants.reasonOpPlaceholder')}
className={inputCls}
required
/>
@@ -352,10 +351,10 @@ function CreateGrantForm({ connections, pieces, onSubmit, onCancel }: CreateGran
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-hairline">
<button type="button" onClick={onCancel} disabled={submitting} className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-canvas rounded-md hover:bg-surface disabled:opacity-50">
{t('ssh.common.cancel')}
</button>
<button type="submit" disabled={!valid || submitting} className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50">
{submitting ? '発行中…' : '発行'}
{submitting ? t('ssh.grants.issuing') : t('ssh.grants.issue')}
</button>
</div>
</form>
@@ -370,6 +369,7 @@ interface ReasonModalProps {
}
function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
const { t } = useTranslation('settings');
const [reason, setReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -396,19 +396,19 @@ function ReasonModal({ title, warning, onCancel, onSubmit }: ReasonModalProps) {
value={reason}
onChange={e => setReason(e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-hairline rounded"
placeholder="監査ログに残す理由を記述"
placeholder={t('ssh.common.reasonModalPlaceholder')}
autoFocus
/>
{error && <div className="text-xs text-red-600">{error}</div>}
</div>
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
<button onClick={onCancel} disabled={submitting} className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50"></button>
<button onClick={onCancel} disabled={submitting} className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50">{t('ssh.common.cancel')}</button>
<button
onClick={handleSubmit}
disabled={submitting || reason.trim().length < 8}
className={`px-3 h-7 text-xs font-semibold rounded-md disabled:opacity-50 ${warning ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-accent text-accent-fg hover:bg-accent-deep'}`}
>
{submitting ? '送信中…' : '実行'}
{submitting ? t('ssh.common.submitting') : t('ssh.common.run')}
</button>
</div>
</div>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface RotationStub {
@@ -53,6 +54,7 @@ interface Props {
}
export function SshMasterKeyRotationForm({ showToast }: Props) {
const { t } = useTranslation('settings');
const qc = useQueryClient();
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [showStartDialog, setShowStartDialog] = useState(false);
@@ -70,11 +72,11 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
onSuccess: (resp) => {
setActiveJobId(resp.jobId);
qc.invalidateQueries({ queryKey: ['ssh', 'admin', 'rotation'] });
showToast?.(`Rotation job 開始: ${resp.jobId}`, 'success');
showToast?.(t('ssh.rotation.toast.started', { jobId: resp.jobId }), 'success');
setShowStartDialog(false);
},
onError: (e) => {
showToast?.(e instanceof Error ? e.message : 'Rotation 開始失敗', 'error');
showToast?.(e instanceof Error ? e.message : t('ssh.rotation.toast.startFailed'), 'error');
},
});
@@ -83,23 +85,21 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
return (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-slate-900">Master Key Rotation</h3>
<h3 className="text-sm font-semibold text-slate-900">{t('ssh.rotation.title')}</h3>
<p className="text-2xs text-slate-500 mt-1 leading-relaxed">
<code className="font-mono">MCP_ENCRYPTION_KEY</code> SSH
API 503 UI ()
{' '}<strong>v1 DEK </strong>
{t('ssh.rotation.desc')}
</p>
</div>
<div className="rounded-md border border-hairline bg-canvas p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide"></div>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">{t('ssh.rotation.currentState')}</div>
<div className="text-xs text-slate-800 mt-0.5">
{activeJobId === null && <span>idle (rotation )</span>}
{activeJobId !== null && statusQuery.isLoading && <span className="text-slate-400"></span>}
{activeJobId === null && <span>{t('ssh.rotation.idle')}</span>}
{activeJobId !== null && statusQuery.isLoading && <span className="text-slate-400">{t('ssh.rotation.checking')}</span>}
{activeJobId !== null && status === null && (
<span className="text-emerald-700 dark:text-emerald-300">job {activeJobId} </span>
<span className="text-emerald-700 dark:text-emerald-300">{t('ssh.rotation.jobDoneOrCleared', { jobId: activeJobId })}</span>
)}
{status && (
<>
@@ -113,7 +113,7 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
)}
</div>
{status?.startedAt && (
<div className="text-2xs text-slate-500 mt-0.5">: {status.startedAt}</div>
<div className="text-2xs text-slate-500 mt-0.5">{t('ssh.rotation.startedAt', { ts: status.startedAt })}</div>
)}
{status?.progress?.note && (
<div className="text-2xs text-slate-500 mt-0.5">{status.progress.note}</div>
@@ -125,7 +125,7 @@ export function SshMasterKeyRotationForm({ showToast }: Props) {
disabled={status !== null && status !== undefined}
className="px-3 h-7 text-xs font-semibold bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50 flex-shrink-0"
>
Rotation
{t('ssh.rotation.startButton')}
</button>
</div>
</div>
@@ -150,6 +150,7 @@ function ConfirmDialog({
onCancel: () => void;
onSubmit: (reason: string) => Promise<void>;
}) {
const { t } = useTranslation('settings');
const [reason, setReason] = useState('');
const [typed, setTyped] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -170,14 +171,13 @@ function ConfirmDialog({
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-lg bg-surface rounded-md shadow-lg border border-amber-300 overflow-hidden">
<div className="px-4 py-3 border-b border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15">
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-300"> Master Key Rotation </h3>
<h3 className="text-sm font-semibold text-amber-900 dark:text-amber-300">{t('ssh.rotation.confirmTitle')}</h3>
</div>
<div className="px-4 py-3 space-y-3">
<p className="text-xs text-slate-700 leading-relaxed">
<strong></strong>
SSH 503
{t('ssh.rotation.confirmBody1')}
<br />
v1 DEK
{t('ssh.rotation.confirmBody2')}
</p>
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Reason ( 8 chars)</div>
@@ -186,13 +186,13 @@ function ConfirmDialog({
value={reason}
onChange={e => setReason(e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-hairline rounded"
placeholder="MCP_ENCRYPTION_KEY を新しい値に置き換えるため"
placeholder={t('ssh.rotation.reasonPlaceholder')}
autoFocus
/>
</label>
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
<code className="font-mono">ROTATE</code>
{t('ssh.rotation.typeToConfirm')}
</div>
<input
type="text"
@@ -206,14 +206,14 @@ function ConfirmDialog({
</div>
<div className="px-4 py-3 border-t border-hairline flex items-center justify-end gap-2 bg-surface/50">
<button onClick={onCancel} disabled={submitting} className="px-2 h-7 text-2xs text-slate-700 border border-hairline rounded hover:bg-surface disabled:opacity-50">
{t('ssh.common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!reasonValid || !typedOk || submitting}
className="px-3 h-7 text-xs font-semibold bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
>
{submitting ? '開始中…' : 'Rotation を開始'}
{submitting ? t('ssh.rotation.starting') : t('ssh.rotation.startButton')}
</button>
</div>
</div>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface StringArrayEditorProps {
value: string[];
@@ -7,6 +8,7 @@ interface StringArrayEditorProps {
}
export function StringArrayEditor({ value, onChange, placeholder }: StringArrayEditorProps) {
const { t } = useTranslation('settings');
const [input, setInput] = useState('');
const handleAdd = () => {
@@ -34,7 +36,7 @@ export function StringArrayEditor({ value, onChange, placeholder }: StringArrayE
onClick={handleAdd}
className="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep"
>
{t('stringArray.add')}
</button>
</div>
{value.length > 0 && (
+6 -6
View File
@@ -1,4 +1,5 @@
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';
@@ -26,6 +27,7 @@ export interface ToolTagInputProps {
* 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);
@@ -143,7 +145,7 @@ export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInput
}}
onFocus={() => setShowDropdown(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
placeholder={value.length === 0 ? t('tools.tagInput.placeholder') : ''}
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
/>
)}
@@ -181,10 +183,7 @@ export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInput
</div>
)}
</div>
<HelpText>
LLM
MCP
</HelpText>
<HelpText>{t('tools.tagInput.help')}</HelpText>
</div>
);
}
@@ -213,6 +212,7 @@ function SelectedToolChip({
onRemove: () => void;
readOnly?: boolean;
}) {
const { t } = useTranslation('settings');
const isUnknown = !entry;
const isUnavailable = entry ? !entry.available : false;
// Visual stack:
@@ -224,7 +224,7 @@ function SelectedToolChip({
? '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;
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
@@ -21,6 +22,7 @@ import type { SectionFormProps } from './types';
* here — see PathsStorageForm.
*/
export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const tools = config.tools ?? {};
return (
@@ -34,21 +36,21 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
<div>
<FieldLabel>X Auth Token</FieldLabel>
<FieldInput type="password" value={tools.xAuthToken ?? ''} onChange={v => onChange('tools.xAuthToken', v)} />
<HelpText>X / Twitter auth_token cookie</HelpText>
<HelpText>{t('tools.x.authTokenHelp')}</HelpText>
</div>
<div>
<FieldLabel>X ct0</FieldLabel>
<FieldInput type="password" value={tools.xCt0 ?? ''} onChange={v => onChange('tools.xCt0', v)} />
<HelpText>X / Twitter ct0 cookie</HelpText>
<HelpText>{t('tools.x.ct0Help')}</HelpText>
</div>
<div>
<FieldLabel>X CLI Command</FieldLabel>
<FieldInput value={Array.isArray(tools.xCliCommand) ? tools.xCliCommand.join(' ') : (tools.xCliCommand ?? '')}
onChange={v => onChange('tools.xCliCommand', v)} />
<HelpText>twitter-cli </HelpText>
<HelpText>{t('tools.x.cliHelp')}</HelpText>
</div>
<div>
<FieldLabel>X Timeout ()</FieldLabel>
<FieldLabel>{t('tools.x.timeout')}</FieldLabel>
<FieldInput type="number" value={tools.xTimeout ?? 90}
onChange={v => onChange('tools.xTimeout', Number(v))} />
</div>
@@ -61,40 +63,40 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
<FieldLabel>X Chrome Profile</FieldLabel>
<FieldInput value={tools.xChromeProfile ?? ''} onChange={v => onChange('tools.xChromeProfile', v)}
placeholder="/path/to/chrome/profile" />
<HelpText>Cookie Chrome </HelpText>
<HelpText>{t('tools.x.chromeProfileHelp')}</HelpText>
</div>
<div>
<FieldLabel>X Media Download</FieldLabel>
<select value={tools.xDownloadMedia ?? 'auto'}
onChange={e => onChange('tools.xDownloadMedia', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
<option value="auto">auto/</option>
<option value="never">never</option>
<option value="auto">{t('tools.x.mediaAuto')}</option>
<option value="never">{t('tools.x.mediaNever')}</option>
</select>
<HelpText>X 稿デフォルト: auto</HelpText>
<HelpText>{t('tools.x.mediaHelp')}</HelpText>
</div>
<div>
<FieldLabel>X Video Download</FieldLabel>
<select value={tools.xDownloadVideo ?? 'thumbnail'}
onChange={e => onChange('tools.xDownloadVideo', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow">
<option value="thumbnail">thumbnail</option>
<option value="full">full</option>
<option value="never">never</option>
<option value="thumbnail">{t('tools.x.videoThumbnail')}</option>
<option value="full">{t('tools.x.videoFull')}</option>
<option value="never">{t('tools.x.videoNever')}</option>
</select>
<HelpText>X 稿デフォルト: thumbnail</HelpText>
<HelpText>{t('tools.x.videoHelp')}</HelpText>
</div>
<div>
<FieldLabel>X Media Max (MB)</FieldLabel>
<FieldInput type="number" value={tools.xMediaMaxMb ?? ''}
onChange={v => onChange('tools.xMediaMaxMb', v ? Number(v) : undefined)} />
<HelpText>1 MB</HelpText>
<HelpText>{t('tools.x.mediaMaxHelp')}</HelpText>
</div>
<div>
<FieldLabel>X Media Fetch Timeout ()</FieldLabel>
<FieldLabel>{t('tools.x.mediaFetchTimeout')}</FieldLabel>
<FieldInput type="number" value={tools.xMediaFetchTimeoutSeconds ?? ''}
onChange={v => onChange('tools.xMediaFetchTimeoutSeconds', v ? Number(v) : undefined)} />
<HelpText></HelpText>
<HelpText>{t('tools.x.mediaFetchTimeoutHelp')}</HelpText>
</div>
</section>
@@ -105,13 +107,13 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
<div>
<FieldLabel>Google Maps API Key</FieldLabel>
<FieldInput type="password" value={tools.googleMapsApiKey ?? ''} onChange={v => onChange('tools.googleMapsApiKey', v)} />
<HelpText>Google Maps Places / Directions API Nominatim / OSRM使</HelpText>
<HelpText>{t('tools.maps.keyHelp')}</HelpText>
</div>
<div>
<FieldLabel>Maps Timeout ()</FieldLabel>
<FieldLabel>{t('tools.maps.timeout')}</FieldLabel>
<FieldInput type="number" value={tools.mapsTimeout ?? ''}
onChange={v => onChange('tools.mapsTimeout', v ? Number(v) : undefined)} />
<HelpText>Maps / Nominatim / OSRM デフォルト: 30</HelpText>
<HelpText>{t('tools.maps.timeoutHelp')}</HelpText>
</div>
</section>
@@ -123,12 +125,12 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
<FieldLabel>Amazon Affiliate Tag</FieldLabel>
<FieldInput value={tools.amazonAffiliateTag ?? ''} onChange={v => onChange('tools.amazonAffiliateTag', v)}
placeholder="your-tag-22" />
<HelpText>SearchAmazon 使</HelpText>
<HelpText>{t('tools.amazon.affiliateHelp')}</HelpText>
</div>
<div>
<FieldLabel>Keepa API Key</FieldLabel>
<FieldInput type="password" value={tools.keepaApiKey ?? ''} onChange={v => onChange('tools.keepaApiKey', v)} />
<HelpText>Keepa API </HelpText>
<HelpText>{t('tools.amazon.keepaHelp')}</HelpText>
</div>
</section>
@@ -137,31 +139,25 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
User-supplied Scripts
</h3>
<div>
<FieldLabel>RunUserScript </FieldLabel>
<FieldLabel>{t('tools.userScripts.enableLabel')}</FieldLabel>
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700">
<input
type="checkbox"
checked={tools.userScriptsEnabled === true}
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
/>
<span> (browser-macros: LLM RunUserScript + scheduled script task )</span>
<span>{t('tools.userScripts.enabledToggle')}</span>
</label>
<HelpText>
plain runtime Node <code>--permission</code> sandbox child_process / worker / tmpdir FS deny
browser-macros Playwright (child_process / native bindings / network) sandbox Node.js capability
</HelpText>
<HelpText>{t('tools.userScripts.enableHelp')}</HelpText>
</div>
<div>
<FieldLabel> allowlist ( = )</FieldLabel>
<FieldLabel>{t('tools.userScripts.allowlistLabel')}</FieldLabel>
<StringArrayEditor
value={tools.userScriptsAllowUserids ?? []}
onChange={v => onChange('tools.userScriptsAllowUserids', v)}
placeholder="user id (例: 12345)"
placeholder={t('tools.userScripts.allowlistPlaceholder')}
/>
<HelpText>
<code>user_scripts_enabled</code> ID browser-macro (RunUserScript / scheduled script task)
</HelpText>
<HelpText>{t('tools.userScripts.allowlistHelp')}</HelpText>
</div>
</section>
</div>
+52 -66
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
@@ -31,6 +32,7 @@ interface ToolsFormProps extends SectionFormProps {
}
export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
const { t } = useTranslation('settings');
const tools = config.tools ?? {};
const tabsToShow = visibleTabs && visibleTabs.length > 0
? TOOL_TABS.filter(t => visibleTabs.includes(t.id))
@@ -49,7 +51,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Tools</h2>
<nav className="flex flex-wrap gap-1 border-b border-hairline -mt-2" aria-label="ツールカテゴリ">
<nav className="flex flex-wrap gap-1 border-b border-hairline -mt-2" aria-label={t('tools.categoriesAria')}>
{tabsToShow.map(t => (
<button
key={t.id}
@@ -72,15 +74,15 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<div>
<FieldLabel>SearXNG URL</FieldLabel>
<FieldInput value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
<HelpText>WebSearch SearXNG </HelpText>
<HelpText>{t('tools.web.searxngHelp')}</HelpText>
</div>
<div>
<FieldLabel>WebFetch Timeout ()</FieldLabel>
<FieldLabel>{t('tools.web.webfetchTimeout')}</FieldLabel>
<FieldInput type="number" value={tools.webfetchTimeout ?? 30}
onChange={v => onChange('tools.webfetchTimeout', Number(v))} />
</div>
<div>
<FieldLabel>WebSearch Timeout ()</FieldLabel>
<FieldLabel>{t('tools.web.websearchTimeout')}</FieldLabel>
<FieldInput type="number" value={tools.websearchTimeout ?? 15}
onChange={v => onChange('tools.websearchTimeout', Number(v))} />
</div>
@@ -90,7 +92,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
value={tools.webfetchAllowedHosts ?? []}
onChange={v => onChange('tools.webfetchAllowedHosts', v)}
placeholder="hostname or IP address" />
<HelpText>SSRF IP WebFetchBrowseWeb </HelpText>
<HelpText>{t('tools.web.ssrfHelp')}</HelpText>
</div>
</div>
)}
@@ -100,16 +102,16 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<div>
<FieldLabel>Vision Model</FieldLabel>
<FieldInput value={tools.visionModel ?? ''} onChange={v => onChange('tools.visionModel', v)} />
<HelpText>使: qwen2-vl:8b-instruct</HelpText>
<HelpText>{t('tools.vision.modelHelp')}</HelpText>
</div>
<div>
<FieldLabel>Vision Base URL</FieldLabel>
<FieldInput value={tools.visionBaseUrl ?? ''} onChange={v => onChange('tools.visionBaseUrl', v)}
placeholder="Provider の Base URL と同じ場合は空欄" />
<HelpText>Vision API </HelpText>
placeholder={t('tools.vision.baseUrlPlaceholder')} />
<HelpText>{t('tools.vision.baseUrlHelp')}</HelpText>
</div>
<div>
<FieldLabel>Vision Timeout ()</FieldLabel>
<FieldLabel>{t('tools.vision.timeout')}</FieldLabel>
<FieldInput type="number" value={tools.visionTimeout ?? 60}
onChange={v => onChange('tools.visionTimeout', Number(v))} />
</div>
@@ -122,7 +124,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<FieldLabel>OCR Model</FieldLabel>
<FieldInput value={tools.ocrModel ?? ''} onChange={v => onChange('tools.ocrModel', v)}
placeholder="glm-ocr" />
<HelpText>GLM-OCR 使</HelpText>
<HelpText>{t('tools.vision.ocrModelHelp')}</HelpText>
</div>
</div>
)}
@@ -132,21 +134,21 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<div>
<FieldLabel>X Auth Token</FieldLabel>
<FieldInput type="password" value={tools.xAuthToken ?? ''} onChange={v => onChange('tools.xAuthToken', v)} />
<HelpText>X / Twitter auth_token cookie</HelpText>
<HelpText>{t('tools.x.authTokenHelp')}</HelpText>
</div>
<div>
<FieldLabel>X ct0</FieldLabel>
<FieldInput type="password" value={tools.xCt0 ?? ''} onChange={v => onChange('tools.xCt0', v)} />
<HelpText>X / Twitter ct0 cookie</HelpText>
<HelpText>{t('tools.x.ct0Help')}</HelpText>
</div>
<div>
<FieldLabel>X CLI Command</FieldLabel>
<FieldInput value={Array.isArray(tools.xCliCommand) ? tools.xCliCommand.join(' ') : (tools.xCliCommand ?? '')}
onChange={v => onChange('tools.xCliCommand', v)} />
<HelpText>twitter-cli </HelpText>
<HelpText>{t('tools.x.cliHelp')}</HelpText>
</div>
<div>
<FieldLabel>X Timeout ()</FieldLabel>
<FieldLabel>{t('tools.x.timeout')}</FieldLabel>
<FieldInput type="number" value={tools.xTimeout ?? 90}
onChange={v => onChange('tools.xTimeout', Number(v))} />
</div>
@@ -159,7 +161,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<FieldLabel>X Chrome Profile</FieldLabel>
<FieldInput value={tools.xChromeProfile ?? ''} onChange={v => onChange('tools.xChromeProfile', v)}
placeholder="/path/to/chrome/profile" />
<HelpText>Cookie Chrome </HelpText>
<HelpText>{t('tools.x.chromeProfileHelp')}</HelpText>
</div>
</div>
)}
@@ -169,7 +171,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<div>
<FieldLabel>Google Maps API Key</FieldLabel>
<FieldInput type="password" value={tools.googleMapsApiKey ?? ''} onChange={v => onChange('tools.googleMapsApiKey', v)} />
<HelpText>Google Maps Places / Directions API Nominatim / OSRM使</HelpText>
<HelpText>{t('tools.maps.keyHelp')}</HelpText>
</div>
</div>
)}
@@ -180,12 +182,12 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<FieldLabel>Amazon Affiliate Tag</FieldLabel>
<FieldInput value={tools.amazonAffiliateTag ?? ''} onChange={v => onChange('tools.amazonAffiliateTag', v)}
placeholder="your-tag-22" />
<HelpText>SearchAmazon 使</HelpText>
<HelpText>{t('tools.amazon.affiliateHelp')}</HelpText>
</div>
<div>
<FieldLabel>Keepa API Key</FieldLabel>
<FieldInput type="password" value={tools.keepaApiKey ?? ''} onChange={v => onChange('tools.keepaApiKey', v)} />
<HelpText>Keepa API </HelpText>
<HelpText>{t('tools.amazon.keepaHelp')}</HelpText>
</div>
</div>
)}
@@ -196,19 +198,19 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<FieldLabel>Speech Server URL</FieldLabel>
<FieldInput value={tools.speechServerUrl ?? ''} onChange={v => onChange('tools.speechServerUrl', v)}
placeholder="http://localhost:8000/v1" />
<HelpText> API TranscribeAudio </HelpText>
<HelpText>{t('tools.speech.serverHelp')}</HelpText>
</div>
<div>
<FieldLabel>Speech Timeout ()</FieldLabel>
<FieldLabel>{t('tools.speech.timeout')}</FieldLabel>
<FieldInput type="number" value={tools.speechTimeout ?? 300}
onChange={v => onChange('tools.speechTimeout', Number(v))} />
<HelpText></HelpText>
<HelpText>{t('tools.speech.timeoutHelp')}</HelpText>
</div>
<div>
<FieldLabel>Speech Language</FieldLabel>
<FieldInput value={tools.speechLanguage ?? 'ja'} onChange={v => onChange('tools.speechLanguage', v)}
placeholder="ja" />
<HelpText></HelpText>
<HelpText>{t('tools.speech.languageHelp')}</HelpText>
</div>
</div>
)}
@@ -219,7 +221,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
<h3 className="text-sm font-semibold text-slate-800">Knowledge (DKS)</h3>
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30"
title="この機能は legacy です。新規の知識検索統合は MCP server 経由を推奨"
title={t('tools.knowledge.legacyBadgeTitle')}
>
LEGACY
</span>
@@ -228,23 +230,21 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
role="note"
className="rounded border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300"
>
DKS <strong>legacy</strong> {' '}
<strong>MCP server </strong> namespace
namespace {' '}
{t('tools.knowledge.note')}{' '}
<a
href="/help"
className="underline text-amber-900 dark:text-amber-300 hover:text-amber-700 dark:hover:text-amber-300"
target="_blank"
rel="noopener noreferrer"
>
MCP
{t('tools.knowledge.mcpGuideLink')}
</a>
</div>
<div>
<FieldLabel>Knowledge Service URL</FieldLabel>
<FieldInput value={tools.knowledgeServiceUrl ?? ''} onChange={v => onChange('tools.knowledgeServiceUrl', v)}
placeholder="http://dks-server:8100" />
<HelpText>Document Knowledge Server (DKS) API knowledge </HelpText>
<HelpText>{t('tools.knowledge.serviceUrlHelp')}</HelpText>
</div>
<div>
<FieldLabel>Knowledge Namespaces</FieldLabel>
@@ -252,10 +252,10 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
value={tools.knowledgeNamespaces ?? {}}
onChange={v => onChange('tools.knowledgeNamespaces', v)}
addDisabled
addDisabledReason="新規 namespace 追加は MCP 経由を推奨"
addDisabledReason={t('tools.knowledge.addDisabledReason')}
addDisabledHref="/help"
/>
<HelpText>DKS API </HelpText>
<HelpText>{t('tools.knowledge.namespacesHelp')}</HelpText>
</div>
</div>
)}
@@ -263,93 +263,79 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
{tab === 'user-folder' && (
<div className="space-y-5">
<div>
<FieldLabel>RunUserScript </FieldLabel>
<FieldLabel>{t('tools.userScripts.enableLabel')}</FieldLabel>
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700">
<input
type="checkbox"
checked={tools.userScriptsEnabled === true}
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
/>
<span> (browser-macros: LLM RunUserScript + scheduled script task )</span>
<span>{t('tools.userScripts.enabledToggle')}</span>
</label>
<HelpText>
plain runtime Node <code>--permission</code> sandbox child_process / worker / tmpdir FS deny
browser-macros Playwright (child_process / native bindings / network) sandbox Node.js capability
</HelpText>
<HelpText>{t('tools.userScripts.enableHelp')}</HelpText>
</div>
<div>
<FieldLabel> allowlist ( = )</FieldLabel>
<FieldLabel>{t('tools.userScripts.allowlistLabel')}</FieldLabel>
<StringArrayEditor
value={tools.userScriptsAllowUserids ?? []}
onChange={v => onChange('tools.userScriptsAllowUserids', v)}
placeholder="user id (例: 12345)"
placeholder={t('tools.userScripts.allowlistPlaceholder')}
/>
<HelpText>
<code>user_scripts_enabled</code> ID browser-macro (RunUserScript / scheduled script task)
</HelpText>
<HelpText>{t('tools.userScripts.allowlistHelp')}</HelpText>
</div>
<div>
<FieldLabel>Trash Retention ()</FieldLabel>
<FieldLabel>{t('tools.userScripts.trashRetentionLabel')}</FieldLabel>
<FieldInput type="number" value={tools.trashRetentionDays ?? 30}
onChange={v => onChange('tools.trashRetentionDays', Number(v))} />
<HelpText>
<code>data/users/&#123;userId&#125;/trash/</code>
+ 24h sweep0 sweep 30
</HelpText>
<HelpText>{t('tools.userScripts.trashRetentionHelp')}</HelpText>
</div>
</div>
)}
{tab === 'uploads' && (
<div className="space-y-5">
<HelpText>UI API body (MB)</HelpText>
<HelpText>{t('tools.uploads.sectionHelp')}</HelpText>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('tools.uploads.maxLabel')}</FieldLabel>
<FieldInput type="number" value={tools.taskUploadMaxSizeMb ?? 50}
onChange={v => onChange('tools.taskUploadMaxSizeMb', Number(v))} />
<HelpText>
<code>POST /api/local/tasks</code> <code>POST /api/local/tasks/:id/comments</code>
body ( base64 JSON )
<code> × 0.75</code> (: 50 MB body 37 MB raw)
11000 MB 50 MB
</HelpText>
<HelpText>{t('tools.uploads.maxHelp')}</HelpText>
</div>
</div>
)}
{tab === 'office' && (
<div className="space-y-5">
<HelpText>Office (MB)</HelpText>
<HelpText>{t('tools.office.sectionHelp')}</HelpText>
<div>
<FieldLabel>ReadExcel </FieldLabel>
<FieldLabel>{t('tools.office.excelLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officeExcelMaxSizeMb ?? 10}
onChange={v => onChange('tools.officeExcelMaxSizeMb', Number(v))} />
<HelpText>ReadExcel .xlsx / .xls デフォルト: 10 MB</HelpText>
<HelpText>{t('tools.office.excelHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadDocx </FieldLabel>
<FieldLabel>{t('tools.office.docxLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officeDocxMaxSizeMb ?? 10}
onChange={v => onChange('tools.officeDocxMaxSizeMb', Number(v))} />
<HelpText>ReadDocx .docx デフォルト: 10 MB</HelpText>
<HelpText>{t('tools.office.docxHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadPdf </FieldLabel>
<FieldLabel>{t('tools.office.pdfLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officePdfMaxSizeMb ?? 10}
onChange={v => onChange('tools.officePdfMaxSizeMb', Number(v))} />
<HelpText>ReadPdf .pdf デフォルト: 10 MB</HelpText>
<HelpText>{t('tools.office.pdfHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadPPTX </FieldLabel>
<FieldLabel>{t('tools.office.pptxLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officePptxMaxSizeMb ?? 50}
onChange={v => onChange('tools.officePptxMaxSizeMb', Number(v))} />
<HelpText>ReadPPTX .pptx デフォルト: 50 MB</HelpText>
<HelpText>{t('tools.office.pptxHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadPPTX </FieldLabel>
<FieldLabel>{t('tools.office.pptxUncompressedLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officePptxMaxUncompressedMb ?? 200}
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
<HelpText>PPTX ZIP ZIP bomb デフォルト: 200 MB</HelpText>
<HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText>
</div>
</div>
)}
+25 -29
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
@@ -16,6 +17,7 @@ import type { SectionFormProps } from './types';
* tools.task_upload_max_size_mb
*/
export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const tools = config.tools ?? {};
return (
@@ -29,16 +31,16 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
<div>
<FieldLabel>Vision Model</FieldLabel>
<FieldInput value={tools.visionModel ?? ''} onChange={v => onChange('tools.visionModel', v)} />
<HelpText>使: qwen2-vl:8b-instruct</HelpText>
<HelpText>{t('tools.vision.modelHelp')}</HelpText>
</div>
<div>
<FieldLabel>Vision Base URL</FieldLabel>
<FieldInput value={tools.visionBaseUrl ?? ''} onChange={v => onChange('tools.visionBaseUrl', v)}
placeholder="Provider の Base URL と同じ場合は空欄" />
<HelpText>Vision API </HelpText>
placeholder={t('tools.vision.baseUrlPlaceholder')} />
<HelpText>{t('tools.vision.baseUrlHelp')}</HelpText>
</div>
<div>
<FieldLabel>Vision Timeout ()</FieldLabel>
<FieldLabel>{t('tools.vision.timeout')}</FieldLabel>
<FieldInput type="number" value={tools.visionTimeout ?? 60}
onChange={v => onChange('tools.visionTimeout', Number(v))} />
</div>
@@ -51,7 +53,7 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
<FieldLabel>OCR Model</FieldLabel>
<FieldInput value={tools.ocrModel ?? ''} onChange={v => onChange('tools.ocrModel', v)}
placeholder="glm-ocr" />
<HelpText>GLM-OCR 使</HelpText>
<HelpText>{t('tools.vision.ocrModelHelp')}</HelpText>
</div>
</section>
@@ -63,19 +65,19 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
<FieldLabel>Speech Server URL</FieldLabel>
<FieldInput value={tools.speechServerUrl ?? ''} onChange={v => onChange('tools.speechServerUrl', v)}
placeholder="http://localhost:8000/v1" />
<HelpText> API TranscribeAudio </HelpText>
<HelpText>{t('tools.speech.serverHelp')}</HelpText>
</div>
<div>
<FieldLabel>Speech Timeout ()</FieldLabel>
<FieldLabel>{t('tools.speech.timeout')}</FieldLabel>
<FieldInput type="number" value={tools.speechTimeout ?? 300}
onChange={v => onChange('tools.speechTimeout', Number(v))} />
<HelpText></HelpText>
<HelpText>{t('tools.speech.timeoutHelp')}</HelpText>
</div>
<div>
<FieldLabel>Speech Language</FieldLabel>
<FieldInput value={tools.speechLanguage ?? 'ja'} onChange={v => onChange('tools.speechLanguage', v)}
placeholder="ja" />
<HelpText></HelpText>
<HelpText>{t('tools.speech.languageHelp')}</HelpText>
</div>
</section>
@@ -83,36 +85,36 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Office (file size limits)
</h3>
<HelpText>Office (MB)</HelpText>
<HelpText>{t('tools.office.sectionHelp')}</HelpText>
<div>
<FieldLabel>ReadExcel </FieldLabel>
<FieldLabel>{t('tools.office.excelLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officeExcelMaxSizeMb ?? 10}
onChange={v => onChange('tools.officeExcelMaxSizeMb', Number(v))} />
<HelpText>ReadExcel .xlsx / .xls デフォルト: 10 MB</HelpText>
<HelpText>{t('tools.office.excelHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadDocx </FieldLabel>
<FieldLabel>{t('tools.office.docxLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officeDocxMaxSizeMb ?? 10}
onChange={v => onChange('tools.officeDocxMaxSizeMb', Number(v))} />
<HelpText>ReadDocx .docx デフォルト: 10 MB</HelpText>
<HelpText>{t('tools.office.docxHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadPdf </FieldLabel>
<FieldLabel>{t('tools.office.pdfLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officePdfMaxSizeMb ?? 10}
onChange={v => onChange('tools.officePdfMaxSizeMb', Number(v))} />
<HelpText>ReadPdf .pdf デフォルト: 10 MB</HelpText>
<HelpText>{t('tools.office.pdfHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadPPTX </FieldLabel>
<FieldLabel>{t('tools.office.pptxLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officePptxMaxSizeMb ?? 50}
onChange={v => onChange('tools.officePptxMaxSizeMb', Number(v))} />
<HelpText>ReadPPTX .pptx デフォルト: 50 MB</HelpText>
<HelpText>{t('tools.office.pptxHelp')}</HelpText>
</div>
<div>
<FieldLabel>ReadPPTX </FieldLabel>
<FieldLabel>{t('tools.office.pptxUncompressedLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officePptxMaxUncompressedMb ?? 200}
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
<HelpText>PPTX ZIP ZIP bomb デフォルト: 200 MB</HelpText>
<HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText>
</div>
</section>
@@ -120,18 +122,12 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Uploads
</h3>
<HelpText>UI API body (MB)</HelpText>
<HelpText>{t('tools.uploads.sectionHelp')}</HelpText>
<div>
<FieldLabel></FieldLabel>
<FieldLabel>{t('tools.uploads.maxLabel')}</FieldLabel>
<FieldInput type="number" value={(config.storage?.taskUploadMaxSizeMb) ?? 50}
onChange={v => onChange('storage.taskUploadMaxSizeMb', v ? Number(v) : undefined)} />
<HelpText>
<code>POST /api/local/tasks</code> <code>POST /api/local/tasks/:id/comments</code>
body ( base64 JSON )
<code> × 0.75</code> (: 50 MB body 37 MB raw)
11000 MB 50 MB
<strong>Paths &amp; Storage</strong> ( <code>storage.task_upload_max_size_mb</code> )
</HelpText>
<HelpText>{t('tools.uploads.maxHelpStorage')}</HelpText>
</div>
</section>
</div>
+14 -12
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
@@ -18,6 +19,7 @@ import type { SectionFormProps } from './types';
* search_filter.auto_block.*
*/
export function ToolsWebForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const tools = config.tools ?? {};
const sf = config.searchFilter ?? {};
const autoBlock = sf.autoBlock ?? {};
@@ -37,15 +39,15 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
<div>
<FieldLabel>SearXNG URL</FieldLabel>
<FieldInput value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
<HelpText>WebSearch SearXNG </HelpText>
<HelpText>{t('tools.web.searxngHelp')}</HelpText>
</div>
<div>
<FieldLabel>WebFetch Timeout ()</FieldLabel>
<FieldLabel>{t('tools.web.webfetchTimeout')}</FieldLabel>
<FieldInput type="number" value={tools.webfetchTimeout ?? 30}
onChange={v => onChange('tools.webfetchTimeout', Number(v))} />
</div>
<div>
<FieldLabel>WebSearch Timeout ()</FieldLabel>
<FieldLabel>{t('tools.web.websearchTimeout')}</FieldLabel>
<FieldInput type="number" value={tools.websearchTimeout ?? 15}
onChange={v => onChange('tools.websearchTimeout', Number(v))} />
</div>
@@ -55,7 +57,7 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
value={tools.webfetchAllowedHosts ?? []}
onChange={v => onChange('tools.webfetchAllowedHosts', v)}
placeholder="hostname or IP address" />
<HelpText>SSRF IP WebFetchBrowseWeb </HelpText>
<HelpText>{t('tools.web.ssrfHelp')}</HelpText>
</div>
</section>
@@ -65,23 +67,23 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
</h3>
<div>
<FieldLabel>Blocked Patterns ()</FieldLabel>
<FieldLabel>{t('tools.web.blockedLabel')}</FieldLabel>
<StringArrayEditor
value={sf.blockedPatterns ?? []}
onChange={v => onChange('searchFilter.blockedPatterns', v)}
placeholder="regex pattern"
/>
<HelpText>WebSearch </HelpText>
<HelpText>{t('tools.web.blockedHelp')}</HelpText>
</div>
<div>
<FieldLabel>Auto Block ()</FieldLabel>
<FieldLabel>{t('tools.web.autoBlockLabel')}</FieldLabel>
<div className="space-y-2 mt-1">
{([
['privateIp', 'プライベートIP', autoBlock.privateIp],
['internalDomain', '内部ドメイン', autoBlock.internalDomain],
['email', 'メールアドレス', autoBlock.email],
['phone', '電話番号', autoBlock.phone],
['privateIp', t('tools.web.autoBlock.privateIp'), autoBlock.privateIp],
['internalDomain', t('tools.web.autoBlock.internalDomain'), autoBlock.internalDomain],
['email', t('tools.web.autoBlock.email'), autoBlock.email],
['phone', t('tools.web.autoBlock.phone'), autoBlock.phone],
] as const).map(([key, label, checked]) => (
<label key={key} className="flex items-center gap-2 text-sm text-slate-700">
<input
@@ -94,7 +96,7 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
</label>
))}
</div>
<HelpText></HelpText>
<HelpText>{t('tools.web.autoBlockHelp')}</HelpText>
</div>
</section>
</div>
+10 -8
View File
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionFormProps) {
const { t } = useTranslation('settings');
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Workspace</h2>
@@ -13,17 +15,17 @@ export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionForm
value={config.worktreeDir ?? ''}
onChange={v => onChange('worktreeDir', v)}
disabled={!!overriddenByEnv['worktreeDir']}
disabledReason="WORKTREE_DIR 環境変数で上書き中"
disabledReason={t('pathsStorage.worktreeOverride')}
/>
{overriddenByEnv['worktreeDir'] && <EnvOverrideWarning />}
<HelpText></HelpText>
<HelpText>{t('pathsStorage.worktreeHelp')}</HelpText>
</div>
<div>
<FieldLabel>Custom Pieces Directory</FieldLabel>
<FieldInput value={config.customPiecesDir ?? ''} onChange={v => onChange('customPiecesDir', v || undefined)}
placeholder="/path/to/your/custom-pieces" />
<HelpText> pieces/ Piece pieces/ 使</HelpText>
<HelpText>{t('pathsStorage.customPiecesHelp')}</HelpText>
</div>
<div>
@@ -33,16 +35,16 @@ export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionForm
value={config.concurrency ?? ''}
onChange={v => onChange('concurrency', v ? Number(v) : undefined)}
disabled={!!overriddenByEnv['concurrency']}
disabledReason="CONCURRENCY 環境変数で上書き中"
disabledReason={t('execution.concurrencyOverride')}
/>
{overriddenByEnv['concurrency'] && <EnvOverrideWarning />}
<HelpText></HelpText>
<HelpText>{t('execution.concurrencyHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Movements</FieldLabel>
<FieldInput type="number" value={config.maxMovements ?? ''} onChange={v => onChange('maxMovements', v ? Number(v) : undefined)} />
<HelpText>1 movement </HelpText>
<HelpText>{t('execution.maxMovementsHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Retry</h3>
@@ -51,14 +53,14 @@ export function WorkspaceForm({ config, onChange, overriddenByEnv }: SectionForm
<FieldLabel>Max Attempts</FieldLabel>
<FieldInput type="number" value={config.retry?.maxAttempts ?? 3}
onChange={v => onChange('retry.maxAttempts', Number(v))} />
<HelpText>デフォルト: 3</HelpText>
<HelpText>{t('execution.maxAttemptsHelp')}</HelpText>
</div>
<div>
<FieldLabel>Backoff Seconds</FieldLabel>
<FieldInput value={(config.retry?.backoffSeconds ?? [60, 300, 900]).join(', ')}
onChange={v => onChange('retry.backoffSeconds', v.split(',').map((s: string) => Number(s.trim())).filter((n: number) => !isNaN(n)))} />
<HelpText>デフォルト: 60, 300, 900</HelpText>
<HelpText>{t('execution.backoffHelp')}</HelpText>
</div>
</div>
);
+4 -1
View File
@@ -1,7 +1,10 @@
import { useTranslation } from 'react-i18next';
export function EnvOverrideWarning() {
const { t } = useTranslation('settings');
return (
<div className="text-2xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 px-2 py-1 rounded mt-1">
{t('formUtils.envOverride')}
</div>
);
}
+6 -4
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface EmptyStateProps {
title: string;
@@ -10,6 +11,7 @@ interface EmptyStateProps {
}
export function EmptyState({ title, description, hint, compact, action, onCreateTask }: EmptyStateProps) {
const { t } = useTranslation('layout');
if (compact) {
return (
<div className="flex flex-col items-center justify-center text-center px-4 py-8 gap-2">
@@ -38,9 +40,9 @@ export function EmptyState({ title, description, hint, compact, action, onCreate
)}
<ol className="list-none p-0 m-0 flex flex-col gap-3 mb-6">
{[
'左パネルからタスクを選択する',
'会話・進捗・成果物ファイルをここで確認する',
'コメントを送ると追加指示として処理される',
t('emptyState.step1'),
t('emptyState.step2'),
t('emptyState.step3'),
].map((step, i) => (
<li key={i} className="flex gap-3 items-start text-xs text-slate-500">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 dark:bg-blue-500/15 text-blue-700 dark:text-blue-300 text-[10px] font-bold flex items-center justify-center mt-0.5">
@@ -56,7 +58,7 @@ export function EmptyState({ title, description, hint, compact, action, onCreate
onClick={onCreateTask}
className="self-start px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold hover:bg-accent-deep focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
{t('emptyState.createButton')}
</button>
)}
</div>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import {
createBrowserSessionProfile, startBrowserSessionLogin,
@@ -16,6 +17,7 @@ interface Props {
}
export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>('form');
const [label, setLabel] = useState(existingProfile?.label ?? '');
@@ -26,7 +28,7 @@ export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [novncPath, setNovncPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const pip = usePictureInPicture(novncPath, label ? `noVNC — ログイン: ${label}` : 'noVNC — ログイン');
const pip = usePictureInPicture(novncPath, label ? t('browserSessions.dialog.novncTitle', { label }) : t('browserSessions.dialog.novncTitleNoLabel'));
async function startLogin() {
setError(null);
@@ -88,7 +90,7 @@ export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
<div className={`bg-surface rounded-lg shadow-xl ${dialogSize} overflow-hidden flex flex-col`}>
<div className="px-4 py-3 border-b border-hairline flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">
{existingProfile ? `再ログイン: ${existingProfile.label}` : 'ブラウザセッションを追加'}
{existingProfile ? t('browserSessions.dialog.reLoginTitle', { label: existingProfile.label }) : t('browserSessions.dialog.addTitle')}
</h3>
<button onClick={cancel} className="text-slate-400 hover:text-slate-700 text-lg leading-none">×</button>
</div>
@@ -96,36 +98,36 @@ export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
{phase === 'form' && (
<div className="p-4 space-y-3">
<div>
<label className="block text-xs text-slate-700 mb-1"></label>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.label')}</label>
<input value={label} onChange={e => setLabel(e.target.value)}
disabled={!!existingProfile}
placeholder="My Twitter"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md disabled:bg-slate-50 disabled:text-slate-500" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1"> URL</label>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.startUrl')}</label>
<input value={startUrl} onChange={e => setStartUrl(e.target.value)}
placeholder="https://twitter.com/home"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1"></label>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.loggedInSelector')}</label>
<input value={loggedInSelector} onChange={e => setLoggedInSelector(e.target.value)}
placeholder='[data-testid="primaryColumn"]'
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
<div>
<label className="block text-xs text-slate-700 mb-1"> URL </label>
<label className="block text-xs text-slate-700 mb-1">{t('browserSessions.dialog.loginUrlPattern')}</label>
<input value={loginUrl} onChange={e => setLoginUrl(e.target.value)}
placeholder="https://twitter.com/i/flow/login**"
className="w-full h-8 px-2 text-xs border border-hairline rounded-md" />
</div>
{error && <div className="text-xs text-rose-600">{error}</div>}
<div className="flex justify-end gap-2 pt-2">
<button onClick={cancel} className="text-xs px-3 py-1.5 rounded-md hover:bg-surface"></button>
<button onClick={cancel} className="text-xs px-3 py-1.5 rounded-md hover:bg-surface">{t('browserSessions.dialog.cancel')}</button>
<button disabled={!label || !startUrl} onClick={startLogin}
className="text-xs px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:bg-slate-300">
{t('browserSessions.dialog.openLoginWindow')}
</button>
</div>
</div>
@@ -136,29 +138,29 @@ export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
<div className="flex-1 min-h-[420px] bg-black">
{pip.isOpen ? (
<div className="w-full h-full flex items-center justify-center text-xs text-slate-300">
PiP
{t('browserSessions.dialog.pipShown')}
</div>
) : (
<iframe src={novncPath} title="login" className="w-full h-full" allow="clipboard-read; clipboard-write" />
)}
</div>
<div className="px-4 py-3 border-t border-hairline flex items-center justify-between text-xs">
<span className="text-slate-600"></span>
<span className="text-slate-600">{t('browserSessions.dialog.loginPrompt')}</span>
<div className="flex gap-2 items-center">
<PipButton pip={pip} />
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface"></button>
<button onClick={saveNow} className="px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep"></button>
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface">{t('browserSessions.dialog.cancel')}</button>
<button onClick={saveNow} className="px-3 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent-deep">{t('browserSessions.dialog.save')}</button>
</div>
</div>
</div>
)}
{phase === 'saving' && <div className="p-6 text-center text-xs text-slate-500"></div>}
{phase === 'done' && <div className="p-6 text-center text-xs text-emerald-600"></div>}
{phase === 'saving' && <div className="p-6 text-center text-xs text-slate-500">{t('browserSessions.dialog.savingState')}</div>}
{phase === 'done' && <div className="p-6 text-center text-xs text-emerald-600">{t('browserSessions.dialog.doneState')}</div>}
{phase === 'error' && (
<div className="p-6 space-y-3 text-center">
<div className="text-xs text-rose-600">{error}</div>
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface text-xs"></button>
<button onClick={cancel} className="px-3 py-1.5 rounded-md hover:bg-surface text-xs">{t('browserSessions.dialog.close')}</button>
</div>
)}
</div>

Some files were not shown because too many files have changed in this diff Show More