feat: translate system interface to English

This commit is contained in:
2026-09-28 18:42:16 +09:00
parent e870bdfcbb
commit 1cf32705a7
74 changed files with 1232 additions and 1065 deletions
+1 -2
View File
@@ -78,8 +78,7 @@ export function createResearchStore(database = getDatabase()): ResearchStore {
},
activate(id) {
database.transaction(() => {
if (id !== null && !load(id))
throw new Error('この会話は見つかりません。')
if (id !== null && !load(id)) throw new Error('Conversation not found.')
setActive(id)
})
},
+1 -3
View File
@@ -141,9 +141,7 @@ describe('research history persistence', () => {
it('rejects a missing conversation without changing the active conversation', () => {
const store = createResearchStore(open())
store.save(run)
expect(() => store.activate('missing')).toThrow(
'この会話は見つかりません。',
)
expect(() => store.activate('missing')).toThrow('Conversation not found.')
expect(store.loadActive()).toEqual(run)
})
@@ -104,7 +104,7 @@ it('propagates failed saves so the user can retry instead of reporting successfu
actions.save.mockResolvedValueOnce(false)
await expect(
syncResearchDeck(plan, false, bindings, actions),
).rejects.toThrow('一時デッキを更新できませんでした')
).rejects.toThrow('Unable to update the temporary deck')
await expect(
syncResearchDeck(plan, true, bindings, actions),
).resolves.toBeUndefined()
+1 -1
View File
@@ -22,7 +22,7 @@ export async function syncResearchDeck(
if (existing && !existing.persisted) {
if (!(await workspace.save({ ...plan, id: existing.id }))) {
throw new Error(
'一時デッキを更新できませんでした。保存処理の完了後に再試行してください。',
'Unable to update the temporary deck. Try again after saving finishes.',
)
}
viewId = existing.id
+3 -3
View File
@@ -45,15 +45,15 @@ export function ResearchMarkdown({
img: ({ src, alt }) =>
typeof src === 'string' && src ? (
<a href={src} target="_blank" rel="noopener noreferrer">
{alt || '画像を開く'}
{alt || 'Open image'}
</a>
) : (
<span>{alt || '画像'}</span>
<span>{alt || 'Image'}</span>
),
table: ({ children }) => (
<section
className="research-markdown-table"
aria-label="回答の表"
aria-label="Response table"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users need to scroll wide tables inside the narrow chat pane.
tabIndex={0}
>
+69 -67
View File
@@ -106,28 +106,34 @@ it('retains input, selected accounts, and the request ID across responsive view
connection,
{ ...connection, id: 'second', displayName: 'second' },
])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 2件' }))
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 2' }),
)
fireEvent.click(screen.getByRole('checkbox', { name: 'second · Twitter' }))
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCPの反応' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'入力内容は残っています',
'Your draft has been kept',
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
expect(screen.getByLabelText('Message')).toHaveValue('WebMCPの反応')
expect(screen.queryByText(/internal credential/)).toBeNull()
view.rerender(view.ui(true, undefined, 'mobile'))
expect(screen.getByRole('alert')).toHaveTextContent('入力内容は残っています')
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 1件' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'Your draft has been kept',
)
expect(screen.getByLabelText('Message')).toHaveValue('WebMCPの反応')
fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 1' }),
)
expect(
screen.getByRole('checkbox', { name: 'second · Twitter' }),
).not.toBeChecked()
expect(MockEventSource.instances).toHaveLength(1)
expect(MockEventSource.instances[0]?.close).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
expect(actions.start.mock.calls[1]?.[0]).toEqual(
actions.start.mock.calls[0]?.[0],
@@ -136,11 +142,11 @@ it('retains input, selected accounts, and the request ID across responsive view
it('opens each generated deck version only once for the run started in this browser', async () => {
const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), {
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCPの反応' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce())
act(() =>
view.client.setQueryData(['research-status'], {
@@ -162,7 +168,9 @@ it('opens each generated deck version only once for the run started in this brow
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledTimes(2), {
timeout: 2500,
})
expect(screen.getByRole('log', { name: '調査の会話' })).toBeVisible()
expect(
screen.getByRole('log', { name: 'Research conversation' }),
).toBeVisible()
})
it('restores the latest plan automatically on reload and permits manual activation', async () => {
@@ -172,7 +180,7 @@ it('restores the latest plan automatically on reload and permits manual activati
expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, true),
)
fireEvent.click(
await screen.findByRole('button', { name: '一時デッキで開く' }),
await screen.findByRole('button', { name: 'Open temporary deck' }),
)
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenLastCalledWith(running.deck, true),
@@ -185,13 +193,13 @@ it('reports cancellation failure and updates the run after a successful retry',
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue({ ...running, status: 'cancelled' })
show()
fireEvent.click(await screen.findByRole('button', { name: '調査を中止' }))
fireEvent.click(await screen.findByRole('button', { name: 'Stop research' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'中止できませんでした',
'Unable to stop research',
)
fireEvent.click(screen.getByRole('button', { name: '調査を中止' }))
fireEvent.click(screen.getByRole('button', { name: 'Stop research' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: '調査を中止' })).toBeNull(),
expect(screen.queryByRole('button', { name: 'Stop research' })).toBeNull(),
)
expect(actions.cancel).toHaveBeenCalledWith({ data: { id: running.id } })
})
@@ -213,7 +221,7 @@ it('limits account selection to six and shows the host report path on completion
})),
)
await screen.findByText('/home/owner/reports/webmcp.md')
fireEvent.click(screen.getByText('接続アカウント · 6件'))
fireEvent.click(screen.getByText('Connected accounts · 6'))
expect(
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
).toHaveAttribute('aria-disabled', 'true')
@@ -225,7 +233,7 @@ it('limits account selection to six and shows the host report path on completion
it('waits for the workspace before restoring a completed plan from the stream', async () => {
const view = show([connection], false)
await screen.findByLabelText('メッセージ')
await screen.findByLabelText('Message')
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
@@ -253,9 +261,9 @@ it('reconnects the stream without polling or cancelling the host job on unmount'
const stream = MockEventSource.instances[0]
expect(stream?.url).toBe('/api/research/events')
act(() => stream?.onerror?.())
expect(screen.getByText(/配信に再接続/)).toBeVisible()
expect(screen.getByText(/Reconnecting to research updates/)).toBeVisible()
act(() => stream?.onopen?.())
expect(screen.queryByText(/配信に再接続/)).toBeNull()
expect(screen.queryByText(/Reconnecting to research updates/)).toBeNull()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1200))
})
@@ -274,11 +282,11 @@ it('keeps newer stream state when the start response arrives late', async () =>
}),
)
const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), {
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCP' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ configured: true, run: running }),
@@ -343,13 +351,13 @@ it('shows streamed conversation and sends follow-up input with the current deck
const view = show()
expect(await screen.findByText('検索結果を比較しました。')).toBeVisible()
expect(screen.getByText('検索カラムを追加しました')).toBeVisible()
expect(screen.getByText('あなた')).toBeVisible()
expect(screen.getByText('You')).toBeVisible()
const editedDeck = { id: 'other', title: '切り替えたデッキ', columns: [] }
view.rerender(view.ui(true, editedDeck))
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'このデッキを絞って' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() =>
expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -359,9 +367,7 @@ it('shows streamed conversation and sends follow-up input with the current deck
}),
}),
)
await waitFor(() =>
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
await waitFor(() => expect(screen.getByLabelText('Message')).toHaveValue(''))
const updatedDeck = { id: 'plan', title: '絞り込み結果', columns: [] }
act(() =>
MockEventSource.instances[0]?.onmessage?.({
@@ -390,9 +396,11 @@ it('shows streamed conversation and sends follow-up input with the current deck
it('selects newly loaded connected accounts until the user changes their selection', async () => {
const view = show([])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 0件' }))
await screen.findByText('接続アカウントを先に追加してください。')
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 0' }),
)
await screen.findByText('Add a connected account first.')
view.rerender(
<QueryClientProvider client={view.client}>
<ResearchPanel
@@ -403,10 +411,10 @@ it('selects newly loaded connected accounts until the user changes their selecti
/>
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '調べて' },
})
expect(screen.getByRole('button', { name: '送信' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled()
})
it('starts a new chat without changing the deck and clears the draft only after success', async () => {
@@ -419,30 +427,26 @@ it('starts a new chat without changing the deck and clears the draft only after
.mockResolvedValue({ configured: true, run: null })
const view = show()
await waitFor(() =>
expect(
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
expect(screen.getByRole('button', { name: 'New chat' })).toBeEnabled(),
)
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '下書き' },
})
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'会話と入力内容は残っています',
'Your conversation and draft have been kept',
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('下書き')
expect(screen.getByLabelText('Message')).toHaveValue('下書き')
const callsBeforeReset = view.onOpenDeck.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
await waitFor(() =>
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
await waitFor(() => expect(screen.getByLabelText('Message')).toHaveValue(''))
expect(screen.queryByText('WebMCPの反応')).toBeNull()
expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset)
expect(screen.getByText('手動デッキ')).toBeVisible()
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '新しい話題' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledOnce())
expect(actions.start.mock.calls[0]?.[0].data).not.toHaveProperty('runId')
expect(actions.reset).toHaveBeenCalledWith({ data: { id: running.id } })
@@ -452,7 +456,7 @@ it('disables new chat during a running turn and receives another device reset ov
current = { configured: true, run: running }
const view = show()
await screen.findByText('WebMCPの反応')
expect(screen.getByRole('button', { name: '新しいチャット' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'New chat' })).toBeDisabled()
const callsBeforeReset = view.onOpenDeck.mock.calls.length
act(() =>
MockEventSource.instances[0]?.onmessage?.({
@@ -475,11 +479,9 @@ it('does not let a late reset response erase a newer streamed conversation', asy
)
const view = show()
await waitFor(() =>
expect(
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
expect(screen.getByRole('button', { name: 'New chat' })).toBeEnabled(),
)
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
const newer = {
configured: true,
run: {
@@ -596,11 +598,11 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
connection,
{ ...connection, id: 'second', displayName: 'account2' },
])
await screen.findByLabelText('チャット履歴')
fireEvent.change(screen.getByLabelText('メッセージ'), {
await screen.findByLabelText('Chat history')
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '別の下書き' },
})
fireEvent.change(screen.getByLabelText('チャット履歴'), {
fireEvent.change(screen.getByLabelText('Chat history'), {
target: { value: saved.id },
})
expect(await screen.findByText('保存した会話')).toBeVisible()
@@ -615,9 +617,9 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true),
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('')
expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible()
fireEvent.click(screen.getByText('接続アカウント · 1件'))
expect(screen.getByLabelText('Message')).toHaveValue('')
expect(screen.getByText('Interrupted · Ready to resume')).toBeVisible()
fireEvent.click(screen.getByText('Connected accounts · 1'))
expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).not.toBeChecked()
@@ -631,24 +633,24 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
configured: true,
run: { ...saved, message: '更新' },
run: { ...saved, message: 'Refresh' },
history,
}),
}),
)
await waitFor(() =>
expect(view.client.getQueryData(['research-status'])).toMatchObject({
run: { message: '更新' },
run: { message: 'Refresh' },
}),
)
expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).toBeChecked()
expect(view.onOpenDeck).toHaveBeenCalledOnce()
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '続きを調べて' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() =>
expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -680,7 +682,7 @@ it('restores a context-only chat on reload and prevents switching during a runni
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true),
)
expect(screen.getByLabelText('チャット履歴')).toBeDisabled()
expect(screen.getByLabelText('Chat history')).toBeDisabled()
})
it('keeps newer streamed history when a chat selection response arrives late', async () => {
@@ -702,7 +704,7 @@ it('keeps newer streamed history when a chat selection response arrives late', a
}),
)
const view = show()
fireEvent.change(await screen.findByLabelText('チャット履歴'), {
fireEvent.change(await screen.findByLabelText('Chat history'), {
target: { value: saved.id },
})
const newer = { configured: true, run: { ...running, id: 'remote' }, history }
+47 -40
View File
@@ -52,12 +52,12 @@ import {
import './research-panel.css'
const statusNames: Record<ResearchRun['status'], string> = {
starting: '開始しています',
running: '調査中',
complete: '完了',
failed: '失敗',
cancelled: '中止しました',
interrupted: '中断しました・続きから再開できます',
starting: 'Starting',
running: 'Researching',
complete: 'Complete',
failed: 'Failed',
cancelled: 'Cancelled',
interrupted: 'Interrupted · Ready to resume',
}
type ResearchInput = { topic: string; connectionIds: string[] }
type ResearchDraft = {
@@ -186,7 +186,7 @@ export function ResearchPanel({
pendingTurn.current = null
void syncDeck(deck, activate).catch(() => {
setError(
'調査デッキを同期できませんでした。「一時デッキで開く」から再試行できます。',
'Unable to sync the research deck. Use “Open temporary deck” to try again.',
)
})
}, [run, syncDeck, ready])
@@ -222,7 +222,7 @@ export function ResearchPanel({
} catch {
pendingTurn.current = null
setError(
'送信できませんでした。接続を確認して再試行してください。入力内容は残っています。',
'Unable to send. Check your connection and try again. Your draft has been kept.',
)
return false
} finally {
@@ -247,7 +247,7 @@ export function ResearchPanel({
: existing,
)
} catch {
setError('調査を中止できませんでした。もう一度お試しください。')
setError('Unable to stop research. Please try again.')
} finally {
setBusy(false)
}
@@ -268,7 +268,7 @@ export function ResearchPanel({
updateDraft({ topic: '' })
} catch {
setError(
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
'Unable to start a new chat. Your conversation and draft have been kept. Please try again.',
)
} finally {
setBusy(false)
@@ -287,7 +287,7 @@ export function ResearchPanel({
)
} catch {
setError(
'チャットを開けませんでした。入力内容は残っています。もう一度お試しください。',
'Unable to open the chat. Your draft has been kept. Please try again.',
)
} finally {
setBusy(false)
@@ -301,7 +301,7 @@ export function ResearchPanel({
await syncDeck(deck, true)
} catch {
setError(
'調査デッキを開けませんでした。保存処理の完了後に再試行してください。',
'Unable to open the research deck. Try again after saving finishes.',
)
} finally {
setBusy(false)
@@ -309,9 +309,9 @@ export function ResearchPanel({
}
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
<aside className="research-panel" aria-label="調査チャット">
<aside className="research-panel" aria-label="Research chat">
<header className="research-heading">
<h2>調査チャット</h2>
<h2>Research chat</h2>
<Button
variant="ghost"
size="sm"
@@ -319,16 +319,16 @@ export function ResearchPanel({
disabled={busy || !run || isResearchActive(run)}
title={
isResearchActive(run)
? '調査を中止してから新しいチャットを開始できます'
? 'Stop research before starting a new chat'
: undefined
}
onClick={() => void newChat()}
>
新しいチャット
New chat
</Button>
{(status.data?.history?.length ?? 0) > 0 && (
<label className="research-history" htmlFor={historyId}>
<span>チャット履歴</span>
<span>Chat history</span>
<NativeSelect
id={historyId}
size="sm"
@@ -337,7 +337,7 @@ export function ResearchPanel({
onChange={(event) => void selectChat(event.target.value)}
>
<NativeSelectOption value="" disabled>
新しいチャット
New chat
</NativeSelectOption>
{status.data?.history?.map((entry) => (
<NativeSelectOption key={entry.id} value={entry.id}>
@@ -350,14 +350,15 @@ export function ResearchPanel({
</header>
{reconnecting && (
<p className="deck-save-status" role="status">
調査状況の配信に再接続しています。処理は自宅サーバーで続きます。
Reconnecting to research updates. Processing continues on your home
server.
</p>
)}
{status.isError && (
<p className="deck-error" role="alert">
調査状況を取得できませんでした。
Unable to load research status.
<Button type="button" onClick={() => void status.refetch()}>
再試行
Retry
</Button>
</p>
)}
@@ -367,9 +368,11 @@ export function ResearchPanel({
</p>
)}
{status.isPending ? (
<p role="status">調査の接続を確認しています…</p>
<p role="status">Checking the research connection…</p>
) : !status.data?.configured ? (
<p role="status">自宅のCodexへの接続が設定されていません。</p>
<p role="status">
The connection to Codex on your home server is not configured.
</p>
) : null}
<ResearchConversation
key={`conversation:${run?.id ?? 'new'}`}
@@ -416,16 +419,20 @@ function ResearchConversation({
citations: ResearchCitation[]
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
}) {
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' }
const names = { user: 'You', assistant: 'Codex', tool: 'Tool activity' }
return (
<MessageScrollerProvider autoScroll>
<MessageScroller className="research-conversation">
<MessageScrollerViewport aria-label="調査メッセージ">
<MessageScrollerContent aria-label="調査の会話" className="gap-5 p-4">
<MessageScrollerViewport aria-label="Research messages">
<MessageScrollerContent
aria-label="Research conversation"
className="gap-5 p-4"
>
{messages.length === 0 ? (
<MessageScrollerItem messageId="empty">
<p className="research-empty">
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
Send a topic or question. The assistant adds columns as it
searches and saves a report to your home server.
</p>
</MessageScrollerItem>
) : (
@@ -471,7 +478,7 @@ function ResearchConversation({
)}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton aria-label="最新のメッセージへ" />
<MessageScrollerButton aria-label="Jump to latest message" />
</MessageScroller>
</MessageScrollerProvider>
)
@@ -508,7 +515,7 @@ function ResearchComposer({
return (
<form
className="research-start-form"
aria-label="調査メッセージ"
aria-label="Research messages"
onSubmit={(event) => {
event.preventDefault()
if (!topic.trim() || validSelection.length === 0 || disabled) return
@@ -521,18 +528,18 @@ function ResearchComposer({
}}
>
<p className="research-context">
送信するデッキ: <strong>{contextTitle}</strong>
Deck to send: <strong>{contextTitle}</strong>
</p>
<fieldset disabled={disabled}>
<Collapsible className="research-account-picker" disabled={disabled}>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
接続アカウント · {validSelection.length}件
Connected accounts · {validSelection.length}
</CollapsibleTrigger>
<CollapsibleContent>
<fieldset className="research-accounts">
<legend>調査に使う接続アカウント(最大6件)</legend>
<legend>Accounts for research (up to 6)</legend>
{available.length === 0 ? (
<p>接続アカウントを先に追加してください。</p>
<p>Add a connected account first.</p>
) : (
available.map((connection) => (
<label key={connection.id} htmlFor={`${id}-${connection.id}`}>
@@ -562,7 +569,7 @@ function ResearchComposer({
</fieldset>
</CollapsibleContent>
</Collapsible>
<label htmlFor={id}>メッセージ</label>
<label htmlFor={id}>Message</label>
<InputGroup>
<InputGroupTextarea
id={id}
@@ -570,7 +577,7 @@ function ResearchComposer({
onChange={(event) => onTopicChange(event.target.value)}
maxLength={1000}
rows={3}
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
placeholder="e.g. Compare reactions to WebMCP, then refine this deck."
required
disabled={disabled}
/>
@@ -583,7 +590,7 @@ function ResearchComposer({
disabled || !topic.trim() || validSelection.length === 0
}
>
送信
Send
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
@@ -605,7 +612,7 @@ function ResearchRunStatus({
}) {
const deck = run.deck ?? run.contextDeck
return (
<section className="research-run" aria-label="調査状況">
<section className="research-run" aria-label="Research status">
<Marker role="status">
<MarkerContent>{statusNames[run.status]}</MarkerContent>
</Marker>
@@ -616,7 +623,7 @@ function ResearchRunStatus({
)}
{run.reportPath && (
<p>
Markdownの保存先
Markdown location
<br />
<code className="research-report-path">{run.reportPath}</code>
</p>
@@ -630,7 +637,7 @@ function ResearchRunStatus({
disabled={busy}
onClick={onCancel}
>
調査を中止
Stop research
</Button>
)}
{deck && (
@@ -642,7 +649,7 @@ function ResearchRunStatus({
onOpenDeck(deck)
}}
>
一時デッキで開く
Open temporary deck
</Button>
)}
</div>
+29 -23
View File
@@ -94,7 +94,7 @@ export function createResearchService(
if (isResearchActive(run) && run) {
run.status = 'interrupted'
run.error =
'サーバーの再起動で調査が中断されました。メッセージを送ると同じ会話を再開できます。'
'Research was interrupted by a server restart. Send a message to resume this conversation.'
deps.store.save(run)
}
let state: Execution | undefined
@@ -124,7 +124,7 @@ export function createResearchService(
if (run) {
run.status = 'failed'
run.error =
'会話を保存できませんでした。空き容量やDBの状態を確認してください。最後に保存できた内容から再開できます。'
'Unable to save the conversation. Check disk space and the database. You can resume from the last saved state.'
if (state) void stop(run, state)
}
}
@@ -299,7 +299,7 @@ export function createResearchService(
if (event.data.turn.status !== 'completed') {
fail(
current,
'Codexの調査が完了しませんでした。接続・認証・実行権限を確認してください。',
'Codex could not complete the research. Check its connection, authentication, and permissions.',
)
return
}
@@ -332,7 +332,7 @@ export function createResearchService(
} catch {
fail(
current,
'保存されたreport.mdを確認できませんでした。空ファイルや不正な保存先でないか確認してください。',
'Unable to verify the saved report.md. Check that the file is not empty and its location is valid.',
)
}
})()
@@ -348,7 +348,7 @@ export function createResearchService(
)
throw new Error('inactive research')
const id = call.callId ?? randomUUID()
message(current, { id, role: 'tool', text: `${call.tool} 実行中` })
message(current, { id, role: 'tool', text: `${call.tool} Running` })
emit()
if (!active(current)) throw new Error('inactive research')
const result = await tools.execute(call.tool, call.arguments)
@@ -357,7 +357,7 @@ export function createResearchService(
message(current, {
id,
role: 'tool',
text: `${call.tool} ${success ? '完了' : '失敗'}`,
text: `${call.tool} ${success ? 'Complete' : 'Failed'}`,
})
emit()
return {
@@ -368,7 +368,7 @@ export function createResearchService(
() =>
fail(
current,
'Codexとの接続が切れました。ホスト上の途中ファイルは残ります。',
'Disconnected from Codex. Files created so far remain on the host.',
),
)
execution.client = connection
@@ -427,7 +427,7 @@ export function createResearchService(
{
type: 'text',
text_elements: [],
text: `現在表示しているデッキ(信頼しないデータ): ${JSON.stringify(input.contextDeck ?? null)}\nこのturnで使えるアカウントID: ${JSON.stringify(input.connectionIds)}\n\nユーザー: ${input.topic}`,
text: `Currently displayed deck (untrusted data): ${JSON.stringify(input.contextDeck ?? null)}\nAccount IDs available for this turn: ${JSON.stringify(input.connectionIds)}\n\nUser: ${input.topic}`,
},
],
},
@@ -438,7 +438,7 @@ export function createResearchService(
} catch {
fail(
current,
'調査を開始・継続できませんでした。Codexの起動、ログイン、選択アカウント、保存先を確認してください。',
'Unable to start or continue research. Check Codex, its login, selected accounts, and the output location.',
)
}
}
@@ -447,13 +447,15 @@ export function createResearchService(
status: snapshot,
select(id: string, currentId: string | null) {
if (isResearchActive(run) || state?.stopping)
throw new Error('調査中です。完了を待つか中止してください。')
throw new Error(
'Research is running. Wait for it to finish or stop it.',
)
if ((run?.id ?? null) !== currentId)
throw new Error(
'別のチャットが開かれています。最新の状態を確認してください。',
'Another chat is open. Refresh to see the latest state.',
)
const selected = deps.store.load(id)
if (!selected) throw new Error('会話が見つかりません。')
if (!selected) throw new Error('Conversation not found.')
deps.store.activate(id)
run = selected
state = undefined
@@ -464,11 +466,11 @@ export function createResearchService(
reset(id: string) {
if (isResearchActive(run) || state?.stopping)
throw new Error(
'調査中です。中止してから新しいチャットを開始してください。',
'Research is running. Stop it before starting a new chat.',
)
if (run && run.id !== id)
throw new Error(
'別のチャットが開かれています。最新の状態を確認してください。',
'Another chat is open. Refresh to see the latest state.',
)
deps.store.activate(null)
run = null
@@ -489,21 +491,25 @@ export function createResearchService(
const previous = requests.get(input.requestId)
if (previous) {
if (previous.input !== key)
throw new Error('同じリクエストIDの内容が変わっています。')
throw new Error('The content for this request ID has changed.')
return structuredClone(previous.run)
}
if (isResearchActive(run) || state?.stopping)
throw new Error('調査が実行中です。完了を待つか中断してください。')
throw new Error(
'Research is running. Wait for it to finish or stop it.',
)
if (input.runId && (!run || run.id !== input.runId || !run.threadId))
throw new Error(
'続ける会話が見つかりません。新しい会話を開始してください。',
'The conversation to continue could not be found. Start a new conversation.',
)
const config = deps.config()
if (!config)
throw new Error('Codexへの接続とMarkdown保存先が未設定です。')
throw new Error(
'The Codex connection and Markdown output location are not configured.',
)
if (requests.size >= 100)
throw new Error(
'プロトタイプの実行上限です。アプリを再起動してください。',
'The prototype run limit has been reached. Restart the app.',
)
const current: ResearchRun = {
...(input.runId && run ? structuredClone(run) : {}),
@@ -514,7 +520,7 @@ export function createResearchService(
status: 'starting',
startedAt: Date.now(),
deckVersion: input.runId && run ? run.deckVersion : 0,
message: 'Codexに調査を依頼しています…',
message: 'Asking Codex to research…',
messages: [
...(input.runId && run ? structuredClone(run.messages) : []),
{ id: input.requestId, role: 'user', text: input.topic },
@@ -532,7 +538,7 @@ export function createResearchService(
if (!active(current)) return
current.status = 'failed'
current.error =
'調査の制限時間(15分)に達しました。途中のMarkdownはホストに残ります。'
'Research reached its 15-minute time limit. Markdown files created so far remain on the host.'
void stop(current, execution)
emit()
}, MAX_RUN_MS)
@@ -542,14 +548,14 @@ export function createResearchService(
return structuredClone(current)
},
async cancel(id: string) {
if (!run || run.id !== id) throw new Error('この調査は見つかりません。')
if (!run || run.id !== id) throw new Error('Research run not found.')
if (!state) return structuredClone(run)
const current = run
const execution = state
if (active(current)) {
current.status = 'cancelled'
current.message =
'調査を中断しました。ホストに作成済みのファイルは残ります。'
'Research stopped. Files already created remain on the host.'
const stopping = stop(current, execution)
emit()
await stopping
+19 -14
View File
@@ -154,7 +154,7 @@ it('deduplicates repeated start requests and rejects changed content under the s
const first = service.start(request)
expect(service.start(request).id).toBe(first.id)
expect(() => service.start({ ...request, topic: 'Different' })).toThrow(
'内容が変わっています',
'content for this request ID has changed',
)
const client = await running()
expect(clients).toHaveLength(1)
@@ -166,7 +166,7 @@ it('deduplicates repeated start requests and rejects changed content under the s
it('rejects a second active research run', async () => {
service.start(input())
await running()
expect(() => service.start(input())).toThrow('調査が実行中')
expect(() => service.start(input())).toThrow('Research is running')
expect(clients).toHaveLength(1)
})
@@ -271,7 +271,7 @@ it('waits for an accepted turn-start response before interrupting and ignores la
const cancelled = service.cancel(first.id)
expect(service.status().run?.status).toBe('cancelled')
expect(old.close).not.toHaveBeenCalled()
expect(() => service.start(input())).toThrow('調査が実行中')
expect(() => service.start(input())).toThrow('Research is running')
resolveTurn({ turn: { id: old.turnId } })
await cancelled
expect(old.calls).toHaveBeenCalledWith('turn/interrupt', {
@@ -428,7 +428,10 @@ it('resumes the same conversation with fresh deck context and preserves prior me
expect.objectContaining({ role: 'user', text: 'WebMCP' }),
{ id: 'reply-one', role: 'assistant', text: 'どんな調査をしますか?' },
expect.objectContaining({ role: 'user', text: 'このカラムを読んで' }),
expect.objectContaining({ role: 'tool', text: 'fetch_column_posts 完了' }),
expect.objectContaining({
role: 'tool',
text: 'fetch_column_posts Complete',
}),
{ id: 'reply-two', role: 'assistant', text: '読みました' },
])
old.disconnected()
@@ -437,7 +440,7 @@ it('resumes the same conversation with fresh deck context and preserves prior me
it('rejects unknown continuation IDs', () => {
expect(() => service.start({ ...input(), runId: randomUUID() })).toThrow(
'続ける会話が見つかりません',
'The conversation to continue could not be found',
)
})
@@ -528,7 +531,7 @@ it('resets a completed conversation for every viewer and starts a fresh thread w
expect(service.reset(first.id).run).toBeNull()
expect(await readFile(reportPath, 'utf8')).toBe('# Saved research')
expect(() => service.start({ ...input(), runId: first.id })).toThrow(
'続ける会話が見つかりません',
'The conversation to continue could not be found',
)
const next = service.start(input())
const current = await running()
@@ -546,9 +549,9 @@ it('resets a completed conversation for every viewer and starts a fresh thread w
it('requires an active turn to stop before resetting and rejects a stale reset from another viewer', async () => {
const first = service.start(input())
await running()
expect(() => service.reset(first.id)).toThrow('中止してから')
expect(() => service.reset(first.id)).toThrow('Stop it before')
await service.cancel(first.id)
expect(() => service.reset(randomUUID())).toThrow('別のチャット')
expect(() => service.reset(randomUUID())).toThrow('Another chat')
expect(service.status().run?.id).toBe(first.id)
expect(service.reset(first.id).run).toBeNull()
})
@@ -698,11 +701,13 @@ it('persists a blank new chat and lets all viewers reopen the retained conversat
it('rejects switching conversations while running or from a stale viewer', async () => {
const first = service.start(input())
const client = await running()
expect(() => service.select(first.id, first.id)).toThrow('調査中')
expect(() => service.select(first.id, first.id)).toThrow(
'Research is running',
)
client.completed()
await vi.waitFor(() => expect(service.status().run?.status).toBe('complete'))
expect(() => service.select(first.id, null)).toThrow('別のチャット')
expect(() => service.select(randomUUID(), first.id)).toThrow('見つかりません')
expect(() => service.select(first.id, null)).toThrow('Another chat')
expect(() => service.select(randomUUID(), first.id)).toThrow('not found')
expect(service.status().run?.id).toBe(first.id)
})
@@ -728,7 +733,7 @@ it('stops a running turn when saving streamed messages fails', async () => {
})
expect(service.status().run).toMatchObject({
status: 'failed',
error: expect.stringContaining('保存できません'),
error: expect.stringContaining('Unable to save'),
})
await vi.waitFor(() => expect(client.close).toHaveBeenCalledTimes(1))
expect(client.calls).toHaveBeenCalledWith('turn/interrupt', {
@@ -750,7 +755,7 @@ function orphanedRun(): ResearchRun {
turnId: 'orphan-turn',
connectionIds: [account.id],
deckVersion: 0,
message: '調査中',
message: 'Researching',
citations: [],
messages: [{ id: 'original', role: 'user', text: 'WebMCP' }],
}
@@ -763,7 +768,7 @@ it('marks an unfinished persisted turn interrupted after service restart', () =>
expect(service.status().run).toMatchObject({
...interrupted,
status: 'interrupted',
error: expect.stringContaining('再起動'),
error: expect.stringContaining('server restart'),
})
expect(store.loadActive()?.status).toBe('interrupted')
expect(clients).toHaveLength(0)
@@ -119,7 +119,7 @@ it.each([
act(() => {
expect(result.current.navigate(citation.post.url, [citation])).toBe(true)
})
expect(createTemporary).toHaveBeenCalledWith('引用元の投稿', [column])
expect(createTemporary).toHaveBeenCalledWith('Cited posts', [column])
expect(result.current.target?.deckId).toBe('created')
})
@@ -83,7 +83,7 @@ export function useCitationNavigation({
}))
return true
}
const deck = createTemporary('引用元の投稿', [citation.column])
const deck = createTemporary('Cited posts', [citation.column])
setTarget((previous) => ({
...citation,
deckId: deck.id,