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
+2
View File
@@ -23,6 +23,8 @@ bound to a connection account, so platforms and multiple accounts work side by s
- Saved chat history with deck, account and citation restoration across devices - Saved chat history with deck, account and citation restoration across devices
and backend restarts and backend restarts
The system interface is in English; user-entered text and external posts retain their original language.
`/` opens the daily home with a greeting, natural-language action proposals, `/` opens the daily home with a greeting, natural-language action proposals,
flexible tasks/routines, messages, notes, and reading. `/deck` opens Twitter Lite. flexible tasks/routines, messages, notes, and reading. `/deck` opens Twitter Lite.
The shared sidebar also opens The shared sidebar also opens
+6 -6
View File
@@ -1,7 +1,7 @@
# Execution support prototype # Execution support prototype
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside The shared sidebar opens `/support` (Messages) and `/journal` (Journal), alongside
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts Reader and Twitter Lite. These are UI-only prototypes with fictional contacts
and in-memory state. Reloading resets the prototype state. Tasks, communication status, outcomes, and and in-memory state. Reloading resets the prototype state. Tasks, communication status, outcomes, and
journal entries are shared between routes for the current browser session. journal entries are shared between routes for the current browser session.
No messages, calendar events, medical records, or Rensheng pages are written. No messages, calendar events, medical records, or Rensheng pages are written.
@@ -37,21 +37,21 @@ the new communication and journal prototypes do not consume those accounts.
## Daily home ## Daily home
`/` presents a greeting, a large input, and a flexible plan grouped as `/` presents a greeting, a large input, and a flexible plan grouped as
「まずこれ」「いつものこと」「余裕があれば」. There are no fixed time slots. Start here, Routines, and If there’s room. There are no fixed time slots.
Tasks can be reordered, completed, deferred, restored, or followed as routine Tasks can be reordered, completed, deferred, restored, or followed as routine
steps. Communications can be reviewed directly in a home dialog; changes also steps. Communications can be reviewed directly in a home dialog; changes also
appear in `/support`. Journal entries and corrections appear on the home, and appear in `/support`. Journal entries and corrections appear on the home, and
reader samples open for reading. Twitter Lite remains a separate work surface. reader samples open for reading. Twitter Lite remains a separate work surface.
The input currently uses a deterministic Japanese mock interpreter, not an LLM. The input currently uses a deterministic English/Japanese mock interpreter, not an LLM.
It proposes typed actions for task creation/status/reordering, routine steps, It proposes typed actions for task creation/status/reordering, routine steps,
contact reply/review or deferral, notes/weight/medication records, and navigation. contact reply/review or deferral, notes/weight/medication records, and navigation.
Confirming a proposal dispatches the corresponding operation. Unknown or Confirming a proposal dispatches the corresponding operation. Unknown or
ambiguous input does not change state. Message sending still goes through the ambiguous input does not change state. Message sending still goes through the
existing simulated review UI; this prototype performs no external send. existing simulated review UI; this prototype performs no external send.
Examples: 「買い物を追加」「朝の支度をあとで」「青木さんに返信」 Examples: `Add grocery shopping`, `Defer Morning routine`, `Reply to Aoki`,
「メモ:次は資料の2ページ目から」「体重は65.2kg」「リーダーを開く」. `Note: Resume from page 2`, `Weight: 65.2 kg`, and `Open Reader`.
The interaction reference is [Tiimo visual planning](https://www.tiimoapp.com/product/visual-planning), The interaction reference is [Tiimo visual planning](https://www.tiimoapp.com/product/visual-planning),
reviewed on 2026-09-28: combined routines and tasks, flexible reordering, small reviewed on 2026-09-28: combined routines and tasks, flexible reordering, small
+1 -1
View File
@@ -2,7 +2,7 @@
`/vitals` recreates the Body and Environment screenshots attached to `/vitals` recreates the Body and Environment screenshots attached to
[Alan Shiflett’s post](https://x.com/AlanShiflett/status/2103524300158366183). [Alan Shiflett’s post](https://x.com/AlanShiflett/status/2103524300158366183).
The shared sidebar opens it as バイタル. Body categories and history, environment The shared sidebar opens it as Vitals. Body categories and history, environment
metric details, chart period/series selection, and simulated climate controls metric details, chart period/series selection, and simulated climate controls
are interactive. Other screenshot navigation entries open a preview explanation. are interactive. Other screenshot navigation entries open a preview explanation.
+17 -15
View File
@@ -5,21 +5,21 @@ test('opens account management after hydration without inheriting real OAuth con
}) => { }) => {
await page.goto('/deck') await page.goto('/deck')
await expect( await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }), page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled() ).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' }) const manage = page.getByRole('button', { name: 'Manage connected accounts' })
if (!(await manage.isVisible())) { if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click() await page.getByRole('button', { name: 'Toggle sidebar' }).click()
} }
await expect(manage).toBeEnabled() await expect(manage).toBeEnabled()
await manage.click() await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' }) const dialog = page.getByRole('dialog', { name: 'Connected accounts' })
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect( await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'), dialog.getByText('No Mastodon servers have been configured yet.'),
).toBeVisible() ).toBeVisible()
await expect( await expect(
dialog.getByRole('button', { name: 'Mastodonで認可する' }), dialog.getByRole('button', { name: 'Authorize with Mastodon' }),
).toHaveCount(0) ).toHaveCount(0)
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
@@ -32,19 +32,21 @@ test('opens account management from the inbox footer and restores focus on close
}) => { }) => {
await page.goto('/inbox') await page.goto('/inbox')
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const manage = page.getByRole('button', { name: '接続アカウントを管理' }) const manage = page.getByRole('button', { name: 'Manage connected accounts' })
await expect(manage).toBeVisible() await expect(manage).toBeVisible()
await manage.click() await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' }) const dialog = page.getByRole('dialog', { name: 'Connected accounts' })
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect( await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'), dialog.getByText('No Mastodon servers have been configured yet.'),
).toBeVisible() ).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([])
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
await expect(manage).toBeFocused() await expect(manage).toBeFocused()
await expect(page.getByRole('textbox', { name: '記事を検索' })).toBeVisible() await expect(
page.getByRole('textbox', { name: 'Search articles' }),
).toBeVisible()
}) })
test('rejects an unbound OAuth callback without contacting an instance', async ({ test('rejects an unbound OAuth callback without contacting an instance', async ({
@@ -53,16 +55,16 @@ test('rejects an unbound OAuth callback without contacting an instance', async (
await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code') await page.goto('/oauth/mastodon/callback?state=invalid&code=not-a-real-code')
await expect(page).toHaveURL(/\/deck\?mastodon=failed$/) await expect(page).toHaveURL(/\/deck\?mastodon=failed$/)
await expect(page.getByRole('alert')).toContainText( await expect(page.getByRole('alert')).toContainText(
'Mastodonの認可を完了できませんでした。', 'Could not complete Mastodon authorization.',
) )
await expect( await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }), page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled() ).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' }) const manage = page.getByRole('button', { name: 'Manage connected accounts' })
if (!(await manage.isVisible())) { if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click() await page.getByRole('button', { name: 'Toggle sidebar' }).click()
} }
await expect( await expect(
page.getByRole('button', { name: '接続アカウントを管理' }), page.getByRole('button', { name: 'Manage connected accounts' }),
).toBeEnabled() ).toBeEnabled()
}) })
+76 -76
View File
@@ -10,26 +10,26 @@ async function addColumn(
kind = 'search', kind = 'search',
) { ) {
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'Add column', exact: true }).click()
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true }) const editor = page.getByRole('form', { name: 'Add column', exact: true })
await editor.getByLabel('カラム名', { exact: true }).fill(title) await editor.getByLabel('Column name', { exact: true }).fill(title)
await editor await editor
.getByRole('combobox', { name: '接続プロファイル', exact: true }) .getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: profile }) .selectOption({ label: profile })
await editor await editor
.getByRole('combobox', { name: 'カラムの種類', exact: true }) .getByRole('combobox', { name: 'Column type', exact: true })
.selectOption(kind) .selectOption(kind)
await editor await editor
.getByLabel( .getByLabel(
kind === 'search' kind === 'search'
? 'Twitterの検索条件' ? 'Twitter search query'
: kind === 'user' : kind === 'user'
? 'ユーザー' ? 'User'
: 'リスト', : 'List',
{ exact: true }, { exact: true },
) )
.fill(query) .fill(query)
await editor.getByRole('button', { name: '追加して検索' }).click() await editor.getByRole('button', { name: 'Add and search' }).click()
await closeMobileSidebar(page) await closeMobileSidebar(page)
await expect( await expect(
page page
@@ -42,13 +42,13 @@ async function addColumn(
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/deck') await page.goto('/deck')
await expect( await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }), page.getByRole('button', { name: 'Save as deck', exact: true }),
).toBeEnabled() ).toBeEnabled()
await page await page.getByRole('button', { name: 'Save as deck', exact: true }).click()
.getByRole('button', { name: 'デッキとして保存', exact: true })
.click()
await showDeckManagement(page) await showDeckManagement(page)
await expect(page.getByText('サーバーに保存・端末間で共有')).toBeVisible() await expect(
page.getByText('Saved to server · Shared across devices'),
).toBeVisible()
await closeMobileSidebar(page) await closeMobileSidebar(page)
}) })
@@ -65,19 +65,19 @@ test('binds identical searches to independent profiles through paging, editing a
await expect( await expect(
second.getByText('alternate · Latest · all page 1', { exact: true }), second.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible() ).toBeVisible()
await first.getByRole('button', { name: '続きを読み込む' }).click() await first.getByRole('button', { name: 'Load more' }).click()
await expect(first.locator('article')).toHaveCount(2) await expect(first.locator('article')).toHaveCount(2)
await expect(second.locator('article')).toHaveCount(1) await expect(second.locator('article')).toHaveCount(1)
await second.getByRole('button', { name: '続きを読み込む' }).click() await second.getByRole('button', { name: 'Load more' }).click()
await expect( await expect(
second.getByText('alternate · Latest · all page 2'), second.getByText('alternate · Latest · all page 2'),
).toBeVisible() ).toBeVisible()
await second.getByRole('button', { name: '別の接続の操作' }).click() await second.getByRole('button', { name: 'Actions for 別の接続' }).click()
await page.getByRole('menuitem', { name: '編集', exact: true }).click() await page.getByRole('menuitem', { name: 'Edit', exact: true }).click()
await page await page
.getByRole('combobox', { name: '接続プロファイル', exact: true }) .getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'e2e' }) .selectOption({ label: 'e2e' })
await page.getByRole('button', { name: '変更を保存' }).click() await page.getByRole('button', { name: 'Save changes' }).click()
await expect( await expect(
second.getByText('Latest · all page 1', { exact: true }), second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible() ).toBeVisible()
@@ -94,19 +94,19 @@ test('binds identical searches to independent profiles through paging, editing a
test('creates, switches, renames and deletes persisted deck profiles', async ({ test('creates, switches, renames and deletes persisted deck profiles', async ({
page, page,
}) => { }) => {
await page.getByRole('button', { name: '名前を変更' }).click() await page.getByRole('button', { name: 'Rename' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査') await page.getByLabel('Research topic / deck name').fill('最初の調査')
await page.getByRole('button', { name: '名前を保存' }).click() await page.getByRole('button', { name: 'Save name' }).click()
await addColumn(page, '最初の観点', 'first-deck') await addColumn(page, '最初の観点', 'first-deck')
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click() await page.getByRole('button', { name: 'Create deck', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('別の調査') await page.getByLabel('New deck name').fill('別の調査')
await page.getByRole('button', { name: '作成', exact: true }).click() await page.getByRole('button', { name: 'Create', exact: true }).click()
await expect(page.locator('.deck-column')).toHaveCount(0) await expect(page.locator('.deck-column')).toHaveCount(0)
await addColumn(page, '別の観点', 'second-deck', 'alternate') await addColumn(page, '別の観点', 'second-deck', 'alternate')
await showDeckManagement(page) await showDeckManagement(page)
await page await page
.getByLabel('デッキプロファイル', { exact: true }) .getByLabel('Deck profile', { exact: true })
.selectOption({ label: '最初の調査' }) .selectOption({ label: '最初の調査' })
await closeMobileSidebar(page) await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
@@ -114,18 +114,18 @@ test('creates, switches, renames and deletes persisted deck profiles', async ({
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page) await showDeckManagement(page)
await page await page
.getByLabel('デッキプロファイル', { exact: true }) .getByLabel('Deck profile', { exact: true })
.selectOption({ label: '別の調査' }) .selectOption({ label: '別の調査' })
await closeMobileSidebar(page) await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点']) await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click() await page.getByRole('button', { name: 'Delete deck', exact: true }).click()
await page.getByRole('button', { name: '削除する', exact: true }).click() await page.getByRole('button', { name: 'Delete', exact: true }).click()
await page.reload() await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page) await showDeckManagement(page)
await expect( await expect(
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'), page.getByLabel('Deck profile', { exact: true }).locator('option'),
).toHaveCount(1) ).toHaveCount(1)
}) })
@@ -138,17 +138,17 @@ test('reads user and list timelines within columns and reorders, deletes and res
const list = page.getByRole('region', { name: 'リストの投稿', exact: true }) const list = page.getByRole('region', { name: 'リストの投稿', exact: true })
await expect(user.getByText('alternate profile page 1')).toBeVisible() await expect(user.getByText('alternate profile page 1')).toBeVisible()
await expect(list.getByText('list page 1')).toBeVisible() await expect(list.getByText('list page 1')).toBeVisible()
await list.getByRole('button', { name: '続きを読み込む' }).click() await list.getByRole('button', { name: 'Load more' }).click()
await expect(list.getByText('list page 2')).toBeVisible() await expect(list.getByText('list page 2')).toBeVisible()
await list.getByRole('button', { name: 'リストの投稿の操作' }).click() await list.getByRole('button', { name: 'Actions for リストの投稿' }).click()
await page.getByRole('menuitem', { name: 'リストの投稿を左へ' }).click() await page.getByRole('menuitem', { name: 'Move リストの投稿 left' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([ await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿', 'リストの投稿',
'アカウント', 'アカウント',
]) ])
await list.getByRole('button', { name: 'リストの投稿の操作' }).click() await list.getByRole('button', { name: 'Actions for リストの投稿' }).click()
await page.getByRole('menuitem', { name: 'リストの投稿を削除' }).click() await page.getByRole('menuitem', { name: 'Delete リストの投稿' }).click()
await page.getByRole('button', { name: '元に戻す' }).click() await page.getByRole('button', { name: 'Undo' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([ await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿', 'リストの投稿',
'アカウント', 'アカウント',
@@ -165,12 +165,12 @@ test('keeps other columns usable when pagination fails and retries that column',
await addColumn(page, '別の検索', 'independent-deck') await addColumn(page, '別の検索', 'independent-deck')
const failed = page.getByRole('region', { name: '失敗する検索', exact: true }) const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
const other = page.getByRole('region', { name: '別の検索', exact: true }) const other = page.getByRole('region', { name: '別の検索', exact: true })
await failed.getByRole('button', { name: '続きを読み込む' }).click() await failed.getByRole('button', { name: 'Load more' }).click()
await expect(failed.getByRole('alert')).toBeVisible() await expect(failed.getByRole('alert')).toBeVisible()
await expect(failed.locator('article')).toHaveCount(1) await expect(failed.locator('article')).toHaveCount(1)
await other.getByRole('button', { name: '続きを読み込む' }).click() await other.getByRole('button', { name: 'Load more' }).click()
await expect(other.locator('article')).toHaveCount(2) await expect(other.locator('article')).toHaveCount(2)
await failed.getByRole('button', { name: '再試行' }).click() await failed.getByRole('button', { name: 'Retry' }).click()
await expect(failed.locator('article')).toHaveCount(2) await expect(failed.locator('article')).toHaveCount(2)
}) })
@@ -201,9 +201,7 @@ test('keeps the deck and editor accessible within narrow viewports', async ({
width: testInfo.project.name === 'desktop' ? 1440 : 393, width: testInfo.project.name === 'desktop' ? 1440 : 393,
height: 900, height: 900,
}) })
await expect( await expect(page.getByRole('navigation', { name: 'Columns' })).toHaveCount(0)
page.getByRole('navigation', { name: 'カラム一覧' }),
).toHaveCount(0)
await expect( await expect(
page.getByRole('heading', { name: '公式アカウント', exact: true }), page.getByRole('heading', { name: '公式アカウント', exact: true }),
).toBeVisible() ).toBeVisible()
@@ -213,7 +211,7 @@ test('keeps the deck and editor accessible within narrow viewports', async ({
fullPage: true, fullPage: true,
}) })
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'Add column', exact: true }).click()
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({ await page.screenshot({
path: testInfo.outputPath('deck.png'), path: testInfo.outputPath('deck.png'),
@@ -225,20 +223,22 @@ test('closes the column editor with Escape and restores focus without applying e
page, page,
}) => { }) => {
await showDeckManagement(page) await showDeckManagement(page)
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true }) const opener = page.getByRole('button', { name: 'Add column', exact: true })
await opener.click() await opener.click()
await expect( await expect(
page.getByRole('dialog', { name: 'カラムを追加', exact: true }), page.getByRole('dialog', { name: 'Add column', exact: true }),
).toBeVisible() ).toBeVisible()
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点') await page
.getByLabel('Column name', { exact: true })
.fill('キャンセルする観点')
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect( await expect(
page.getByRole('dialog', { name: 'カラムを追加', exact: true }), page.getByRole('dialog', { name: 'Add column', exact: true }),
).toHaveCount(0) ).toHaveCount(0)
await expect(opener).toBeFocused() await expect(opener).toBeFocused()
await expect(page.locator('.deck-column')).toHaveCount(0) await expect(page.locator('.deck-column')).toHaveCount(0)
await opener.click() await opener.click()
await expect(page.getByLabel('カラム名', { exact: true })).toHaveValue('') await expect(page.getByLabel('Column name', { exact: true })).toHaveValue('')
}) })
test('does not overwrite unreadable storage on reload', async ({ page }) => { test('does not overwrite unreadable storage on reload', async ({ page }) => {
@@ -246,7 +246,7 @@ test('does not overwrite unreadable storage on reload', async ({ page }) => {
localStorage.setItem('twitter-lite-research-deck', '{broken'), localStorage.setItem('twitter-lite-research-deck', '{broken'),
) )
await page.reload() await page.reload()
await expect(page.getByRole('alert')).toContainText('読み込めませんでした') await expect(page.getByRole('alert')).toContainText('Unable to load')
expect( expect(
await page.evaluate(() => await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'), localStorage.getItem('twitter-lite-research-deck'),
@@ -258,19 +258,19 @@ test('selects a list from the column profile catalog and refreshes it on profile
page, page,
}) => { }) => {
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'Add column', exact: true }).click()
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト') await page.getByLabel('Column name', { exact: true }).fill('購読リスト')
await page await page
.getByRole('combobox', { name: '接続プロファイル', exact: true }) .getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'e2e' }) .selectOption({ label: 'e2e' })
await page await page
.getByRole('combobox', { name: 'カラムの種類', exact: true }) .getByRole('combobox', { name: 'Column type', exact: true })
.selectOption('list') .selectOption('list')
await expect( await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }), page.getByRole('button', { name: 'My E2E List', exact: true }),
).toBeVisible() ).toBeVisible()
await page await page
.getByRole('combobox', { name: '接続プロファイル', exact: true }) .getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'alternate' }) .selectOption({ label: 'alternate' })
await expect( await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }), page.getByRole('button', { name: 'My E2E List', exact: true }),
@@ -278,10 +278,10 @@ test('selects a list from the column profile catalog and refreshes it on profile
await page await page
.getByRole('button', { name: 'Alternate List', exact: true }) .getByRole('button', { name: 'Alternate List', exact: true })
.click() .click()
await expect(page.getByLabel('リスト', { exact: true })).toHaveValue( await expect(page.getByLabel('List', { exact: true })).toHaveValue(
'1234567890', '1234567890',
) )
await page.getByRole('button', { name: '追加して検索' }).click() await page.getByRole('button', { name: 'Add and search' }).click()
await closeMobileSidebar(page) await closeMobileSidebar(page)
await expect( await expect(
page page
@@ -302,27 +302,27 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
try { try {
const device = await other.newPage() const device = await other.newPage()
await device.goto('/deck') await device.goto('/deck')
await page.getByRole('button', { name: '名前を変更' }).click() await page.getByRole('button', { name: 'Rename' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前') await page.getByLabel('Research topic / deck name').fill('編集中の名前')
await device.getByRole('button', { name: '名前を変更' }).click() await device.getByRole('button', { name: 'Rename' }).click()
await device.getByLabel('調査テーマ・デッキ名').fill('別の端末の変更') await device.getByLabel('Research topic / deck name').fill('別の端末の変更')
await device.getByRole('button', { name: '名前を保存' }).click() await device.getByRole('button', { name: 'Save name' }).click()
await expect(device.getByRole('heading', { level: 1 })).toHaveText( await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更', '別の端末の変更',
) )
await page.evaluate(() => window.dispatchEvent(new Event('focus'))) await page.evaluate(() => window.dispatchEvent(new Event('focus')))
await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue( await expect(page.getByLabel('Research topic / deck name')).toHaveValue(
'編集中の名前', '編集中の名前',
) )
await page.getByRole('button', { name: '名前を保存' }).click() await page.getByRole('button', { name: 'Save name' }).click()
await expect(page.getByRole('alert')).toContainText( await expect(page.getByRole('alert')).toContainText(
'別の端末で変更されました', 'This deck changed on another device',
) )
await expect(page.getByLabel('調査テーマ・デッキ名')).toHaveValue( await expect(page.getByLabel('Research topic / deck name')).toHaveValue(
'編集中の名前', '編集中の名前',
) )
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await page.getByRole('button', { name: '最新のデッキを再読み込み' }).click() await page.getByRole('button', { name: 'Reload latest deck' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更', '別の端末の変更',
) )
@@ -368,10 +368,10 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
) )
await page.reload() await page.reload()
await showDeckManagement(page) await showDeckManagement(page)
const selector = page.getByLabel('デッキプロファイル', { exact: true }) const selector = page.getByLabel('Deck profile', { exact: true })
await expect(selector.locator('option')).toHaveCount(1) await expect(selector.locator('option')).toHaveCount(1)
await closeMobileSidebar(page) await closeMobileSidebar(page)
await page.getByRole('button', { name: '旧デッキを取り込む' }).click() await page.getByRole('button', { name: 'Import legacy decks' }).click()
await showDeckManagement(page) await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2) await expect(selector.locator('option')).toHaveCount(2)
await selector.selectOption({ label: '以前の調査' }) await selector.selectOption({ label: '以前の調査' })
@@ -388,9 +388,9 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
legacy, legacy,
) )
await page.reload() await page.reload()
await page.getByRole('button', { name: '旧デッキを取り込む' }).click() await page.getByRole('button', { name: 'Import legacy decks' }).click()
await expect( await expect(
page.getByRole('button', { name: '旧デッキを取り込む' }), page.getByRole('button', { name: 'Import legacy decks' }),
).toHaveCount(0) ).toHaveCount(0)
await showDeckManagement(page) await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2) await expect(selector.locator('option')).toHaveCount(2)
@@ -400,19 +400,19 @@ test('preserves a new deck dialog and its draft across the mobile breakpoint', a
page, page,
}) => { }) => {
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click() await page.getByRole('button', { name: 'Create deck', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'デッキを作成', exact: true }) const dialog = page.getByRole('dialog', { name: 'Create deck', exact: true })
await dialog.getByLabel('新しいデッキ名').fill('画面を回転しても残る調査') await dialog.getByLabel('New deck name').fill('画面を回転しても残る調査')
for (const width of [1024, 375, 1024]) { for (const width of [1024, 375, 1024]) {
await page.setViewportSize({ width, height: 900 }) await page.setViewportSize({ width, height: 900 })
await expect(dialog).toHaveCount(1) await expect(dialog).toHaveCount(1)
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('新しいデッキ名')).toHaveValue( await expect(dialog.getByLabel('New deck name')).toHaveValue(
'画面を回転しても残る調査', '画面を回転しても残る調査',
) )
await expect(page.locator('#new-deck-title')).toHaveCount(1) await expect(page.locator('#new-deck-title')).toHaveCount(1)
} }
await dialog.getByRole('button', { name: '作成', exact: true }).click() await dialog.getByRole('button', { name: 'Create', exact: true }).click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'画面を回転しても残る調査', '画面を回転しても残る調査',
+53 -48
View File
@@ -6,21 +6,17 @@ test('previews a natural-language memo and shares the executed record with journ
await page.goto('/') await page.goto('/')
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const memo = '明日のレビューは導線から確認する' const memo = '明日のレビューは導線から確認する'
await page await page.getByRole('textbox', { name: 'Home input' }).fill(`Note: ${memo}`)
.getByRole('textbox', { name: 'ホームへの入力' }) await page.getByRole('button', { name: 'Preview action' }).click()
.fill(`メモ: ${memo}`) const execute = page.getByRole('button', { name: 'Apply', exact: true })
await page.getByRole('button', { name: '候補を表示' }).click()
const execute = page.getByRole('button', { name: '実行する', exact: true })
await expect(execute).toBeVisible() await expect(execute).toBeVisible()
await execute.click() await execute.click()
await page.getByRole('link', { name: '記録', exact: true }).first().click() await page.getByRole('link', { name: 'Journal', exact: true }).first().click()
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText( await expect(page.getByRole('article', { name: 'Note entry' })).toContainText(
memo, memo,
) )
await page.getByRole('link', { name: 'ホーム', exact: true }).first().click() await page.getByRole('link', { name: 'Home', exact: true }).first().click()
await expect( await expect(page.getByRole('textbox', { name: 'Home input' })).toBeVisible()
page.getByRole('textbox', { name: 'ホームへの入力' }),
).toBeVisible()
}) })
test('keeps the home usable on narrow screens', async ({ test('keeps the home usable on narrow screens', async ({
@@ -32,7 +28,7 @@ test('keeps the home usable on narrow screens', async ({
for (const width of [320, 375, 768]) { for (const width of [320, 375, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 })
await expect( await expect(
page.getByRole('textbox', { name: 'ホームへの入力' }), page.getByRole('textbox', { name: 'Home input' }),
).toBeVisible() ).toBeVisible()
expect( expect(
await page.evaluate(() => document.documentElement.scrollWidth), await page.evaluate(() => document.documentElement.scrollWidth),
@@ -47,28 +43,30 @@ test('adds tasks only after confirmation and can defer and restore them', async
}) => { }) => {
await page.goto('/') await page.goto('/')
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const input = page.getByRole('textbox', { name: 'ホームへの入力' }) const input = page.getByRole('textbox', { name: 'Home input' })
const complete = page.getByRole('button', { const complete = page.getByRole('button', {
name: '買い物を完了', name: 'Complete grocery shopping',
exact: true, exact: true,
}) })
await input.fill('買い物を追加') await input.fill('Add grocery shopping')
await page.getByRole('button', { name: '候補を表示' }).click() await page.getByRole('button', { name: 'Preview action' }).click()
await expect(complete).toHaveCount(0) await expect(complete).toHaveCount(0)
await page.getByRole('button', { name: '実行する', exact: true }).click() await page.getByRole('button', { name: 'Apply', exact: true }).click()
await expect(complete).toBeVisible() await expect(complete).toBeVisible()
await input.fill('買い物をあとで') await input.fill('Defer grocery shopping')
await page.getByRole('button', { name: '候補を表示' }).click() await page.getByRole('button', { name: 'Preview action' }).click()
await expect(complete).toBeVisible() await expect(complete).toBeVisible()
await page.getByRole('button', { name: '実行する', exact: true }).click() await page.getByRole('button', { name: 'Apply', exact: true }).click()
await expect(complete).toHaveCount(0) await expect(complete).toHaveCount(0)
await page.locator('.home-rest summary').click() await page.locator('.home-rest summary').click()
await page.getByRole('button', { name: '買い物を戻す', exact: true }).click() await page
.getByRole('button', { name: 'Restore grocery shopping', exact: true })
.click()
await complete.click() await complete.click()
await expect(complete).toHaveCount(0) await expect(complete).toHaveCount(0)
await page.locator('.home-rest summary').click() await page.locator('.home-rest summary').click()
await expect( await expect(
page.getByRole('button', { name: '買い物を戻す', exact: true }), page.getByRole('button', { name: 'Restore grocery shopping', exact: true }),
).toBeVisible() ).toBeVisible()
}) })
@@ -78,30 +76,37 @@ test('resolves communication within home and carries the status to the shared qu
}) => { }) => {
await page.goto('/') await page.goto('/')
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: '青木 遥との会話を開く' }).click() await page
const dialog = page.getByRole('dialog', { name: '青木 遥との連絡' }) .getByRole('button', { name: 'Open conversation with Haruka Aoki' })
.click()
const dialog = page.getByRole('dialog', {
name: 'Conversation with Haruka Aoki',
})
await expect( await expect(
dialog.getByRole('textbox', { name: '返信', exact: true }), dialog.getByRole('textbox', { name: 'Reply', exact: true }),
).toBeVisible() ).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([])
await dialog.getByRole('button', { name: '今は置いておく' }).click() await dialog.getByRole('button', { name: 'Leave for later' }).click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
await expect( await expect(
page.getByRole('button', { name: '青木 遥との会話を開く' }), page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }),
).toHaveCount(0) ).toHaveCount(0)
await page.getByRole('link', { name: '連絡', exact: true }).first().click() await page
.getByRole('link', { name: 'Messages', exact: true })
.first()
.click()
const rows = page.locator('.support-row') const rows = page.locator('.support-row')
await expect( await expect(
rows.filter({ hasText: '来週のレビュー、日程を決める' }), rows.filter({ hasText: 'Schedule next week’s design review' }),
).toHaveCount(0) ).toHaveCount(0)
await page.getByRole('button', { name: 'あとで', exact: true }).click() await page.getByRole('button', { name: 'Later', exact: true }).click()
await rows.filter({ hasText: '来週のレビュー、日程を決める' }).click() await rows.filter({ hasText: 'Schedule next week’s design review' }).click()
await page.getByRole('button', { name: '今に戻す' }).click() await page.getByRole('button', { name: 'Move to Now' }).click()
const back = page.getByRole('button', { name: '連絡一覧に戻る' }) const back = page.getByRole('button', { name: 'Back to messages' })
if (await back.isVisible()) await back.click() if (await back.isVisible()) await back.click()
await page.getByRole('link', { name: 'ホーム', exact: true }).first().click() await page.getByRole('link', { name: 'Home', exact: true }).first().click()
await expect( await expect(
page.getByRole('button', { name: '青木 遥との会話を開く' }), page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }),
).toBeVisible() ).toBeVisible()
}) })
@@ -110,25 +115,25 @@ test('updates the prose brief as priorities change and opens the referenced conv
}) => { }) => {
await page.goto('/') await page.goto('/')
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const brief = page.getByRole('region', { name: '今日のブリーフ' }) const brief = page.getByRole('region', { name: 'Daily brief' })
await expect(brief).toContainText('レビュー資料を確認する') await expect(brief).toContainText('Review the documents')
await expect(brief).toContainText('4件') await expect(brief).toContainText('4 conversations')
await page await page
.getByRole('button', { name: 'レビュー資料を確認するを完了', exact: true }) .getByRole('button', { name: 'Complete Review the documents', exact: true })
.click() .click()
await expect(brief).not.toContainText('レビュー資料を確認する') await expect(brief).not.toContainText('Review the documents')
await brief.getByRole('button', { name: '青木 遥さんへの返信' }).click() await brief.getByRole('button', { name: 'Reply to Haruka Aoki' }).click()
const dialog = page.getByRole('dialog', { const dialog = page.getByRole('dialog', {
name: '青木 遥との連絡', name: 'Conversation with Haruka Aoki',
exact: true, exact: true,
}) })
await dialog.getByRole('button', { name: '今は置いておく' }).click() await dialog.getByRole('button', { name: 'Leave for later' }).click()
await expect(brief).toContainText('3件') await expect(brief).toContainText('3 conversations')
await expect(brief).not.toContainText('青木 遥') await expect(brief).not.toContainText('Haruka Aoki')
await page await page
.getByRole('textbox', { name: 'ホームへの入力' }) .getByRole('textbox', { name: 'Home input' })
.fill('再開地点:資料の2ページ目から') .fill('再開地点:資料の2ページ目から')
await page.getByRole('button', { name: '候補を表示' }).click() await page.getByRole('button', { name: 'Preview action' }).click()
await page.getByRole('button', { name: '実行する', exact: true }).click() await page.getByRole('button', { name: 'Apply', exact: true }).click()
await expect(brief).toContainText('資料の2ページ目から') await expect(brief).toContainText('資料の2ページ目から')
}) })
+19 -19
View File
@@ -7,7 +7,7 @@ test('selects articles and keeps the reader focused on their content', async ({
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row') const rows = page.locator('.inbox-article-row')
await expect(rows).toHaveCount(9) await expect(rows).toHaveCount(9)
const articleTitle = 'エージェントに渡す文脈は、短くてもよい' const articleTitle = 'Context for an agent can be brief'
await rows.filter({ hasText: articleTitle }).click() await rows.filter({ hasText: articleTitle }).click()
const reader = page.getByRole('article', { name: articleTitle, exact: true }) const reader = page.getByRole('article', { name: articleTitle, exact: true })
await expect(reader).toBeVisible() await expect(reader).toBeVisible()
@@ -16,26 +16,26 @@ test('selects articles and keeps the reader focused on their content', async ({
).toBeVisible() ).toBeVisible()
await expect( await expect(
reader.getByText( reader.getByText(
'まず目的を一文で書き、次に変えてはいけない条件を添える。', 'Start with a one-sentence goal, then add the constraints that must hold.',
{ exact: false }, { exact: false },
), ),
).toBeVisible() ).toBeVisible()
await expect(page.getByText('FOR YOU', { exact: true })).toHaveCount(0) await expect(page.getByText('FOR YOU', { exact: true })).toHaveCount(0)
await expect(page.getByText('モック', { exact: true })).toHaveCount(0) await expect(page.getByText('Mock', { exact: true })).toHaveCount(0)
await expect(page.getByText('Why this article', { exact: true })).toHaveCount(
0,
)
await expect( await expect(
page.getByText('この記事が届いた理由', { exact: true }), page.getByRole('button', { name: /For you|Read later|Unread only/ }),
).toHaveCount(0) ).toHaveCount(0)
await expect( const back = page.getByRole('button', { name: 'Back to articles' })
page.getByRole('button', { name: /あなたへ|あとで読む|未読のみ/ }),
).toHaveCount(0)
const back = page.getByRole('button', { name: '記事一覧に戻る' })
if (await back.isVisible()) await back.click() if (await back.isVisible()) await back.click()
await rows await rows
.filter({ hasText: '3カラムの余白が、情報を読むリズムをつくる' }) .filter({ hasText: 'Three columns create a rhythm for reading' })
.click() .click()
await expect( await expect(
page.getByRole('article', { page.getByRole('article', {
name: '3カラムの余白が、情報を読むリズムをつくる', name: 'Three columns create a rhythm for reading',
exact: true, exact: true,
}), }),
).toBeVisible() ).toBeVisible()
@@ -49,21 +49,21 @@ test('recovers from empty search and shares navigation with Twitter Lite', async
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row') const rows = page.locator('.inbox-article-row')
await page await page
.getByRole('textbox', { name: '記事を検索' }) .getByRole('textbox', { name: 'Search articles' })
.fill('存在しない検索語xyz') .fill('nonexistent-query-xyz')
await expect(rows).toHaveCount(0) await expect(rows).toHaveCount(0)
await expect( await expect(
page.getByText('記事がありません', { exact: true }), page.getByText('No articles found', { exact: true }),
).toBeVisible() ).toBeVisible()
await page.getByRole('button', { name: '検索をクリア' }).click() await page.getByRole('button', { name: 'Clear search' }).click()
await expect(rows).toHaveCount(9) await expect(rows).toHaveCount(9)
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([])
await expect( await expect(
page.getByRole('button', { name: '興味を編集', exact: true }), page.getByRole('button', { name: 'Edit interests', exact: true }),
).toHaveCount(0) ).toHaveCount(0)
await page.getByRole('link', { name: 'Twitter Lite', exact: true }).click() await page.getByRole('link', { name: 'Twitter Lite', exact: true }).click()
await expect(page).toHaveURL(/\/deck$/) await expect(page).toHaveURL(/\/deck$/)
await page.getByRole('link', { name: 'リーダー', exact: true }).click() await page.getByRole('link', { name: 'Reader', exact: true }).click()
await expect(page).toHaveURL(/\/inbox$/) await expect(page).toHaveURL(/\/inbox$/)
await expect(rows).toHaveCount(9) await expect(rows).toHaveCount(9)
}) })
@@ -77,14 +77,14 @@ test('keeps article navigation readable without horizontal overflow', async ({
for (const width of [320, 375, 414, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 })
await expect( await expect(
page.getByRole('textbox', { name: '記事を検索' }), page.getByRole('textbox', { name: 'Search articles' }),
).toBeVisible() ).toBeVisible()
expect( expect(
await page.evaluate(() => document.documentElement.scrollWidth), await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width) ).toBe(width)
await page.locator('.inbox-article-row').first().click() await page.locator('.inbox-article-row').first().click()
const reader = page.getByRole('article', { const reader = page.getByRole('article', {
name: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方', name: 'Thinking in the browser: a new workflow with AI agents',
exact: true, exact: true,
}) })
await expect(reader).toBeVisible() await expect(reader).toBeVisible()
@@ -95,7 +95,7 @@ test('keeps article navigation readable without horizontal overflow', async ({
path: testInfo.outputPath(`inbox-reader-${width}.png`), path: testInfo.outputPath(`inbox-reader-${width}.png`),
}) })
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: '記事一覧に戻る' }) const back = page.getByRole('button', { name: 'Back to articles' })
if (await back.isVisible()) await back.click() if (await back.isVisible()) await back.click()
} }
}) })
+28 -28
View File
@@ -43,9 +43,9 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
}) => { }) => {
await page.goto('/deck') await page.goto('/deck')
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'Add column', exact: true }).click()
const accounts = page.getByRole('combobox', { const accounts = page.getByRole('combobox', {
name: '接続プロファイル', name: 'Connection profile',
exact: true, exact: true,
}) })
await accounts.selectOption({ label: 'e2e' }) await accounts.selectOption({ label: 'e2e' })
@@ -98,7 +98,7 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
) )
await page.reload() await page.reload()
await showResearch(page) await showResearch(page)
const conversation = page.getByRole('log', { name: '調査の会話' }) const conversation = page.getByRole('log', { name: 'Research conversation' })
await expect(conversation).toContainText('既存の観点を使って調査して') await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('list_decks: 完了') await expect(conversation).toContainText('list_decks: 完了')
await expect(conversation).toContainText('既存の検索条件を再利用しています。') await expect(conversation).toContainText('既存の検索条件を再利用しています。')
@@ -135,11 +135,11 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
]) ])
await closeMobileSidebar(page) await closeMobileSidebar(page)
await expect( await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }), page.getByRole('button', { name: 'Save as deck', exact: true }),
).toBeEnabled() ).toBeEnabled()
await showResearch(page) await showResearch(page)
const accountPicker = page.getByRole('button', { const accountPicker = page.getByRole('button', {
name: '接続アカウント · 1件', name: 'Connected accounts · 1',
}) })
await accountPicker.click() await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'true') await expect(accountPicker).toHaveAttribute('aria-expanded', 'true')
@@ -148,13 +148,11 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
).toBeChecked() ).toBeChecked()
await accountPicker.click() await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'false') await expect(accountPicker).toHaveAttribute('aria-expanded', 'false')
await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく') await page.getByLabel('Message', { exact: true }).fill('この観点を詳しく')
await expect( await expect(
page.getByRole('button', { name: '送信', exact: true }), page.getByRole('button', { name: 'Send', exact: true }),
).toBeEnabled() ).toBeEnabled()
await page await page.getByRole('button', { name: 'Close sidebar', exact: true }).click()
.getByRole('button', { name: 'サイドバーを閉じる', exact: true })
.click()
run.deckVersion = 3 run.deckVersion = 3
if (run.deck) run.deck.title = '閉じている間に更新された調査' if (run.deck) run.deck.title = '閉じている間に更新された調査'
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole('heading', { level: 1 })).toHaveText(
@@ -162,7 +160,7 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
{ timeout: 15000 }, { timeout: 15000 },
) )
await showResearch(page) await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue( await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく', 'この観点を詳しく',
) )
await page.setViewportSize({ width: 600, height: 900 }) await page.setViewportSize({ width: 600, height: 900 })
@@ -170,20 +168,20 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(1) ).toHaveCount(1)
await showResearch(page) await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue( await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく', 'この観点を詳しく',
) )
await page.getByRole('button', { name: '接続アカウント · 1件' }).click() await page.getByRole('button', { name: 'Connected accounts · 1' }).click()
await page.getByRole('checkbox', { name: 'e2e · Twitter' }).uncheck() await page.getByRole('checkbox', { name: 'e2e · Twitter' }).uncheck()
await page.setViewportSize({ width: 1100, height: 900 }) await page.setViewportSize({ width: 1100, height: 900 })
await expect( await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(0) ).toHaveCount(0)
await showResearch(page) await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue( await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく', 'この観点を詳しく',
) )
await page.getByRole('button', { name: '接続アカウント · 0件' }).click() await page.getByRole('button', { name: 'Connected accounts · 0' }).click()
await expect( await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }), page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).not.toBeChecked() ).not.toBeChecked()
@@ -192,10 +190,10 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(1) ).toHaveCount(1)
await showResearch(page) await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue( await expect(page.getByLabel('Message', { exact: true })).toHaveValue(
'この観点を詳しく', 'この観点を詳しく',
) )
await page.getByRole('button', { name: '接続アカウント · 0件' }).click() await page.getByRole('button', { name: 'Connected accounts · 0' }).click()
await expect( await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }), page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).not.toBeChecked() ).not.toBeChecked()
@@ -217,9 +215,9 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/deck') await page.goto('/deck')
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'Add column', exact: true }).click()
const accounts = page.getByRole('combobox', { const accounts = page.getByRole('combobox', {
name: '接続プロファイル', name: 'Connection profile',
exact: true, exact: true,
}) })
await accounts.selectOption({ label: 'e2e' }) await accounts.selectOption({ label: 'e2e' })
@@ -288,7 +286,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
) )
await page.reload() await page.reload()
await showResearch(page) await showResearch(page)
const chat = page.getByRole('log', { name: '調査の会話' }) const chat = page.getByRole('log', { name: 'Research conversation' })
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible() await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible()
await expect(chat.getByRole('heading', { name: '調査結果' })).toHaveCSS( await expect(chat.getByRole('heading', { name: '調査結果' })).toHaveCSS(
'font-weight', 'font-weight',
@@ -309,7 +307,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
await expect(card).toBeVisible() await expect(card).toBeVisible()
await expect(card).toHaveClass(/citation-highlight/) await expect(card).toHaveClass(/citation-highlight/)
await expect(card).toBeFocused() await expect(card).toBeFocused()
await expect(card).toContainText('調査時に取得した投稿') await expect(card).toContainText('Post saved during research')
await expect(card).toContainText('AIが調査時に取得した引用本文') await expect(card).toContainText('AIが調査時に取得した引用本文')
expect( expect(
await page.locator('.deck-board').evaluate((element) => element.scrollLeft), await page.locator('.deck-board').evaluate((element) => element.scrollLeft),
@@ -327,23 +325,25 @@ test('explains unconfigured Codex and prevents starting research without affecti
await expect(heading).toBeVisible() await expect(heading).toBeVisible()
const originalTitle = await heading.textContent() const originalTitle = await heading.textContent()
await showResearch(page) await showResearch(page)
const panel = page.getByRole('complementary', { name: '調査チャット' }) const panel = page.getByRole('complementary', { name: 'Research chat' })
await expect(panel).toBeVisible() await expect(panel).toBeVisible()
await expect( await expect(
panel.getByText('自宅のCodexへの接続が設定されていません。'), panel.getByText(
'The connection to Codex on your home server is not configured.',
),
).toBeVisible() ).toBeVisible()
await expect( await expect(
panel.getByRole('button', { name: '送信', exact: true }), panel.getByRole('button', { name: 'Send', exact: true }),
).toBeDisabled() ).toBeDisabled()
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled() await expect(panel.getByLabel('Message', { exact: true })).toBeDisabled()
const accessibility = await a11y().analyze() const accessibility = await a11y().analyze()
expect(accessibility.violations).toEqual([]) expect(accessibility.violations).toEqual([])
await closeMobileSidebar(page) await closeMobileSidebar(page)
await expect(heading).toHaveText(originalTitle ?? '') await expect(heading).toHaveText(originalTitle ?? '')
await showDeckManagement(page) await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click() await page.getByRole('button', { name: 'Create deck', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('手動の調査') await page.getByLabel('New deck name').fill('手動の調査')
await page.getByRole('button', { name: '作成', exact: true }).click() await page.getByRole('button', { name: 'Create', exact: true }).click()
await closeMobileSidebar(page) await closeMobileSidebar(page)
await expect(heading).toHaveText('手動の調査') await expect(heading).toHaveText('手動の調査')
await showResearch(page) await showResearch(page)
+63 -52
View File
@@ -1,7 +1,7 @@
import { expect, test } from '../fixtures' import { expect, test } from '../fixtures'
const reviewSubject = '来週のレビュー、日程を決める' const reviewSubject = 'Schedule next week’s design review'
const coffeeSubject = '久しぶりに、お茶でも' const coffeeSubject = 'Catch up over coffee'
test('reviews an edited reply and keeps the simulated result in history', async ({ test('reviews an edited reply and keeps the simulated result in history', async ({
page, page,
@@ -12,28 +12,31 @@ test('reviews an edited reply and keeps the simulated result in history', async
const rows = page.locator('.support-row') const rows = page.locator('.support-row')
await expect(rows).toHaveCount(3) await expect(rows).toHaveCount(3)
await rows.filter({ hasText: reviewSubject }).click() await rows.filter({ hasText: reviewSubject }).click()
await page.getByRole('button', { name: '丁寧に', exact: true }).click() await page.getByRole('button', { name: 'Polite', exact: true }).click()
const reply = page.getByRole('textbox', { name: '返信', exact: true }) const reply = page.getByRole('textbox', { name: 'Reply', exact: true })
await expect(reply).toHaveValue(/資料をご共有/) await expect(reply).toHaveValue(/Thank you for sharing/)
const edited = '資料を確認しました。初回の導線について当日お話しします。' const edited =
'I reviewed the materials. Let’s discuss the first-time experience.'
await reply.fill(edited) await reply.fill(edited)
const slot = '10月5日(月)14:00–14:30' const slot = 'Mon, Oct 5, 14:00–14:30'
await page.getByRole('combobox', { name: '日程候補' }).selectOption(slot) await page
await page.getByRole('button', { name: '返信に入れる' }).click() .getByRole('combobox', { name: 'Suggested times' })
await expect(reply).toHaveValue(`${edited}\n\n${slot}はいかがでしょうか。`) .selectOption(slot)
await page.getByRole('button', { name: '送信内容を確認' }).click() await page.getByRole('button', { name: 'Add to reply' }).click()
const dialog = page.getByRole('dialog', { name: '送信内容の確認' }) await expect(reply).toHaveValue(`${edited}\n\nWould ${slot} work for you?`)
await page.getByRole('button', { name: 'Review reply' }).click()
const dialog = page.getByRole('dialog', { name: 'Review reply' })
await expect(dialog).toContainText(edited) await expect(dialog).toContainText(edited)
await expect(dialog).toContainText(slot) await expect(dialog).toContainText(slot)
await expect(dialog).toContainText('外部には送信されません') await expect(dialog).toContainText('No message will be sent')
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([])
await dialog.getByRole('button', { name: '戻る', exact: true }).click() await dialog.getByRole('button', { name: 'Back', exact: true }).click()
await expect(reply).toHaveValue(new RegExp(edited)) await expect(reply).toHaveValue(new RegExp(edited))
await page.getByRole('button', { name: '送信内容を確認' }).click() await page.getByRole('button', { name: 'Review reply' }).click()
await dialog.getByRole('button', { name: '送信を試す' }).click() await dialog.getByRole('button', { name: 'Try sending' }).click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0) await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0)
await page.getByRole('button', { name: '履歴', exact: true }).click() await page.getByRole('button', { name: 'History', exact: true }).click()
await rows.filter({ hasText: reviewSubject }).click() await rows.filter({ hasText: reviewSubject }).click()
await expect(page.locator('.support-outcome')).toContainText(edited) await expect(page.locator('.support-outcome')).toContainText(edited)
await expect(page.locator('.support-outcome')).toContainText(slot) await expect(page.locator('.support-outcome')).toContainText(slot)
@@ -47,21 +50,21 @@ test('defers a conversation and restores it to the current queue', async ({
const rows = page.locator('.support-row') const rows = page.locator('.support-row')
await rows.filter({ hasText: coffeeSubject }).click() await rows.filter({ hasText: coffeeSubject }).click()
await page await page
.getByRole('textbox', { name: '返信', exact: true }) .getByRole('textbox', { name: 'Reply', exact: true })
.fill('土曜の予定を確認してから返事するね。') .fill('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: '今は置いておく' }).click() await page.getByRole('button', { name: 'Leave for later' }).click()
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0) await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0)
await page.getByRole('button', { name: 'あとで', exact: true }).click() await page.getByRole('button', { name: 'Later', exact: true }).click()
await rows.filter({ hasText: coffeeSubject }).click() await rows.filter({ hasText: coffeeSubject }).click()
await expect( await expect(
page.getByRole('textbox', { name: '返信', exact: true }), page.getByRole('textbox', { name: 'Reply', exact: true }),
).toHaveValue('土曜の予定を確認してから返事するね。') ).toHaveValue('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: '今に戻す' }).click() await page.getByRole('button', { name: 'Move to Now' }).click()
const back = page.getByRole('button', { name: '連絡一覧に戻る' }) const back = page.getByRole('button', { name: 'Back to messages' })
if (await back.isVisible()) await back.click() if (await back.isVisible()) await back.click()
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible() await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible()
await expect( await expect(
page.getByRole('button', { name: '今', exact: true }), page.getByRole('button', { name: 'Now', exact: true }),
).toHaveAttribute('aria-pressed', 'true') ).toHaveAttribute('aria-pressed', 'true')
}) })
@@ -72,44 +75,52 @@ test('keeps corrections as new records and summarizes only selected current fact
await page.goto('/journal') await page.goto('/journal')
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
await expect( await expect(
page.getByRole('button', { name: '記録する', exact: true }), page.getByRole('button', { name: 'Save entry', exact: true }),
).toBeDisabled() ).toBeDisabled()
await page await page
.getByRole('textbox', { name: '記録内容' }) .getByRole('textbox', { name: 'Entry text' })
.fill('次はレビュー資料の導線を確認する') .fill('次はレビュー資料の導線を確認する')
await page.getByRole('button', { name: '記録する', exact: true }).click() await page.getByRole('button', { name: 'Save entry', exact: true }).click()
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText( await expect(page.getByRole('article', { name: 'Note entry' })).toContainText(
'発生日時: 不明', 'Occurred: Unknown',
) )
await page.getByRole('button', { name: '体重', exact: true }).click() await page.getByRole('button', { name: 'Weight', exact: true }).click()
await page.getByRole('spinbutton', { name: '体重', exact: true }).fill('65.2')
await page.getByRole('textbox', { name: '記録元' }).fill('自宅の体重計')
await page.getByRole('button', { name: '記録する', exact: true }).click()
await page await page
.getByRole('article', { name: '体重の記録' }) .getByRole('spinbutton', { name: 'Weight', exact: true })
.getByRole('button', { name: '訂正する' }) .fill('65.2')
await page.getByRole('textbox', { name: 'Source' }).fill('自宅の体重計')
await page.getByRole('button', { name: 'Save entry', exact: true }).click()
await page
.getByRole('article', { name: 'Weight entry' })
.getByRole('button', { name: 'Correct' })
.click() .click()
const correction = page.getByRole('dialog', { name: '記録を訂正' }) const correction = page.getByRole('dialog', { name: 'Correct entry' })
await correction await correction
.getByRole('spinbutton', { name: '体重', exact: true }) .getByRole('spinbutton', { name: 'Weight', exact: true })
.fill('65.8') .fill('65.8')
await correction.getByRole('button', { name: '訂正を追加' }).click() await correction.getByRole('button', { name: 'Add correction' }).click()
const weights = page.getByRole('article', { name: '体重の記録' }) const weights = page.getByRole('article', { name: 'Weight entry' })
await expect(weights).toHaveCount(2) await expect(weights).toHaveCount(2)
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('訂正前') await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('Original')
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText('訂正') await expect(weights.filter({ hasText: '65.8 kg' })).toContainText(
'Correction',
)
await page await page
.getByRole('button', { name: '記録をまとめる', exact: true }) .getByRole('button', { name: 'Summarize entries', exact: true })
.click() .click()
const review = page.getByRole('dialog', { name: '記録をまとめる' }) const review = page.getByRole('dialog', { name: 'Summarize entries' })
const summary = review.getByRole('textbox', { name: '記録のまとめ' }) const summary = review.getByRole('textbox', { name: 'Entry summary' })
await expect(summary).toHaveValue(/65.8 kg/) await expect(summary).toHaveValue(/65.8 kg/)
await expect(summary).not.toHaveValue(/65.2 kg/) await expect(summary).not.toHaveValue(/65.2 kg/)
await expect(summary).toHaveValue(/記録元: 自宅の体重計/) await expect(summary).toHaveValue(/Source: 自宅の体重計/)
await expect(summary).toHaveValue(/発生日時が不明の記録: 2件/) await expect(summary).toHaveValue(
/Entries with an unknown event time: 2 entries/,
)
await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck() await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck()
await expect(summary).not.toHaveValue(/次はレビュー資料/) await expect(summary).not.toHaveValue(/次はレビュー資料/)
await expect(summary).toHaveValue(/記録のない期間の服薬・体重は不明/) await expect(summary).toHaveValue(
/Medication and weight are unknown for periods without entries/,
)
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([])
}) })
@@ -126,18 +137,18 @@ test('fits communication and journal interactions in narrow screens', async ({
).toBe(width) ).toBe(width)
await page.locator('.support-row').first().click() await page.locator('.support-row').first().click()
await expect( await expect(
page.getByRole('textbox', { name: '返信', exact: true }), page.getByRole('textbox', { name: 'Reply', exact: true }),
).toBeVisible() ).toBeVisible()
expect( expect(
await page.evaluate(() => document.documentElement.scrollWidth), await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width) ).toBe(width)
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: '連絡一覧に戻る' }) const back = page.getByRole('button', { name: 'Back to messages' })
if (await back.isVisible()) await back.click() if (await back.isVisible()) await back.click()
await page.getByRole('link', { name: '記録', exact: true }).click() await page.getByRole('link', { name: 'Journal', exact: true }).click()
await page.getByRole('button', { name: '体重', exact: true }).click() await page.getByRole('button', { name: 'Weight', exact: true }).click()
await expect( await expect(
page.getByRole('spinbutton', { name: '体重', exact: true }), page.getByRole('spinbutton', { name: 'Weight', exact: true }),
).toBeVisible() ).toBeVisible()
expect( expect(
await page.evaluate(() => document.documentElement.scrollWidth), await page.evaluate(() => document.documentElement.scrollWidth),
+3 -3
View File
@@ -17,7 +17,7 @@ test('filters body systems and opens lab history', async ({ page, a11y }) => {
const dialog = page.getByRole('dialog', { name: 'Cardiovascular' }) const dialog = page.getByRole('dialog', { name: 'Cardiovascular' })
await expect(dialog).toContainText('68') await expect(dialog).toContainText('68')
await dialog await dialog
.getByRole('button', { name: '閉じる', exact: true }) .getByRole('button', { name: 'Close', exact: true })
.last() .last()
.click() .click()
await page.getByRole('button', { name: 'Lab history', exact: true }).click() await page.getByRole('button', { name: 'Lab history', exact: true }).click()
@@ -38,7 +38,7 @@ test('explores environment charts and simulated climate controls', async ({
}) => { }) => {
await page.goto('/vitals') await page.goto('/vitals')
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
const menu = page.getByRole('button', { name: 'Healthメニューを開く' }) const menu = page.getByRole('button', { name: 'Open Health menu' })
if (await menu.isVisible()) await menu.click() if (await menu.isVisible()) await menu.click()
await page await page
.getByRole('complementary', { name: 'Health navigation' }) .getByRole('complementary', { name: 'Health navigation' })
@@ -95,7 +95,7 @@ test('keeps both dashboards within the viewport', async ({
await page.evaluate(() => document.documentElement.scrollWidth), await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width) ).toBe(width)
await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) }) await page.screenshot({ path: testInfo.outputPath(`body-${width}.png`) })
const menu = page.getByRole('button', { name: 'Healthメニューを開く' }) const menu = page.getByRole('button', { name: 'Open Health menu' })
if (await menu.isVisible()) await menu.click() if (await menu.isVisible()) await menu.click()
const navigation = page.getByRole('complementary', { const navigation = page.getByRole('complementary', {
name: 'Health navigation', name: 'Health navigation',
+5 -3
View File
@@ -117,9 +117,9 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
).toBeVisible() ).toBeVisible()
await page await page
.getByRole('region', { name: '日本語', exact: true }) .getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: '日本語の操作' }) .getByRole('button', { name: 'Actions for 日本語' })
.click() .click()
await page.getByRole('menuitem', { name: '編集', exact: true }).click() await page.getByRole('menuitem', { name: 'Edit', exact: true }).click()
const replaced = await executeTool(page, 'set_deck', { const replaced = await executeTool(page, 'set_deck', {
deckId: deck.id, deckId: deck.id,
title: '更新した調査', title: '更新した調査',
@@ -234,7 +234,9 @@ test('keeps temporary views independent and loses only unsaved views on reload',
).toBe(false) ).toBe(false)
expect(savedCount()).toEqual({ count: 0 }) expect(savedCount()).toEqual({ count: 0 })
await page.reload() await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('新しい調査') await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'New research',
)
}) })
test('rejects invalid input without changing the active view', async ({ test('rejects invalid input without changing the active view', async ({
+6 -6
View File
@@ -9,10 +9,10 @@ test('keeps the sidebar, composer and deck reachable at compact widths', async (
for (const width of [320, 375, 414, 768]) { for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 }) await page.setViewportSize({ width, height: 850 })
await showResearch(page) await showResearch(page)
const chat = page.getByRole('complementary', { name: '調査チャット' }) const chat = page.getByRole('complementary', { name: 'Research chat' })
await expect(chat).toBeVisible() await expect(chat).toBeVisible()
const composer = chat.getByRole('textbox', { const composer = chat.getByRole('textbox', {
name: 'メッセージ', name: 'Message',
exact: true, exact: true,
}) })
await expect(composer).toBeVisible() await expect(composer).toBeVisible()
@@ -30,12 +30,12 @@ test('keeps the sidebar, composer and deck reachable at compact widths', async (
if (width === 320) expect((await a11y().analyze()).violations).toEqual([]) if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
await closeMobileSidebar(page) await closeMobileSidebar(page)
} }
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click() await page.getByRole('button', { name: 'Toggle sidebar' }).click()
await expect( await expect(
page.getByRole('complementary', { name: '調査チャット' }), page.getByRole('complementary', { name: 'Research chat' }),
).toBeHidden() ).toBeHidden()
await page.getByRole('button', { name: '調査チャット', exact: true }).click() await page.getByRole('button', { name: 'Research chat', exact: true }).click()
await expect( await expect(
page.getByRole('complementary', { name: '調査チャット' }), page.getByRole('complementary', { name: 'Research chat' }),
).toBeVisible() ).toBeVisible()
}) })
+7 -7
View File
@@ -2,28 +2,28 @@ import { expect, type Page } from '@playwright/test'
export async function showDeckManagement(page: Page) { export async function showDeckManagement(page: Page) {
await expect( await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }), page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled() ).toBeEnabled()
const navigation = page.getByRole('button', { const navigation = page.getByRole('button', {
name: 'デッキ管理', name: 'Deck manager',
exact: true, exact: true,
}) })
if (!(await navigation.isVisible())) { if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click() await page.getByRole('button', { name: 'Toggle sidebar' }).click()
} }
await navigation.click() await navigation.click()
} }
export async function showResearch(page: Page) { export async function showResearch(page: Page) {
await expect( await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }), page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled() ).toBeEnabled()
const navigation = page.getByRole('button', { const navigation = page.getByRole('button', {
name: '調査チャット', name: 'Research chat',
exact: true, exact: true,
}) })
if (!(await navigation.isVisible())) { if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click() await page.getByRole('button', { name: 'Toggle sidebar' }).click()
} }
await navigation.click() await navigation.click()
} }
@@ -32,7 +32,7 @@ export async function closeMobileSidebar(page: Page) {
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]') const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]')
if (await sheet.isVisible()) { if (await sheet.isVisible()) {
await page await page
.getByRole('button', { name: 'サイドバーを閉じる', exact: true }) .getByRole('button', { name: 'Close sidebar', exact: true })
.click() .click()
await expect(sheet).toBeHidden() await expect(sheet).toBeHidden()
} }
+9 -9
View File
@@ -46,8 +46,8 @@ function Workspace({
const [panel, setPanel] = useState<'chat' | 'decks'>('chat') const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
const { setOpen, setOpenMobile, isMobile } = useSidebar() const { setOpen, setOpenMobile, isMobile } = useSidebar()
const navigation = [ const navigation = [
{ id: 'chat', title: '調査チャット', icon: MessageSquare }, { id: 'chat', title: 'Research chat', icon: MessageSquare },
{ id: 'decks', title: 'デッキ管理', icon: Columns3 }, { id: 'decks', title: 'Deck manager', icon: Columns3 },
] as const ] as const
return ( return (
@@ -61,8 +61,8 @@ function Workspace({
footer={ footer={
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
aria-label="サイドバーを閉じる" aria-label="Close sidebar"
tooltip={{ children: 'サイドバーを閉じる', hidden: false }} tooltip={{ children: 'Close sidebar', hidden: false }}
onClick={() => onClick={() =>
isMobile ? setOpenMobile(false) : setOpen(false) isMobile ? setOpenMobile(false) : setOpen(false)
} }
@@ -104,29 +104,29 @@ function Workspace({
{researchChat(() => setOpenMobile(false))} {researchChat(() => setOpenMobile(false))}
</div> </div>
<section <section
aria-label="デッキ管理" aria-label="Deck manager"
className={ className={
panel === 'decks' panel === 'decks'
? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4' ? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4'
: 'hidden' : 'hidden'
} }
> >
<h2 className="mb-4 text-base font-semibold">デッキ</h2> <h2 className="mb-4 text-base font-semibold">Decks</h2>
<div className="flex flex-col gap-4">{sidebar}</div> <div className="flex flex-col gap-4">{sidebar}</div>
</section> </section>
</Sidebar> </Sidebar>
</Sidebar> </Sidebar>
<SidebarInset className="min-w-0 overflow-hidden"> <SidebarInset className="min-w-0 overflow-hidden">
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3"> <header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="サイドバーを切り替え" /> <SidebarTrigger aria-label="Toggle sidebar" />
<span className="text-sm font-medium">Twitter Lite</span> <span className="text-sm font-medium">Twitter Lite</span>
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">
調査ワークスペース Research workspace
</span> </span>
</header> </header>
<section <section
className="reader flex min-h-0 min-w-0 flex-1 flex-col" className="reader flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="デッキ" aria-label="Decks"
> >
{children} {children}
</section> </section>
+1 -1
View File
@@ -41,7 +41,7 @@ export function Dialog({
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="absolute right-2 top-2" className="absolute right-2 top-2"
aria-label="閉じる" aria-label="Close"
/> />
} }
> >
+4 -2
View File
@@ -189,8 +189,10 @@ function Sidebar({
side={side} side={side}
> >
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>ワークスペース</SheetTitle> <SheetTitle>Workspace</SheetTitle>
<SheetDescription>調査チャットとデッキ管理</SheetDescription> <SheetDescription>
Research chat and deck management
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex h-full w-full flex-row">{children}</div> <div className="flex h-full w-full flex-row">{children}</div>
</SheetContent> </SheetContent>
+8 -8
View File
@@ -38,27 +38,27 @@ export function WorkspaceNavigation({
const [managingAccounts, setManagingAccounts] = useState(false) const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname }) const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [ const navigation = [
{ title: 'ホーム', to: '/', icon: House, active: pathname === '/' }, { title: 'Home', to: '/', icon: House, active: pathname === '/' },
{ {
title: 'バイタル', title: 'Vitals',
to: '/vitals', to: '/vitals',
icon: Activity, icon: Activity,
active: pathname === '/vitals', active: pathname === '/vitals',
}, },
{ {
title: '連絡', title: 'Messages',
to: '/support', to: '/support',
icon: MessagesSquare, icon: MessagesSquare,
active: pathname === '/support', active: pathname === '/support',
}, },
{ {
title: '記録', title: 'Journal',
to: '/journal', to: '/journal',
icon: NotebookPen, icon: NotebookPen,
active: pathname === '/journal', active: pathname === '/journal',
}, },
{ {
title: 'リーダー', title: 'Reader',
to: '/inbox', to: '/inbox',
icon: Inbox, icon: Inbox,
active: pathname === '/inbox', active: pathname === '/inbox',
@@ -80,7 +80,7 @@ export function WorkspaceNavigation({
<div <div
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground" className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
role="img" role="img"
aria-label="ワークスペース" aria-label="Workspace"
> >
<Layers3 className="size-5" aria-hidden="true" /> <Layers3 className="size-5" aria-hidden="true" />
</div> </div>
@@ -123,8 +123,8 @@ export function WorkspaceNavigation({
<SidebarMenu className="mt-2"> <SidebarMenu className="mt-2">
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
aria-label="接続アカウントを管理" aria-label="Manage connected accounts"
tooltip={{ children: '接続アカウントを管理', hidden: false }} tooltip={{ children: 'Manage connected accounts', hidden: false }}
className="size-10 justify-center p-0" className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)} onClick={() => setManagingAccounts(true)}
> >
@@ -20,14 +20,18 @@ export function ConnectionManagerDialog({
}) })
return ( return (
<Dialog title="接続アカウント" onClose={onClose} className="sm:max-w-2xl"> <Dialog
title="Connected accounts"
onClose={onClose}
className="sm:max-w-2xl"
>
{connections.isPending ? ( {connections.isPending ? (
<p role="status">接続アカウントを読み込んでいます…</p> <p role="status">Loading connected accounts…</p>
) : connections.isError ? ( ) : connections.isError ? (
<div className="grid gap-3"> <div className="grid gap-3">
<p role="alert">接続アカウントを取得できませんでした。</p> <p role="alert">Could not load connected accounts.</p>
<Button variant="outline" onClick={() => void connections.refetch()}> <Button variant="outline" onClick={() => void connections.refetch()}>
再試行 Retry
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -60,11 +60,11 @@ it('shows per-account status and warns about temporary decks without blocking ac
vi.fn(), vi.fn(),
true, true,
) )
expect(screen.getByText('接続済み')).toBeVisible() expect(screen.getByText('Connected')).toBeVisible()
expect(screen.getByText('再接続が必要')).toBeVisible() expect(screen.getByText('Reconnect required')).toBeVisible()
expect(screen.getByText(/一時デッキは消えます/)).toBeVisible() expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible()
expect( expect(
await screen.findByRole('button', { name: 'Mastodonで認可する' }), await screen.findByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled() ).toBeEnabled()
}) })
@@ -73,29 +73,31 @@ it('uses the selected instance and reports a safe error when authorization fails
new Error('private server credential detail'), new Error('private server credential detail'),
) )
show() show()
fireEvent.change(await screen.findByLabelText('Mastodonサーバー'), { fireEvent.change(await screen.findByLabelText('Mastodon server'), {
target: { value: 'https://second.invalid' }, target: { value: 'https://second.invalid' },
}) })
fireEvent.click(screen.getByRole('button', { name: 'Mastodonで認可する' })) fireEvent.click(
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
)
await waitFor(() => await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({ expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: 'https://second.invalid' }, data: { origin: 'https://second.invalid' },
}), }),
) )
expect(await screen.findByRole('alert')).toHaveTextContent( expect(await screen.findByRole('alert')).toHaveTextContent(
'認可を開始できませんでした', 'Could not start Mastodon authorization',
) )
expect(screen.queryByText(/private server/)).toBeNull() expect(screen.queryByText(/private server/)).toBeNull()
expect( expect(
screen.getByRole('button', { name: 'Mastodonで認可する' }), screen.getByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled() ).toBeEnabled()
}) })
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => { it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => {
actions.authorize.mockRejectedValue(new Error('offline')) actions.authorize.mockRejectedValue(new Error('offline'))
show() show()
await screen.findByLabelText('Mastodonサーバー') await screen.findByLabelText('Mastodon server')
fireEvent.click(screen.getByRole('button', { name: '@firstを再接続' })) fireEvent.click(screen.getByRole('button', { name: 'Reconnect @first' }))
await waitFor(() => await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({ expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: account.origin, connectionId: account.id }, data: { origin: account.origin, connectionId: account.id },
@@ -108,7 +110,7 @@ it('refreshes the account list after successful disconnect', async () => {
const onChanged = vi.fn().mockResolvedValue(undefined) const onChanged = vi.fn().mockResolvedValue(undefined)
actions.disconnect.mockResolvedValue({ disconnected: true }) actions.disconnect.mockResolvedValue({ disconnected: true })
show([account], onChanged) show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' })) fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce()) await waitFor(() => expect(onChanged).toHaveBeenCalledOnce())
expect(actions.disconnect).toHaveBeenCalledWith({ expect(actions.disconnect).toHaveBeenCalledWith({
data: { connectionId: account.id }, data: { connectionId: account.id },
@@ -119,21 +121,19 @@ it('keeps account controls available after a failed disconnect', async () => {
actions.disconnect.mockRejectedValue(new Error('private token')) actions.disconnect.mockRejectedValue(new Error('private token'))
const onChanged = vi.fn() const onChanged = vi.fn()
show([account], onChanged) show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' })) fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
expect(await screen.findByRole('alert')).toHaveTextContent( expect(await screen.findByRole('alert')).toHaveTextContent(
'接続を解除できませんでした', 'Could not disconnect the account',
) )
expect(onChanged).not.toHaveBeenCalled() expect(onChanged).not.toHaveBeenCalled()
expect( expect(
screen.getByRole('button', { name: '@firstの接続を解除' }), screen.getByRole('button', { name: 'Disconnect @first' }),
).toBeEnabled() ).toBeEnabled()
}) })
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => { it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => {
show([{ ...account, platform: 'twitter', id: 'twitter-first' }]) show([{ ...account, platform: 'twitter', id: 'twitter-first' }])
await screen.findByLabelText('Mastodonサーバー') await screen.findByLabelText('Mastodon server')
expect(screen.queryByRole('button', { name: '@firstを再接続' })).toBeNull() expect(screen.queryByRole('button', { name: 'Reconnect @first' })).toBeNull()
expect( expect(screen.queryByRole('button', { name: 'Disconnect @first' })).toBeNull()
screen.queryByRole('button', { name: '@firstの接続を解除' }),
).toBeNull()
}) })
+26 -22
View File
@@ -14,9 +14,9 @@ import type { Connection } from './model'
import './connection-manager.css' import './connection-manager.css'
const statusNames = { const statusNames = {
connected: '接続済み', connected: 'Connected',
disconnected: '未接続', disconnected: 'Disconnected',
expired: '再接続が必要', expired: 'Reconnect required',
} }
export function ConnectionManager({ export function ConnectionManager({
@@ -52,7 +52,7 @@ export function ConnectionManager({
window.location.assign(result.authorizationUrl) window.location.assign(result.authorizationUrl)
} catch { } catch {
setError( setError(
'Mastodonの認可を開始できませんでした。接続先を確認して再試行してください。', 'Could not start Mastodon authorization. Check the server and try again.',
) )
} finally { } finally {
setBusy(false) setBusy(false)
@@ -68,24 +68,27 @@ export function ConnectionManager({
await onChanged() await onChanged()
} catch { } catch {
setError( setError(
'接続は解除しましたが、一覧を更新できませんでした。画面を再読み込みしてください。', 'Account disconnected, but the list could not be refreshed. Reload the page.',
) )
} }
} catch { } catch {
setError('接続を解除できませんでした。時間をおいて再試行してください。') setError('Could not disconnect the account. Try again later.')
} finally { } finally {
setBusy(false) setBusy(false)
} }
} }
return ( return (
<section className="connection-manager" aria-label="接続アカウント管理"> <section
className="connection-manager"
aria-label="Connected account management"
>
<p className="connection-manager-description"> <p className="connection-manager-description">
追加したアカウントは、どの端末のカラムからも選べます。 Connected accounts are available in columns on all your devices.
</p> </p>
{error && <p role="alert">{error}</p>} {error && <p role="alert">{error}</p>}
{connections.length === 0 ? ( {connections.length === 0 ? (
<p>接続アカウントはまだありません。</p> <p>No connected accounts yet.</p>
) : ( ) : (
<ul className="connection-manager-list"> <ul className="connection-manager-list">
{connections.map((connection) => ( {connections.map((connection) => (
@@ -110,22 +113,22 @@ export function ConnectionManager({
variant="outline" variant="outline"
type="button" type="button"
disabled={busy} disabled={busy}
aria-label={`${connection.displayName}を再接続`} aria-label={`Reconnect ${connection.displayName}`}
onClick={() => onClick={() =>
void connect(connection.origin, connection.id) void connect(connection.origin, connection.id)
} }
> >
再接続 Reconnect
</Button> </Button>
{connection.status !== 'disconnected' && ( {connection.status !== 'disconnected' && (
<Button <Button
variant="destructive" variant="destructive"
type="button" type="button"
disabled={busy} disabled={busy}
aria-label={`${connection.displayName}の接続を解除`} aria-label={`Disconnect ${connection.displayName}`}
onClick={() => void remove(connection.id)} onClick={() => void remove(connection.id)}
> >
接続解除 Disconnect
</Button> </Button>
)} )}
</div> </div>
@@ -141,25 +144,25 @@ export function ConnectionManager({
if (origin) void connect(origin) if (origin) void connect(origin)
}} }}
> >
<h3>Mastodonアカウントを追加</h3> <h3>Add a Mastodon account</h3>
{instances.isPending ? ( {instances.isPending ? (
<p role="status">接続先を確認しています…</p> <p role="status">Checking servers…</p>
) : instances.isError ? ( ) : instances.isError ? (
<p role="alert"> <p role="alert">
接続先を取得できませんでした。 Could not load servers.
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => void instances.refetch()} onClick={() => void instances.refetch()}
> >
再試行 Retry
</Button> </Button>
</p> </p>
) : instances.data.origins.length === 0 ? ( ) : instances.data.origins.length === 0 ? (
<p>利用するMastodonサーバーがまだ設定されていません。</p> <p>No Mastodon servers have been configured yet.</p>
) : ( ) : (
<> <>
<Label htmlFor={selectId}>Mastodonサーバー</Label> <Label htmlFor={selectId}>Mastodon server</Label>
<NativeSelect <NativeSelect
className="w-full" className="w-full"
id={selectId} id={selectId}
@@ -174,16 +177,17 @@ export function ConnectionManager({
))} ))}
</NativeSelect> </NativeSelect>
<Button type="submit" disabled={busy || !origin}> <Button type="submit" disabled={busy || !origin}>
Mastodonで認可する Authorize with Mastodon
</Button> </Button>
</> </>
)} )}
{hasTemporaryDecks && ( {hasTemporaryDecks && (
<p className="connection-manager-note"> <p className="connection-manager-note">
認可画面へ移動すると、一時デッキは消えます。残したいデッキは先に保存してください。 Temporary decks will be lost when you leave to authorize. Save any
decks you want to keep first.
</p> </p>
)} )}
{busy && <p role="status">処理しています…</p>} {busy && <p role="status">Working…</p>}
</form> </form>
</section> </section>
) )
@@ -9,9 +9,7 @@ import type { Connection } from './model'
function relayOrigin(): string { function relayOrigin(): string {
const configured = process.env.TWITTER_RELAY_BASE_URL const configured = process.env.TWITTER_RELAY_BASE_URL
if (!configured) if (!configured)
throw new ProfileUnavailableError( throw new ProfileUnavailableError('Set TWITTER_RELAY_BASE_URL.')
'TWITTER_RELAY_BASE_URL を設定してください。',
)
return new URL(configured).origin return new URL(configured).origin
} }
@@ -23,7 +21,7 @@ async function syncTwitterConnections(
const profiles = new Set( const profiles = new Set(
await fetchProfiles(origin).catch(() => { await fetchProfiles(origin).catch(() => {
throw new ProfileUnavailableError( throw new ProfileUnavailableError(
'接続プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。', 'Could not verify connection profiles. Check the relay connection and try again.',
) )
}), }),
) )
@@ -118,7 +116,7 @@ export async function requireTwitterConnection(
!connection.relayProfile !connection.relayProfile
) { ) {
throw new ProfileUnavailableError( throw new ProfileUnavailableError(
'この接続は利用できません。カラムの接続アカウントを確認してください。', 'This connection is unavailable. Check the connected account for this column.',
) )
} }
return connection.relayProfile return connection.relayProfile
+5 -5
View File
@@ -40,7 +40,7 @@ it('preserves unavailable bindings and reconnects the original ID when a profile
) )
await expect( await expect(
requireTwitterConnection(id, database, async () => []), requireTwitterConnection(id, database, async () => []),
).rejects.toThrow('この接続は利用できません') ).rejects.toThrow('This connection is unavailable')
expect( expect(
(await listConnections(database, async () => ['first'])).connections, (await listConnections(database, async () => ['first'])).connections,
).toEqual(original) ).toEqual(original)
@@ -69,7 +69,7 @@ it('does not reuse an old relay binding for the same profile at another origin',
requireTwitterConnection(original[0]?.id ?? '', database, async () => [ requireTwitterConnection(original[0]?.id ?? '', database, async () => [
'first', 'first',
]), ]),
).rejects.toThrow('この接続は利用できません') ).rejects.toThrow('This connection is unavailable')
}) })
it('does not convert a relay outage into persisted account removal', async () => { it('does not convert a relay outage into persisted account removal', async () => {
@@ -80,7 +80,7 @@ it('does not convert a relay outage into persisted account removal', async () =>
const failed = await listConnections(database, unavailable) const failed = await listConnections(database, unavailable)
expect(failed.connections).toEqual(original.connections) expect(failed.connections).toEqual(original.connections)
expect(failed.relayError).toContain( expect(failed.relayError).toContain(
'Relay への接続を確認して再試行してください。', 'Check the relay connection and try again.',
) )
expect(database.select().from(connections).get()?.status).toBe('connected') expect(database.select().from(connections).get()?.status).toBe('connected')
expect(await listConnections(database, async () => ['first'])).toEqual( expect(await listConnections(database, async () => ['first'])).toEqual(
@@ -140,7 +140,7 @@ it('returns public metadata for all platforms without joining or exposing creden
requireTwitterConnection('mastodon-account', database, async () => [ requireTwitterConnection('mastodon-account', database, async () => [
'first', 'first',
]), ]),
).rejects.toThrow('この接続は利用できません') ).rejects.toThrow('This connection is unavailable')
expect( expect(
database database
.select() .select()
@@ -153,5 +153,5 @@ it('returns public metadata for all platforms without joining or exposing creden
it('rejects an unknown connection without treating its ID as a relay profile', async () => { it('rejects an unknown connection without treating its ID as a relay profile', async () => {
await expect( await expect(
requireTwitterConnection('first', database, async () => ['first']), requireTwitterConnection('first', database, async () => ['first']),
).rejects.toThrow('この接続は利用できません') ).rejects.toThrow('This connection is unavailable')
}) })
+30 -30
View File
@@ -75,7 +75,7 @@ function show() {
/> />
</QueryClientProvider>, </QueryClientProvider>,
) )
fireEvent.change(screen.getByLabelText('カラム名'), { fireEvent.change(screen.getByLabelText('Column name'), {
target: { value: '調査' }, target: { value: '調査' },
}) })
return onSave return onSave
@@ -83,16 +83,16 @@ function show() {
it('creates a Mastodon search without Twitter ordering or following fields', async () => { it('creates a Mastodon search without Twitter ordering or following fields', async () => {
const save = show() const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), { fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' }, target: { value: 'mastodon-first' },
}) })
expect(screen.queryByLabelText('表示順')).toBeNull() expect(screen.queryByLabelText('Sort by')).toBeNull()
expect(screen.queryByLabelText('フォロー中のみ')).toBeNull() expect(screen.queryByLabelText('Following only')).toBeNull()
expect(screen.getByText(/結果が0件でも/)).toBeVisible() expect(screen.getByText(/Zero results/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodonの検索条件'), { fireEvent.change(screen.getByLabelText('Mastodon search query'), {
target: { value: 'WebMCP' }, target: { value: 'WebMCP' },
}) })
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => await waitFor(() =>
expect(save).toHaveBeenCalledWith( expect(save).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -105,16 +105,16 @@ it('creates a Mastodon search without Twitter ordering or following fields', asy
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => { it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => {
const save = show() const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), { fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' }, target: { value: 'mastodon-first' },
}) })
fireEvent.change(screen.getByLabelText('カラムの種類'), { fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'hashtag' }, target: { value: 'hashtag' },
}) })
fireEvent.change(screen.getByLabelText('ハッシュタグ'), { fireEvent.change(screen.getByLabelText('Hashtag'), {
target: { value: 'WebMCP' }, target: { value: 'WebMCP' },
}) })
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => await waitFor(() =>
expect(save).toHaveBeenCalledWith( expect(save).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -122,32 +122,32 @@ it('creates hashtags and resets unsupported source kind when changing to Twitter
}), }),
), ),
) )
fireEvent.change(screen.getByLabelText('接続プロファイル'), { fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'twitter' }, target: { value: 'twitter' },
}) })
expect(screen.getByLabelText('カラムの種類')).toHaveValue('search') expect(screen.getByLabelText('Column type')).toHaveValue('search')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue('') expect(screen.getByLabelText('Twitter search query')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'ハッシュタグ' })).toBeNull() expect(screen.queryByRole('option', { name: 'Hashtag' })).toBeNull()
}) })
it('loads lists for each account and clears the selected list when the binding changes', async () => { it('loads lists for each account and clears the selected list when the binding changes', async () => {
show() show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), { fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' }, target: { value: 'mastodon-first' },
}) })
fireEvent.change(screen.getByLabelText('カラムの種類'), { fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'list' }, target: { value: 'list' },
}) })
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' })) fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
expect(screen.getByLabelText('リスト')).toHaveValue('10') expect(screen.getByLabelText('List')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('接続プロファイル'), { fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-second' }, target: { value: 'mastodon-second' },
}) })
expect(screen.getByLabelText('リスト')).toHaveValue('') expect(screen.getByLabelText('List')).toHaveValue('')
fireEvent.click( fireEvent.click(
await screen.findByRole('button', { name: 'mastodon-second' }), await screen.findByRole('button', { name: 'mastodon-second' }),
) )
expect(screen.getByLabelText('リスト')).toHaveValue('20') expect(screen.getByLabelText('List')).toHaveValue('20')
expect(lists).toHaveBeenCalledWith({ expect(lists).toHaveBeenCalledWith({
data: { connectionId: 'mastodon-second' }, data: { connectionId: 'mastodon-second' },
}) })
@@ -156,22 +156,22 @@ it('loads lists for each account and clears the selected list when the binding c
it('preserves title and Twitter search options after a rejected save', async () => { it('preserves title and Twitter search options after a rejected save', async () => {
const save = show() const save = show()
save.mockResolvedValue(false) save.mockResolvedValue(false)
fireEvent.change(screen.getByLabelText('Twitterの検索条件'), { fireEvent.change(screen.getByLabelText('Twitter search query'), {
target: { value: 'from:owner WebMCP' }, target: { value: 'from:owner WebMCP' },
}) })
fireEvent.change(screen.getByLabelText('表示順'), { fireEvent.change(screen.getByLabelText('Sort by'), {
target: { value: 'Top' }, target: { value: 'Top' },
}) })
fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' })) fireEvent.click(screen.getByRole('checkbox', { name: 'Following only' }))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce()) await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査') expect(screen.getByLabelText('Column name')).toHaveValue('調査')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue( expect(screen.getByLabelText('Twitter search query')).toHaveValue(
'from:owner WebMCP', 'from:owner WebMCP',
) )
expect(screen.getByLabelText('表示順')).toHaveValue('Top') expect(screen.getByLabelText('Sort by')).toHaveValue('Top')
expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked() expect(screen.getByRole('checkbox', { name: 'Following only' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2)) await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source) expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
}) })
+10 -10
View File
@@ -40,7 +40,7 @@ export function ColumnEditor({
return ( return (
<form <form
className="deck-editor" className="deck-editor"
aria-label={column ? 'カラムを編集' : 'カラムを追加'} aria-label={column ? 'Edit column' : 'Add column'}
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault() event.preventDefault()
setError(undefined) setError(undefined)
@@ -55,7 +55,7 @@ export function ColumnEditor({
if (!parsed.success || !validConnection) { if (!parsed.success || !validConnection) {
setError( setError(
parsed.success parsed.success
? '接続プロファイルを選択してください。' ? 'Select a connection profile.'
: parsed.error.issues[0]?.message, : parsed.error.issues[0]?.message,
) )
return return
@@ -65,18 +65,18 @@ export function ColumnEditor({
> >
<div className="deck-editor-fields"> <div className="deck-editor-fields">
<Label htmlFor={`${formId}-title`}> <Label htmlFor={`${formId}-title`}>
カラム名 Column name
<Input <Input
id={`${formId}-title`} id={`${formId}-title`}
name="title" name="title"
defaultValue={column?.title ?? ''} defaultValue={column?.title ?? ''}
maxLength={80} maxLength={80}
placeholder="例:発表元の投稿" placeholder="e.g. Announcements"
required required
/> />
</Label> </Label>
<Label htmlFor={`${formId}-profile`}> <Label htmlFor={`${formId}-profile`}>
接続プロファイル Connection profile
<NativeSelect <NativeSelect
className="w-full" className="w-full"
id={`${formId}-profile`} id={`${formId}-profile`}
@@ -92,11 +92,11 @@ export function ColumnEditor({
}} }}
> >
<NativeSelectOption value="" disabled> <NativeSelectOption value="" disabled>
選択してください Select an option
</NativeSelectOption> </NativeSelectOption>
{connectionId && !validConnection && ( {connectionId && !validConnection && (
<NativeSelectOption value={connectionId} disabled> <NativeSelectOption value={connectionId} disabled>
{connectionId}(利用不可) {connectionId} (unavailable)
</NativeSelectOption> </NativeSelectOption>
)} )}
{available.map((connection) => ( {available.map((connection) => (
@@ -114,7 +114,7 @@ export function ColumnEditor({
onChange={setSource} onChange={setSource}
/> />
<p className="deck-help"> <p className="deck-help">
このカラムは選択した接続プロファイルで取得します。 This column uses the selected connection profile.
</p> </p>
{error && ( {error && (
<p role="alert" className="deck-error"> <p role="alert" className="deck-error">
@@ -123,10 +123,10 @@ export function ColumnEditor({
)} )}
<div className="deck-actions"> <div className="deck-actions">
<Button type="submit" disabled={!validConnection}> <Button type="submit" disabled={!validConnection}>
{column ? '変更を保存' : '追加して検索'} {column ? 'Save changes' : 'Add and search'}
</Button> </Button>
<Button variant="outline" type="button" onClick={onCancel}> <Button variant="outline" type="button" onClick={onCancel}>
キャンセル Cancel
</Button> </Button>
</div> </div>
</form> </form>
+4 -4
View File
@@ -19,7 +19,7 @@ vi.mock('./research-post-card', () => ({
}) => ( }) => (
<article> <article>
{post.text} {post.text}
{citationSnapshot ? '調査時に取得した投稿' : ''} {citationSnapshot ? 'Post saved during research' : ''}
</article> </article>
), ),
})) }))
@@ -71,11 +71,11 @@ it('temporarily renders missing evidence without inserting it into feed pages or
/>, />,
) )
expect(screen.getByRole('article')).toHaveTextContent( expect(screen.getByRole('article')).toHaveTextContent(
'Snapshot調査時に取得した投稿', 'SnapshotPost saved during research',
) )
expect(page.posts).toHaveLength(0) expect(page.posts).toHaveLength(0)
expect(page.nextCursor).toBe('original-cursor') expect(page.nextCursor).toBe('original-cursor')
fireEvent.click(screen.getByRole('button', { name: '続きを読み込む' })) fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false }) expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
}) })
@@ -110,6 +110,6 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
expect(screen.getAllByRole('article')).toHaveLength(1) expect(screen.getAllByRole('article')).toHaveLength(1)
expect(screen.getByRole('article')).toHaveTextContent('Live') expect(screen.getByRole('article')).toHaveTextContent('Live')
expect( expect(
screen.queryByText(/Snapshot|調査時に取得した投稿/), screen.queryByText(/Snapshot|Post saved during research/),
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
+20 -22
View File
@@ -53,7 +53,7 @@ export function ResearchColumn({
const error = const error =
query.error instanceof PostLoadError query.error instanceof PostLoadError
? query.error.detail ? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true } : { message: 'Unable to load posts.', retryable: true }
const headingId = `column-${column.id}` const headingId = `column-${column.id}`
const sourceDescription = describeSource(column.source) const sourceDescription = describeSource(column.source)
return ( return (
@@ -65,8 +65,8 @@ export function ResearchColumn({
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="更新" aria-label="Refresh"
title="更新" title="Refresh"
type="button" type="button"
onClick={() => void query.refetch()} onClick={() => void query.refetch()}
disabled={query.isFetching} disabled={query.isFetching}
@@ -79,45 +79,45 @@ export function ResearchColumn({
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`${column.title}の操作`} aria-label={`Actions for ${column.title}`}
title="カラムの操作" title="Column actions"
/> />
} }
> >
<Icon name="more" /> <Icon name="more" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem> <DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onMove(-1)} onClick={() => onMove(-1)}
disabled={first} disabled={first}
aria-label={`${column.title}を左へ`} aria-label={`Move ${column.title} left`}
> >
← 左へ移動 ← Move left
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onMove(1)} onClick={() => onMove(1)}
disabled={last} disabled={last}
aria-label={`${column.title}を右へ`} aria-label={`Move ${column.title} right`}
> >
右へ移動 → Move right →
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={onRemove} onClick={onRemove}
aria-label={`${column.title}を削除`} aria-label={`Delete ${column.title}`}
> >
カラムを削除 Delete column
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="deck-column-label"> <div className="deck-column-label">
<span <span
title={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`} title={`Connection profile: ${connectionLabel ?? 'Connection not found'}`}
> >
{connectionLabel ?? '接続が見つかりません'} {connectionLabel ?? 'Connection not found'}
</span> </span>
<Badge variant="outline" className="deck-source-label"> <Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} /> <Icon name={column.source.kind} />
@@ -126,16 +126,14 @@ export function ResearchColumn({
</div> </div>
<p className="deck-query">{sourceDescription.query}</p> <p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status"> <p className="deck-column-status" role="status">
{query.isFetching {query.isFetching ? 'Loading posts…' : `${posts.length} posts loaded`}
? '投稿を取得しています…'
: `${posts.length}件を読み込み済み`}
</p> </p>
</header> </header>
<section <section
className="deck-column-results" className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible. // biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
tabIndex={0} tabIndex={0}
aria-label={`${column.title}の投稿`} aria-label={`Posts in ${column.title}`}
> >
{visiblePosts.map((post) => ( {visiblePosts.map((post) => (
<ResearchPostCard <ResearchPostCard
@@ -163,13 +161,13 @@ export function ResearchColumn({
: query.refetch()) : query.refetch())
} }
> >
再試行 Retry
</Button> </Button>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{!query.isPending && !query.isError && visiblePosts.length === 0 ? ( {!query.isPending && !query.isError && visiblePosts.length === 0 ? (
<p className="deck-message">条件に一致する投稿はありません。</p> <p className="deck-message">No posts match your filters.</p>
) : null} ) : null}
{query.hasNextPage && !query.isFetchNextPageError ? ( {query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message"> <div className="deck-message">
@@ -179,7 +177,7 @@ export function ResearchColumn({
disabled={query.isFetching} disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })} onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
> >
続きを読み込む Load more
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -187,7 +185,7 @@ export function ResearchColumn({
!query.isError && !query.isError &&
posts.length > 0 && posts.length > 0 &&
!query.hasNextPage ? ( !query.hasNextPage ? (
<p className="deck-message">これ以上の投稿はありません。</p> <p className="deck-message">No more posts.</p>
) : null} ) : null}
</section> </section>
</section> </section>
+34 -32
View File
@@ -126,7 +126,7 @@ export function DeckPage() {
void refresh(true) void refresh(true)
}} }}
> >
最新のデッキを再読み込み Reload latest deck
</Button> </Button>
</p> </p>
) : null ) : null
@@ -187,8 +187,8 @@ export function DeckPage() {
/> />
<Button <Button
className="w-full" className="w-full"
aria-label="カラムを追加" aria-label="Add column"
title="カラムを追加" title="Add column"
type="button" type="button"
disabled={ disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS !ready || saving || deck.columns.length >= MAX_COLUMNS
@@ -196,12 +196,12 @@ export function DeckPage() {
onClick={() => setEditing('new')} onClick={() => setEditing('new')}
> >
<Icon name="plus" /> <Icon name="plus" />
<span>カラムを追加</span> <span>Add column</span>
</Button> </Button>
<p className="sidebar-note"> <p className="sidebar-note">
{deck.persisted {deck.persisted
? 'サーバーに保存・端末間で共有' ? 'Saved to server · Shared across devices'
: '一時ビュー · このタブのみ'} : 'Temporary view · This tab only'}
</p> </p>
</> </>
} }
@@ -210,7 +210,7 @@ export function DeckPage() {
<div> <div>
<h1>{deck.title}</h1> <h1>{deck.title}</h1>
<span className="deck-count"> <span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム {deck.columns.length} / {MAX_COLUMNS} columns
</span> </span>
</div> </div>
<div className="deck-actions"> <div className="deck-actions">
@@ -220,11 +220,11 @@ export function DeckPage() {
type="button" type="button"
disabled={saving} disabled={saving}
onClick={() => { onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns) createTemporary(`${deck.title} copy`, deck.columns)
clearEditors() clearEditors()
}} }}
> >
一時コピーを作成 Create temporary copy
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -233,14 +233,14 @@ export function DeckPage() {
disabled={!ready || saving} disabled={!ready || saving}
onClick={() => void persist(deck.id)} onClick={() => void persist(deck.id)}
> >
デッキとして保存 Save as deck
</Button> </Button>
)} )}
<Button <Button
variant="outline" variant="outline"
className="icon-button" className="icon-button"
aria-label="名前を変更" aria-label="Rename"
title="名前を変更" title="Rename"
type="button" type="button"
disabled={!ready || saving} disabled={!ready || saving}
onClick={() => setRenaming(!renaming)} onClick={() => setRenaming(!renaming)}
@@ -251,47 +251,48 @@ export function DeckPage() {
</div> </div>
{saving ? ( {saving ? (
<p className="deck-save-status" role="status"> <p className="deck-save-status" role="status">
保存しています… Saving…
</p> </p>
) : null} ) : null}
{legacyAvailable ? ( {legacyAvailable ? (
<p className="deck-save-status"> <p className="deck-save-status">
このブラウザに旧デッキがあります。 Legacy decks are available in this browser.
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
disabled={saving || !profiles.data} disabled={saving || !profiles.data}
onClick={() => void importLegacy()} onClick={() => void importLegacy()}
> >
旧デッキを取り込む Import legacy decks
</Button> </Button>
</p> </p>
) : null} ) : null}
{!ready ? ( {!ready ? (
<p className="deck-save-status" role="status"> <p className="deck-save-status" role="status">
デッキを読み込んでいます… Loading decks…
</p> </p>
) : null} ) : null}
{profiles.isError ? ( {profiles.isError ? (
<p role="alert" className="deck-error"> <p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。 Unable to load connection profiles.
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => void profiles.refetch()} onClick={() => void profiles.refetch()}
> >
再試行 Retry
</Button> </Button>
</p> </p>
) : null} ) : null}
{oauthResult === 'failed' ? ( {oauthResult === 'failed' ? (
<p className="deck-error" role="alert"> <p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。 Could not complete Mastodon authorization. Try again from
Connected accounts.
</p> </p>
) : null} ) : null}
{oauthResult === 'connected' ? ( {oauthResult === 'connected' ? (
<p className="deck-save-status" role="status"> <p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。 Mastodon account connected. You can now use it in a column.
</p> </p>
) : null} ) : null}
{profiles.data?.relayError ? ( {profiles.data?.relayError ? (
@@ -300,13 +301,13 @@ export function DeckPage() {
</p> </p>
) : null} ) : null}
{profiles.isPending ? ( {profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p> <p role="status">Loading connection profiles…</p>
) : null} ) : null}
{!renaming && editing === null && !switcherEditing {!renaming && editing === null && !switcherEditing
? storageErrorNotice ? storageErrorNotice
: null} : null}
{renaming ? ( {renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}> <Dialog title="Rename deck" onClose={() => setRenaming(false)}>
<form <form
className="deck-rename" className="deck-rename"
onSubmit={async (event) => { onSubmit={async (event) => {
@@ -319,7 +320,7 @@ export function DeckPage() {
}} }}
> >
<label htmlFor="deck-title"> <label htmlFor="deck-title">
調査テーマ・デッキ名 Research topic / deck name
<Input <Input
id="deck-title" id="deck-title"
name="title" name="title"
@@ -329,7 +330,7 @@ export function DeckPage() {
/> />
</label> </label>
<Button variant="outline" type="submit"> <Button variant="outline" type="submit">
名前を保存 Save name
</Button> </Button>
</form> </form>
{storageErrorNotice} {storageErrorNotice}
@@ -337,7 +338,7 @@ export function DeckPage() {
) : null} ) : null}
{editing !== null ? ( {editing !== null ? (
<Dialog <Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'} title={editing === 'new' ? 'Add column' : 'Edit column'}
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
> >
<ColumnEditor <ColumnEditor
@@ -356,7 +357,7 @@ export function DeckPage() {
) : null} ) : null}
{removed ? ( {removed ? (
<div className="deck-undo" role="status"> <div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span> <span>Deleted “{removed.column.title}”.</span>
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
@@ -369,7 +370,7 @@ export function DeckPage() {
if (await save({ ...deck, columns })) setRemoved(undefined) if (await save({ ...deck, columns })) setRemoved(undefined)
}} }}
> >
元に戻す Undo
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -380,16 +381,17 @@ export function DeckPage() {
<Icon name="columns" /> <Icon name="columns" />
</EmptyMedia> </EmptyMedia>
<EmptyTitle> <EmptyTitle>
<h2>調べたいことから、デッキを作りましょう</h2> <h2>Start a deck with a question</h2>
</EmptyTitle> </EmptyTitle>
<EmptyDescription> <EmptyDescription>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。 Tell the assistant what you want to research to create
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。 relevant columns. You can also add search, user posts, and
list columns yourself.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
<EmptyContent> <EmptyContent>
<Button type="button" onClick={() => setEditing('new')}> <Button type="button" onClick={() => setEditing('new')}>
手動でカラムを追加 Add column manually
</Button> </Button>
</EmptyContent> </EmptyContent>
</Empty> </Empty>
@@ -397,7 +399,7 @@ export function DeckPage() {
{ready && deck.columns.length > 0 ? ( {ready && deck.columns.length > 0 ? (
<section <section
className="deck-board" className="deck-board"
aria-label="調査カラム" aria-label="Research columns"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board. // biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
tabIndex={0} tabIndex={0}
> >
+16 -16
View File
@@ -35,7 +35,7 @@ export function DeckSwitcher({
return ( return (
<div className="deck-workspaces"> <div className="deck-workspaces">
<div className="deck-actions"> <div className="deck-actions">
<Label htmlFor="deck-selection">デッキプロファイル</Label> <Label htmlFor="deck-selection">Deck profile</Label>
<NativeSelect <NativeSelect
className="w-full" className="w-full"
id="deck-selection" id="deck-selection"
@@ -49,15 +49,15 @@ export function DeckSwitcher({
{decks.map((deck) => ( {decks.map((deck) => (
<NativeSelectOption key={deck.id} value={deck.id}> <NativeSelectOption key={deck.id} value={deck.id}>
{deck.title} {deck.title}
{deck.persisted ? '' : '(一時)'} {deck.persisted ? '' : ' (temporary)'}
</NativeSelectOption> </NativeSelectOption>
))} ))}
</NativeSelect> </NativeSelect>
<Button <Button
variant="outline" variant="outline"
className="workspace-create" className="workspace-create"
aria-label="デッキを作成" aria-label="Create deck"
title="デッキを作成" title="Create deck"
type="button" type="button"
disabled={!ready} disabled={!ready}
onClick={() => { onClick={() => {
@@ -65,13 +65,13 @@ export function DeckSwitcher({
}} }}
> >
<Icon name="plus" /> <Icon name="plus" />
<span>デッキを作成</span> <span>Create deck</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
className="workspace-delete" className="workspace-delete"
aria-label="デッキを削除" aria-label="Delete deck"
title="デッキを削除" title="Delete deck"
type="button" type="button"
disabled={!ready} disabled={!ready}
onClick={() => { onClick={() => {
@@ -79,11 +79,11 @@ export function DeckSwitcher({
}} }}
> >
<Icon name="trash" /> <Icon name="trash" />
<span>デッキを削除</span> <span>Delete deck</span>
</Button> </Button>
</div> </div>
{dialog === 'create' ? ( {dialog === 'create' ? (
<Dialog title="デッキを作成" onClose={() => onDialogChange(null)}> <Dialog title="Create deck" onClose={() => onDialogChange(null)}>
<form <form
className="deck-rename" className="deck-rename"
onSubmit={async (event) => { onSubmit={async (event) => {
@@ -94,7 +94,7 @@ export function DeckSwitcher({
}} }}
> >
<Label htmlFor="new-deck-title"> <Label htmlFor="new-deck-title">
新しいデッキ名 New deck name
<Input <Input
id="new-deck-title" id="new-deck-title"
name="title" name="title"
@@ -104,22 +104,22 @@ export function DeckSwitcher({
required required
/> />
</Label> </Label>
<Button type="submit">作成</Button> <Button type="submit">Create</Button>
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => onDialogChange(null)} onClick={() => onDialogChange(null)}
> >
キャンセル Cancel
</Button> </Button>
</form> </form>
{errorNotice} {errorNotice}
</Dialog> </Dialog>
) : null} ) : null}
{dialog === 'delete' ? ( {dialog === 'delete' ? (
<Dialog title="デッキを削除" onClose={() => onDialogChange(null)}> <Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
<div className="deck-undo"> <div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p> <p>Delete this deck and its column settings.</p>
<Button <Button
variant="destructive" variant="destructive"
type="button" type="button"
@@ -127,14 +127,14 @@ export function DeckSwitcher({
if (await onDelete()) onDialogChange(null) if (await onDelete()) onDialogChange(null)
}} }}
> >
削除する Delete
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => onDialogChange(null)} onClick={() => onDialogChange(null)}
> >
キャンセル Cancel
</Button> </Button>
</div> </div>
{errorNotice} {errorNotice}
+2 -2
View File
@@ -29,7 +29,7 @@ export function importLegacyWorkspace(
if (marker.payloadHash !== payloadHash) if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError( throw new DeckPersistenceError(
'conflict', 'conflict',
'旧デッキはすでに取り込み済みです。別の内容で上書きできません。', 'Legacy decks have already been imported and cannot be overwritten.',
) )
return listDecks(database) return listDecks(database)
} }
@@ -56,7 +56,7 @@ export function importLegacyWorkspace(
if (!connection) if (!connection)
throw new DeckPersistenceError( throw new DeckPersistenceError(
'connection-unavailable', 'connection-unavailable',
`旧プロファイル「${profileName}」に対応する接続がありません。接続一覧を更新してください。`, `No connection matches the legacy profile “${profileName}”. Refresh your connections.`,
) )
return { ...column, connectionId: connection.id } return { ...column, connectionId: connection.id }
}), }),
+2 -2
View File
@@ -77,7 +77,7 @@ it('blocks a different browser snapshot after the one-time import', () => {
database, database,
'https://relay.invalid', 'https://relay.invalid',
), ),
).toThrow('旧デッキはすでに取り込み済みです') ).toThrow('Legacy decks have already been imported')
expect(listDecks(database)).toEqual(result) expect(listDecks(database)).toEqual(result)
}) })
it('rolls back all imported decks and marker when any profile is unavailable', () => { it('rolls back all imported decks and marker when any profile is unavailable', () => {
@@ -92,7 +92,7 @@ it('rolls back all imported decks and marker when any profile is unavailable', (
database, database,
'https://relay.invalid', 'https://relay.invalid',
), ),
).toThrow('対応する接続がありません') ).toThrow('No connection matches')
expect(listDecks(database)).toEqual([]) expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([]) expect(database.select().from(legacyImports).all()).toEqual([])
}) })
+2 -2
View File
@@ -16,7 +16,7 @@ const legacyDeck = z
(deck) => (deck) =>
new Set(deck.columns.map((column) => column.id)).size === new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length, deck.columns.length,
'カラム ID が重複しています。', 'Column IDs must be unique.',
) )
export const legacyWorkspaceSchema = z export const legacyWorkspaceSchema = z
.object({ .object({
@@ -28,5 +28,5 @@ export const legacyWorkspaceSchema = z
.refine( .refine(
(value) => (value) =>
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length, new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
'デッキ ID が重複しています。', 'Deck IDs must be unique.',
) )
+20 -19
View File
@@ -37,7 +37,7 @@ export function MastodonSourceEditor({
return ( return (
<> <>
<Label htmlFor={id}> <Label htmlFor={id}>
カラムの種類 Column type
<NativeSelect <NativeSelect
className="w-full" className="w-full"
id={id} id={id}
@@ -50,10 +50,10 @@ export function MastodonSourceEditor({
) )
} }
> >
<NativeSelectOption value="search">検索</NativeSelectOption> <NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption> <NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption> <NativeSelectOption value="list">List</NativeSelectOption>
<NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption> <NativeSelectOption value="hashtag">Hashtag</NativeSelectOption>
</NativeSelect> </NativeSelect>
</Label> </Label>
<MastodonSourceFields <MastodonSourceFields
@@ -96,7 +96,7 @@ function MastodonSearchEditor({
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
Mastodonの検索条件 Mastodon search query
<Input <Input
id={id} id={id}
value={source.query} value={source.query}
@@ -104,12 +104,13 @@ function MastodonSearchEditor({
onChange({ ...source, query: event.target.value }) onChange({ ...source, query: event.target.value })
} }
maxLength={512} maxLength={512}
placeholder="例:WebMCP" placeholder="e.g. WebMCP"
required required
/> />
</Label> </Label>
<p className="deck-help"> <p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。 Full-text search depends on server settings. Zero results do not
necessarily mean search is supported.
</p> </p>
</div> </div>
) )
@@ -123,14 +124,14 @@ function MastodonUserEditor({
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
ユーザー User
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
onChange({ ...source, target: event.target.value }) onChange({ ...source, target: event.target.value })
} }
placeholder="user@server またはアカウントID" placeholder="user@server or account ID"
maxLength={256} maxLength={256}
required required
/> />
@@ -147,14 +148,14 @@ function MastodonHashtagEditor({
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
ハッシュタグ Hashtag
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
onChange({ ...source, target: event.target.value }) onChange({ ...source, target: event.target.value })
} }
placeholder="# を除いたタグ名" placeholder="Tag without #"
maxLength={128} maxLength={128}
required required
/> />
@@ -174,14 +175,14 @@ function MastodonListEditor({
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
リスト List
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
onChange({ ...source, target: event.target.value }) onChange({ ...source, target: event.target.value })
} }
placeholder="このアカウントのリストID" placeholder="List ID for this account"
maxLength={32} maxLength={32}
required required
/> />
@@ -209,24 +210,24 @@ function MastodonListChoices({
queryFn: () => fetchLists({ data: { connectionId } }), queryFn: () => fetchLists({ data: { connectionId } }),
retry: false, retry: false,
}) })
if (lists.isPending) return <p role="status">リストを取得しています…</p> if (lists.isPending) return <p role="status">Loading lists…</p>
if (lists.isError) if (lists.isError)
return ( return (
<p role="alert"> <p role="alert">
リストを取得できませんでした。 Unable to load lists.
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => void lists.refetch()} onClick={() => void lists.refetch()}
> >
再試行 Retry
</Button> </Button>
</p> </p>
) )
return ( return (
<section className="deck-list-choices" aria-label="このアカウントのリスト"> <section className="deck-list-choices" aria-label="Lists for this account">
{lists.data.lists.length === 0 ? ( {lists.data.lists.length === 0 ? (
<p>このアカウントのリストはありません。</p> <p>This account has no lists.</p>
) : ( ) : (
lists.data.lists.map((list) => ( lists.data.lists.map((list) => (
<Button <Button
+4 -7
View File
@@ -24,9 +24,7 @@ const searchSourceSchema = z
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
message: message:
error instanceof Error error instanceof Error ? error.message : 'Check the search query.',
? error.message
: '検索条件を確認してください。',
}) })
} }
}) })
@@ -43,8 +41,7 @@ function targetSchema(normalize: (target: string) => string) {
} catch (error) { } catch (error) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
message: message: error instanceof Error ? error.message : 'Check the target.',
error instanceof Error ? error.message : '対象を確認してください。',
}) })
return z.NEVER return z.NEVER
} }
@@ -93,7 +90,7 @@ export const deckSchema = z
(deck) => (deck) =>
new Set(deck.columns.map((column) => column.id)).size === new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length, deck.columns.length,
'カラム ID が重複しています。', 'Column IDs must be unique.',
) )
export type Deck = z.infer<typeof deckSchema> export type Deck = z.infer<typeof deckSchema>
@@ -102,7 +99,7 @@ export type DeckView = Deck & { persisted: boolean; revision?: number }
export type Workspace = { activeDeckId: string; decks: DeckView[] } export type Workspace = { activeDeckId: string; decks: DeckView[] }
export const EMPTY_DECK: Deck = { export const EMPTY_DECK: Deck = {
id: 'initial', id: 'initial',
title: '新しい調査', title: 'New research',
columns: [], columns: [],
} }
export const EMPTY_WORKSPACE: Workspace = { export const EMPTY_WORKSPACE: Workspace = {
+3 -3
View File
@@ -22,12 +22,12 @@ export class DeckPersistenceError extends Error {
const conflict = () => const conflict = () =>
new DeckPersistenceError( new DeckPersistenceError(
'conflict', 'conflict',
'別の端末で変更されました。最新のデッキを読み直してください。', 'This deck changed on another device. Reload the latest deck.',
) )
const missing = () => const missing = () =>
new DeckPersistenceError( new DeckPersistenceError(
'not-found', 'not-found',
'このデッキは削除されたか、見つかりません。', 'This deck was deleted or could not be found.',
) )
export function loadDeck( export function loadDeck(
@@ -79,7 +79,7 @@ function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
) { ) {
throw new DeckPersistenceError( throw new DeckPersistenceError(
'connection-unavailable', 'connection-unavailable',
'カラムの接続アカウントが利用できません。接続を確認してください。', 'The account for this column is unavailable. Check your connections.',
) )
} }
} }
+9 -9
View File
@@ -84,7 +84,7 @@ it('makes retried creation idempotent but rejects different content using the sa
expect(createDeck(deck, second)).toEqual(saved) expect(createDeck(deck, second)).toEqual(saved)
expect(() => expect(() =>
createDeck({ ...deck, title: 'Different request' }, second), createDeck({ ...deck, title: 'Different request' }, second),
).toThrow('別の端末で変更されました') ).toThrow('This deck changed on another device')
expect(listDecks(database)).toEqual([saved]) expect(listDecks(database)).toEqual([saved])
}) })
@@ -105,7 +105,7 @@ it('replaces title, ordered columns and bindings atomically, then rejects stale
createdAt: initial.createdAt, createdAt: initial.createdAt,
}) })
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow( expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'別の端末で変更されました', 'This deck changed on another device',
) )
expect(loadDeck(deck.id, second)).toEqual(saved) expect(loadDeck(deck.id, second)).toEqual(saved)
}) })
@@ -119,7 +119,7 @@ it('clears all columns and prevents stale deletion from another device', () => {
expect(loadDeck(deck.id, second)?.columns).toEqual([]) expect(loadDeck(deck.id, second)?.columns).toEqual([])
expect(() => expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second), deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('別の端末で変更されました') ).toThrow('This deck changed on another device')
expect(loadDeck(deck.id, second)).toEqual(saved) expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id) expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull() expect(loadDeck(deck.id, database)).toBeNull()
@@ -130,11 +130,11 @@ it('cascades deleted deck columns and reports already-deleted decks', () => {
deleteDeck({ id: deck.id, expectedRevision: 1 }, database) deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
expect(database.select().from(deckColumns).all()).toEqual([]) expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow( expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'このデッキは削除されたか', 'This deck was deleted',
) )
expect(() => expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second), deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('このデッキは削除されたか') ).toThrow('This deck was deleted')
}) })
it.each([ it.each([
@@ -152,7 +152,7 @@ it.each([
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 }, { deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database, database,
), ),
).toThrow('カラムの接続アカウントが利用できません') ).toThrow('The account for this column is unavailable')
expect(loadDeck(deck.id, second)).toEqual(initial) expect(loadDeck(deck.id, second)).toEqual(initial)
}) })
@@ -163,7 +163,7 @@ it('rejects a connection from another platform', () => {
.where(eq(connections.id, 'twitter-one')) .where(eq(connections.id, 'twitter-one'))
.run() .run()
expect(() => createDeck(deck, database)).toThrow( expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません', 'The account for this column is unavailable',
) )
expect(listDecks(database)).toEqual([]) expect(listDecks(database)).toEqual([])
}) })
@@ -171,7 +171,7 @@ it('rejects a connection from another platform', () => {
it('rejects unknown connections without creating rows', () => { it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run() database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow( expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません', 'The account for this column is unavailable',
) )
expect(listDecks(database)).toEqual([]) expect(listDecks(database)).toEqual([])
}) })
@@ -186,7 +186,7 @@ it('rejects duplicate column IDs without changing the saved version', () => {
}, },
database, database,
), ),
).toThrow('カラム ID が重複しています') ).toThrow('Column IDs must be unique')
expect(loadDeck(deck.id, database)).toEqual(saved) expect(loadDeck(deck.id, database)).toEqual(saved)
}) })
@@ -35,8 +35,8 @@ it('focuses and briefly highlights a cited card without revealing sensitive cont
inline: 'center', inline: 'center',
behavior: 'instant', behavior: 'instant',
}) })
expect(screen.getByText('調査時に取得した投稿')).toBeInTheDocument() expect(screen.getByText('Post saved during research')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '内容を表示' })).toHaveAttribute( expect(screen.getByRole('button', { name: 'Show content' })).toHaveAttribute(
'aria-expanded', 'aria-expanded',
'false', 'false',
) )
@@ -44,7 +44,9 @@ it('focuses and briefly highlights a cited card without revealing sensitive cont
rerender(<ResearchPostCard post={post} citationRequest={1} />) rerender(<ResearchPostCard post={post} citationRequest={1} />)
expect(scroll).toHaveBeenCalledTimes(2) expect(scroll).toHaveBeenCalledTimes(2)
expect(article).toHaveFocus() expect(article).toHaveFocus()
expect(screen.queryByText('調査時に取得した投稿')).not.toBeInTheDocument() expect(
screen.queryByText('Post saved during research'),
).not.toBeInTheDocument()
act(() => { act(() => {
vi.advanceTimersByTime(2500) vi.advanceTimersByTime(2500)
}) })
@@ -76,12 +78,12 @@ it('waits for reveal before mounting CW content or sensitive media', () => {
], ],
} }
render(<ResearchPostCard post={post} />) render(<ResearchPostCard post={post} />)
expect(screen.getByText('Bob がブースト')).toBeInTheDocument() expect(screen.getByText('Boosted by Bob')).toBeInTheDocument()
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument() expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument() expect(screen.queryByText('content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '内容を表示' })) fireEvent.click(screen.getByRole('button', { name: 'Show content' }))
expect(screen.getByAltText('Hidden image')).toBeInTheDocument() expect(screen.getByAltText('Hidden image')).toBeInTheDocument()
expect(screen.getByText('content').tagName).toBe('STRONG') expect(screen.getByText('content').tagName).toBe('STRONG')
fireEvent.click(screen.getByRole('button', { name: '内容を隠す' })) fireEvent.click(screen.getByRole('button', { name: 'Hide content' }))
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument() expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
}) })
+8 -8
View File
@@ -6,7 +6,7 @@ import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button' import { Button } from '#/components/ui/button'
import type { ResearchPost } from '#/features/platforms/types' import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', { const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'numeric', month: 'numeric',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
@@ -53,10 +53,10 @@ export function ResearchPostCard({
data-post-url={post.url} data-post-url={post.url}
> >
{citationSnapshot ? ( {citationSnapshot ? (
<Badge variant="secondary">調査時に取得した投稿</Badge> <Badge variant="secondary">Post saved during research</Badge>
) : null} ) : null}
{post.boostedBy ? ( {post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p> <p className="handle">Boosted by {post.boostedBy.name}</p>
) : null} ) : null}
<header> <header>
<Avatar className="size-9 shrink-0"> <Avatar className="size-9 shrink-0">
@@ -77,7 +77,7 @@ export function ResearchPostCard({
</header> </header>
{concealed ? ( {concealed ? (
<div> <div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p> <p>{post.contentWarning || 'This post contains sensitive media'}</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -85,7 +85,7 @@ export function ResearchPostCard({
aria-expanded={revealed} aria-expanded={revealed}
onClick={() => setRevealed(!revealed)} onClick={() => setRevealed(!revealed)}
> >
{revealed ? '内容を隠す' : '内容を表示'} {revealed ? 'Hide content' : 'Show content'}
</Button> </Button>
</div> </div>
) : null} ) : null}
@@ -118,7 +118,7 @@ export function ResearchPostCard({
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
動画を開く Open video
</a> </a>
), ),
)} )}
@@ -133,7 +133,7 @@ export function ResearchPostCard({
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
引用元を開く Open quoted post
</a> </a>
</blockquote> </blockquote>
) : null} ) : null}
@@ -141,7 +141,7 @@ export function ResearchPostCard({
) : null} ) : null}
<footer> <footer>
<a href={post.url} target="_blank" rel="noreferrer noopener"> <a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く <Icon name="arrow" /> Open original post <Icon name="arrow" />
</a> </a>
</footer> </footer>
</article> </article>
+2 -2
View File
@@ -24,14 +24,14 @@ async function run<T>(
ok: false, ok: false,
error: { error: {
code: 'invalid-input', code: 'invalid-input',
message: 'デッキの入力内容を確認してください。', message: 'Check the deck details.',
}, },
} }
return { return {
ok: false, ok: false,
error: { error: {
code: 'storage-unavailable', code: 'storage-unavailable',
message: 'デッキを保存・取得できませんでした。再試行してください。', message: 'Unable to save or load the deck. Please try again.',
}, },
} }
} }
+7 -7
View File
@@ -10,25 +10,25 @@ export function describeSource(source: DeckColumn['source']): {
switch (source.kind) { switch (source.kind) {
case 'search': case 'search':
return { return {
label: `${source.product === 'Latest' ? '最新' : '話題'}${source.following ? ' · フォロー中' : ''}`, label: `${source.product === 'Latest' ? 'Latest' : 'Top'}${source.following ? ' · Following' : ''}`,
query: source.query, query: source.query,
} }
case 'user': case 'user':
return { label: 'ユーザー投稿', query: source.target } return { label: 'User posts', query: source.target }
case 'list': case 'list':
return { label: 'リスト', query: source.target } return { label: 'List', query: source.target }
} }
break break
case 'mastodon': case 'mastodon':
switch (source.kind) { switch (source.kind) {
case 'search': case 'search':
return { label: '全文検索', query: source.query } return { label: 'Full-text search', query: source.query }
case 'user': case 'user':
return { label: 'ユーザー投稿', query: source.target } return { label: 'User posts', query: source.target }
case 'list': case 'list':
return { label: 'リスト', query: source.target } return { label: 'List', query: source.target }
case 'hashtag': case 'hashtag':
return { label: 'ハッシュタグ', query: `#${source.target}` } return { label: 'Hashtag', query: `#${source.target}` }
} }
break break
} }
+21 -24
View File
@@ -39,7 +39,7 @@ export function TwitterSourceEditor({
return ( return (
<> <>
<Label htmlFor={id}> <Label htmlFor={id}>
カラムの種類 Column type
<NativeSelect <NativeSelect
className="w-full" className="w-full"
id={id} id={id}
@@ -48,9 +48,9 @@ export function TwitterSourceEditor({
onChange(defaultTwitterSource(event.target.value as Source['kind'])) onChange(defaultTwitterSource(event.target.value as Source['kind']))
} }
> >
<NativeSelectOption value="search">検索</NativeSelectOption> <NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption> <NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption> <NativeSelectOption value="list">List</NativeSelectOption>
</NativeSelect> </NativeSelect>
</Label> </Label>
<TwitterSourceFields <TwitterSourceFields
@@ -91,7 +91,7 @@ function TwitterSearchEditor({
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={`${id}-query`}> <Label htmlFor={`${id}-query`}>
Twitterの検索条件 Twitter search query
<Input <Input
id={`${id}-query`} id={`${id}-query`}
value={source.query} value={source.query}
@@ -99,12 +99,12 @@ function TwitterSearchEditor({
onChange({ ...source, query: event.target.value }) onChange({ ...source, query: event.target.value })
} }
maxLength={512} maxLength={512}
placeholder="例:WebMCP lang:ja" placeholder="e.g. WebMCP lang:en"
required required
/> />
</Label> </Label>
<Label htmlFor={`${id}-product`}> <Label htmlFor={`${id}-product`}>
表示順 Sort by
<NativeSelect <NativeSelect
className="w-full" className="w-full"
id={`${id}-product`} id={`${id}-product`}
@@ -116,12 +116,12 @@ function TwitterSearchEditor({
}) })
} }
> >
<NativeSelectOption value="Latest">最新</NativeSelectOption> <NativeSelectOption value="Latest">Latest</NativeSelectOption>
<NativeSelectOption value="Top">話題</NativeSelectOption> <NativeSelectOption value="Top">Top</NativeSelectOption>
</NativeSelect> </NativeSelect>
</Label> </Label>
<p className="deck-help"> <p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。 Use Twitter search operators such as from:, lang:, and since:.
</p> </p>
<Label className="deck-checkbox"> <Label className="deck-checkbox">
<Checkbox <Checkbox
@@ -130,7 +130,7 @@ function TwitterSearchEditor({
onChange({ ...source, following: checked }) onChange({ ...source, following: checked })
} }
/> />
フォロー中のみ Following only
</Label> </Label>
</div> </div>
) )
@@ -144,14 +144,14 @@ function TwitterUserEditor({
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
ユーザー User
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
onChange({ ...source, target: event.target.value }) onChange({ ...source, target: event.target.value })
} }
placeholder="@handle またはプロフィールURL" placeholder="@handle or profile URL"
maxLength={256} maxLength={256}
required required
/> />
@@ -169,14 +169,14 @@ function TwitterListEditor({
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<Label htmlFor={id}> <Label htmlFor={id}>
リスト List
<Input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
onChange({ ...source, target: event.target.value }) onChange({ ...source, target: event.target.value })
} }
placeholder="リストのURLまたはID" placeholder="List URL or ID"
maxLength={256} maxLength={256}
required required
/> />
@@ -199,27 +199,24 @@ function TwitterListChoices({
onSelect: (target: string) => void onSelect: (target: string) => void
}) { }) {
const lists = useListChoices(connectionId) const lists = useListChoices(connectionId)
if (lists.isPending) return <p role="status">リストを取得しています…</p> if (lists.isPending) return <p role="status">Loading lists…</p>
if (lists.isError) if (lists.isError)
return ( return (
<p role="alert"> <p role="alert">
リストを取得できませんでした。 Unable to load lists.
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => void lists.refetch()} onClick={() => void lists.refetch()}
> >
再試行 Retry
</Button> </Button>
</p> </p>
) )
return ( return (
<section <section className="deck-list-choices" aria-label="Lists for this profile">
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
{lists.data.length === 0 ? ( {lists.data.length === 0 ? (
<p>リストはありません。URLまたはIDでも指定できます。</p> <p>No lists found. You can enter a URL or ID instead.</p>
) : ( ) : (
lists.data.map((list) => ( lists.data.map((list) => (
<Button <Button
@@ -229,7 +226,7 @@ function TwitterListChoices({
onClick={() => onSelect(list.id)} onClick={() => onSelect(list.id)}
> >
{list.name} {list.name}
{list.isPrivate ? ' · 非公開' : ''} {list.isPrivate ? ' · Private' : ''}
</Button> </Button>
)) ))
)} )}
+1 -1
View File
@@ -194,7 +194,7 @@ it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken') localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck()) const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true)) await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('読み込めませんでした') expect(result.current.storageError).toContain('Unable to load')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken') expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
}) })
+9 -11
View File
@@ -88,7 +88,7 @@ export function useDeck(editing = false) {
const id = newId() const id = newId()
if (preferred === view.id) preferred = id if (preferred === view.id) preferred = id
setStorageError( setStorageError(
'保存済みの内容と異なる編集を、一時コピーとして残しました。', 'Your unsaved changes have been kept as a temporary copy.',
) )
return [{ ...view, id }] return [{ ...view, id }]
}) })
@@ -106,7 +106,7 @@ export function useDeck(editing = false) {
? preferred ? preferred
: (all[0]?.id ?? '') : (all[0]?.id ?? '')
if (initialized.current && activeDeckId !== preferred) if (initialized.current && activeDeckId !== preferred)
setStorageError('表示中のデッキが別の端末で削除されました。') setStorageError('This deck was deleted on another device.')
initialized.current = true initialized.current = true
apply({ activeDeckId, decks: all }) apply({ activeDeckId, decks: all })
}, },
@@ -138,9 +138,7 @@ export function useDeck(editing = false) {
requestGeneration !== generation.current requestGeneration !== generation.current
) )
return false return false
setStorageError( setStorageError('Unable to load saved decks. Please try again.')
'保存済みデッキを読み込めませんでした。再試行してください。',
)
return false return false
} }
}, },
@@ -156,7 +154,7 @@ export function useDeck(editing = false) {
} }
} catch { } catch {
setStorageError( setStorageError(
'旧デッキを読み込めませんでした。ブラウザの保存内容は変更していません。', 'Unable to load legacy decks. Your browser data has not been changed.',
) )
} }
void refresh() void refresh()
@@ -176,7 +174,7 @@ export function useDeck(editing = false) {
accept: (value: T) => void, accept: (value: T) => void,
) { ) {
if (busy.current) { if (busy.current) {
setStorageError('保存処理が完了するまでお待ちください。') setStorageError('Please wait until saving finishes.')
return false return false
} }
busy.current = true busy.current = true
@@ -191,7 +189,7 @@ export function useDeck(editing = false) {
setStorageError( setStorageError(
error instanceof Error error instanceof Error
? error.message ? error.message
: '保存できませんでした。再試行してください。', : 'Unable to save. Please try again.',
) )
return false return false
} finally { } finally {
@@ -233,7 +231,7 @@ export function useDeck(editing = false) {
} }
function select(id: string) { function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id)) if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。') throw new Error('Deck not found.')
apply({ ...current.current, activeDeckId: id }) apply({ ...current.current, activeDeckId: id })
return true return true
} }
@@ -247,7 +245,7 @@ export function useDeck(editing = false) {
} }
async function persist(id: string) { async function persist(id: string) {
const view = current.current.decks.find((deck) => deck.id === id) const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。') if (!view) throw new Error('Deck not found.')
if (view.persisted) return true if (view.persisted) return true
return mutate( return mutate(
async () => { async () => {
@@ -266,7 +264,7 @@ export function useDeck(editing = false) {
} }
async function remove(id: string, expectedRevision?: number) { async function remove(id: string, expectedRevision?: number) {
const view = current.current.decks.find((deck) => deck.id === id) const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('デッキが見つかりません。') if (!view) throw new Error('Deck not found.')
const accept = () => { const accept = () => {
const previous = current.current const previous = current.current
const decks = previous.decks.filter((deck) => deck.id !== id) const decks = previous.decks.filter((deck) => deck.id !== id)
+10 -10
View File
@@ -24,11 +24,11 @@ export function DailyBrief({
) )
return ( return (
<section className="home-brief" aria-label="今日のブリーフ"> <section className="home-brief" aria-label="Daily brief">
<p> <p>
{focus {focus
? `今日は「${focus.title}」から始められそうです。` ? `Start with “${focus.title}” today.`
: 'いま優先して取りかかるタスクはありません。'} : 'There are no priority tasks right now.'}
{firstContact ? ( {firstContact ? (
<> <>
{' '} {' '}
@@ -36,27 +36,27 @@ export function DailyBrief({
type="button" type="button"
onClick={() => onOpenContact(firstContact.id)} onClick={() => onOpenContact(firstContact.id)}
> >
{firstContact.name}さんへの返信 Reply to {firstContact.name}
</button> </button>
{contacts.length > 1 {contacts.length > 1
? `など、確認できる連絡が${contacts.length}件あります。` ? ` — one of ${contacts.length} conversations to review.`
: 'が残っています。'} : ' when you’re ready.'}
</> </>
) : ( ) : (
' いま返信に取りかかる連絡もありません。' ' There are no conversations awaiting your attention.'
)} )}
</p> </p>
{resume ? ( {resume ? (
<p className="home-brief-secondary"> <p className="home-brief-secondary">
再開メモには「 Your resume note: “
{resume.text.length > 100 {resume.text.length > 100
? `${resume.text.slice(0, 100)}…` ? `${resume.text.slice(0, 100)}…`
: resume.text} : resume.text}
」と残っています。 ”
</p> </p>
) : optional ? ( ) : optional ? (
<p className="home-brief-secondary"> <p className="home-brief-secondary">
「{optional.title}」は、余裕があるときに。 “{optional.title}” can wait until you have room.
</p> </p>
) : null} ) : null}
</section> </section>
+49 -48
View File
@@ -23,7 +23,10 @@ import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { articles } from '#/features/inbox/mock-articles' import { articles } from '#/features/inbox/mock-articles'
import { ConversationDetail } from '#/features/support/conversation-detail' import { ConversationDetail } from '#/features/support/conversation-detail'
import { conversations } from '#/features/support/mock-conversations' import { conversations } from '#/features/support/mock-conversations'
import { useWorkspaceState } from '#/features/workspace/workspace-state' import {
entryKindLabels,
useWorkspaceState,
} from '#/features/workspace/workspace-state'
import { DailyBrief } from './daily-brief' import { DailyBrief } from './daily-brief'
import { type HomeAction, interpretAction } from './interpret-action' import { type HomeAction, interpretAction } from './interpret-action'
import './home.css' import './home.css'
@@ -138,7 +141,7 @@ export function HomePage() {
void navigate({ to: action.to }) void navigate({ to: action.to })
break break
} }
setNotice(proposal?.label ?? '反映しました') setNotice(proposal?.label ?? 'Updated')
setProposal(null) setProposal(null)
setPrompt('') setPrompt('')
} }
@@ -171,8 +174,8 @@ export function HomePage() {
<div className="home-scroll"> <div className="home-scroll">
<div className="home-content"> <div className="home-content">
<header className="home-greeting"> <header className="home-greeting">
<p className="text-sm text-muted-foreground">今日</p> <p className="text-sm text-muted-foreground">Today</p>
<h1>おはようございます、Yutaさん</h1> <h1>Good morning, Yuta</h1>
<DailyBrief onOpenContact={setContactId} /> <DailyBrief onOpenContact={setContactId} />
</header> </header>
<form <form
@@ -184,8 +187,8 @@ export function HomePage() {
> >
<Textarea <Textarea
ref={input} ref={input}
aria-label="ホームへの入力" aria-label="Home input"
placeholder="頭にあることを、そのまま。" placeholder="What’s on your mind?"
value={prompt} value={prompt}
onChange={(event) => { onChange={(event) => {
setPrompt(event.target.value) setPrompt(event.target.value)
@@ -205,12 +208,12 @@ export function HomePage() {
/> />
<div className="home-composer-footer"> <div className="home-composer-footer">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
やること、返信、メモ。 Tasks, replies, notes.
</span> </span>
<Button <Button
type="submit" type="submit"
size="icon" size="icon"
aria-label="候補を表示" aria-label="Preview action"
disabled={!prompt.trim()} disabled={!prompt.trim()}
> >
<ArrowUp /> <ArrowUp />
@@ -219,9 +222,9 @@ export function HomePage() {
</form> </form>
<div className="home-suggestions"> <div className="home-suggestions">
{[ {[
'買い物を追加', 'Add grocery shopping',
'青木さんに返信', 'Reply to Aoki',
'メモ:次は資料の2ページ目から', 'Note: Resume on page 2',
].map((text) => ( ].map((text) => (
<button <button
type="button" type="button"
@@ -236,13 +239,11 @@ export function HomePage() {
{proposal && ( {proposal && (
<div className="home-proposal"> <div className="home-proposal">
<p>{proposal.label}</p> <p>{proposal.label}</p>
<Button onClick={() => apply(proposal.action)}> <Button onClick={() => apply(proposal.action)}>Apply</Button>
実行する
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="候補を取り消す" aria-label="Dismiss suggestion"
onClick={() => setProposal(null)} onClick={() => setProposal(null)}
> >
<X /> <X />
@@ -253,12 +254,12 @@ export function HomePage() {
{notice} {notice}
</p> </p>
<div className="home-columns"> <div className="home-columns">
<section className="home-plan" aria-label="今日の予定"> <section className="home-plan" aria-label="Today’s plan">
{( {(
[ [
{ id: 'focus', label: 'まずこれ' }, { id: 'focus', label: 'Start here' },
{ id: 'routine', label: 'いつものこと' }, { id: 'routine', label: 'Routines' },
{ id: 'optional', label: '余裕があれば' }, { id: 'optional', label: 'If there’s room' },
] as const ] as const
).map((section) => ( ).map((section) => (
<section key={section.id} className="home-section"> <section key={section.id} className="home-section">
@@ -267,14 +268,14 @@ export function HomePage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`${section.label}に追加`} aria-label={`Add to ${section.label}`}
onClick={() => onClick={() =>
suggest( suggest(
section.id === 'routine' section.id === 'routine'
? 'ルーティンに追加:' ? 'Add routine:'
: section.id === 'optional' : section.id === 'optional'
? '余裕があれば ' ? 'Optional: '
: '追加:', : 'Add:',
) )
} }
> >
@@ -292,7 +293,7 @@ export function HomePage() {
<button <button
type="button" type="button"
className="home-check" className="home-check"
aria-label={`${task.title}を完了`} aria-label={`Complete ${task.title}`}
onClick={() => onClick={() =>
setTasks((current) => setTasks((current) =>
current.map((item) => current.map((item) =>
@@ -309,7 +310,7 @@ export function HomePage() {
<p>{task.title}</p> <p>{task.title}</p>
{task.steps && ( {task.steps && (
<details> <details>
<summary>小さく進める</summary> <summary>One step at a time</summary>
<div className="home-steps"> <div className="home-steps">
{task.steps.map((step) => ( {task.steps.map((step) => (
<label <label
@@ -360,7 +361,7 @@ export function HomePage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`${task.title}を上へ`} aria-label={`Move ${task.title} up`}
onClick={() => moveTask(task.id, -1)} onClick={() => moveTask(task.id, -1)}
> >
<ArrowUp /> <ArrowUp />
@@ -368,7 +369,7 @@ export function HomePage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`${task.title}を下へ`} aria-label={`Move ${task.title} down`}
onClick={() => moveTask(task.id, 1)} onClick={() => moveTask(task.id, 1)}
> >
<ArrowDown /> <ArrowDown />
@@ -376,7 +377,7 @@ export function HomePage() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-label={`${task.title}をあとで`} aria-label={`Defer ${task.title}`}
onClick={() => onClick={() =>
setTasks((current) => setTasks((current) =>
current.map((item) => current.map((item) =>
@@ -387,7 +388,7 @@ export function HomePage() {
) )
} }
> >
あとで Later
</Button> </Button>
</div> </div>
</div> </div>
@@ -402,7 +403,7 @@ export function HomePage() {
onClick={() => setContactId(item.id)} onClick={() => setContactId(item.id)}
> >
<span className="block"> <span className="block">
{item.name}さんに返信 Reply to {item.name}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{item.subject} {item.subject}
@@ -411,7 +412,7 @@ export function HomePage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`${item.name}との会話を開く`} aria-label={`Open conversation with ${item.name}`}
onClick={() => setContactId(item.id)} onClick={() => setContactId(item.id)}
> >
<ChevronRight /> <ChevronRight />
@@ -424,7 +425,7 @@ export function HomePage() {
) && ) &&
(section.id !== 'focus' || !waiting.length) && ( (section.id !== 'focus' || !waiting.length) && (
<p className="home-empty"> <p className="home-empty">
ここは空けておいても大丈夫。 It’s okay to leave some room.
</p> </p>
)} )}
</section> </section>
@@ -432,7 +433,7 @@ export function HomePage() {
{rest.length > 0 && ( {rest.length > 0 && (
<details className="home-rest"> <details className="home-rest">
<summary> <summary>
あとで・終えたこと <span>{rest.length}</span> Later & done <span>{rest.length}</span>
</summary> </summary>
{rest.map((task) => ( {rest.map((task) => (
<div className="home-task" key={task.id}> <div className="home-task" key={task.id}>
@@ -445,7 +446,7 @@ export function HomePage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={`${task.title}を戻す`} aria-label={`Restore ${task.title}`}
onClick={() => onClick={() =>
setTasks((current) => setTasks((current) =>
current.map((item) => current.map((item) =>
@@ -463,11 +464,11 @@ export function HomePage() {
</details> </details>
)} )}
</section> </section>
<aside className="home-context" aria-label="今日のつながり"> <aside className="home-context" aria-label="Today’s context">
<section className="home-section"> <section className="home-section">
<header className="home-section-heading"> <header className="home-section-heading">
<h2>連絡</h2> <h2>Messages</h2>
<Link to="/support" aria-label="連絡をすべて見る"> <Link to="/support" aria-label="View all messages">
<ArrowUpRight className="size-4" /> <ArrowUpRight className="size-4" />
</Link> </Link>
</header> </header>
@@ -488,9 +489,9 @@ export function HomePage() {
<span>{item.name}</span> <span>{item.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{statuses[item.id] === 'waiting' {statuses[item.id] === 'waiting'
? '相手待ち' ? 'Waiting'
: statuses[item.id] === 'later' : statuses[item.id] === 'later'
? 'あとで' ? 'Later'
: item.channels.join(' / ')} : item.channels.join(' / ')}
</span> </span>
</button> </button>
@@ -498,15 +499,15 @@ export function HomePage() {
</section> </section>
<section className="home-section"> <section className="home-section">
<header className="home-section-heading"> <header className="home-section-heading">
<h2>記録</h2> <h2>Journal</h2>
<Link to="/journal" aria-label="記録を開く"> <Link to="/journal" aria-label="Open journal">
<ArrowUpRight className="size-4" /> <ArrowUpRight className="size-4" />
</Link> </Link>
</header> </header>
{currentEntries.slice(0, 2).map((entry) => ( {currentEntries.slice(0, 2).map((entry) => (
<p className="home-record" key={entry.id}> <p className="home-record" key={entry.id}>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{entry.kind} {entryKindLabels[entry.kind]}
</span> </span>
<span> <span>
{entry.text} {entry.text}
@@ -518,17 +519,17 @@ export function HomePage() {
<button <button
type="button" type="button"
className="home-context-row" className="home-context-row"
onClick={() => suggest('メモ:')} onClick={() => suggest('Note:')}
> >
<span>ひとこと残す</span> <span>Leave a note</span>
<Plus className="size-4" /> <Plus className="size-4" />
</button> </button>
)} )}
</section> </section>
<section className="home-section"> <section className="home-section">
<header className="home-section-heading"> <header className="home-section-heading">
<h2>読みもの</h2> <h2>Reading</h2>
<Link to="/inbox" aria-label="リーダーを開く"> <Link to="/inbox" aria-label="Open reader">
<ArrowUpRight className="size-4" /> <ArrowUpRight className="size-4" />
</Link> </Link>
</header> </header>
@@ -547,7 +548,7 @@ export function HomePage() {
))} ))}
</section> </section>
<Link to="/deck" className="home-deck-link"> <Link to="/deck" className="home-deck-link">
Twitter Liteで調べる Explore in Twitter Lite
<ArrowUpRight className="size-4" /> <ArrowUpRight className="size-4" />
</Link> </Link>
</aside> </aside>
@@ -557,7 +558,7 @@ export function HomePage() {
</SidebarInset> </SidebarInset>
{contact && ( {contact && (
<Dialog <Dialog
title={`${contact.name}との連絡`} title={`Conversation with ${contact.name}`}
className="sm:max-w-3xl" className="sm:max-w-3xl"
onClose={() => setContactId(undefined)} onClose={() => setContactId(undefined)}
> >
+54 -1
View File
@@ -122,7 +122,7 @@ describe('home natural-language proposals', () => {
['メモ:散歩でアイデアを思いついた', 'メモ', '散歩でアイデアを思いついた'], ['メモ:散歩でアイデアを思いついた', 'メモ', '散歩でアイデアを思いついた'],
['再開地点:レビューの続き', '再開地点', 'レビューの続き'], ['再開地点:レビューの続き', '再開地点', 'レビューの続き'],
['体重は65.2kg', '体重', '65.2'], ['体重は65.2kg', '体重', '65.2'],
['薬を飲んだ', '服薬', '服薬した'], ['薬を飲んだ', '服薬', 'Took medication'],
])('records explicit user statements: %s', (input, kind, text) => { ])('records explicit user statements: %s', (input, kind, text) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({ expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'journal', kind, text }, action: { type: 'journal', kind, text },
@@ -155,3 +155,56 @@ describe('home natural-language proposals', () => {
}) })
}) })
}) })
describe('English home commands', () => {
const tasks = [{ id: 'shop', title: 'grocery shopping' }]
const contacts = [{ id: 'aoki', name: 'Haruka Aoki' }]
it.each([
[
'Add grocery shopping',
{ type: 'add-task', title: 'grocery shopping', section: 'focus' },
],
[
'Add routine: Stretch',
{ type: 'add-task', title: 'Stretch', section: 'routine' },
],
[
'Optional: Read',
{ type: 'add-task', title: 'Read', section: 'optional' },
],
['Reply to Aoki', { type: 'contact', id: 'aoki' }],
[
'Note: Resume on page 2',
{ type: 'journal', kind: 'メモ', text: 'Resume on page 2' },
],
['Resume: Page 2', { type: 'journal', kind: '再開地点', text: 'Page 2' }],
['Weight: 65.2 kg', { type: 'journal', kind: '体重', text: '65.2' }],
[
'Complete grocery shopping',
{ type: 'task-status', id: 'shop', status: 'done' },
],
[
'Defer grocery shopping',
{ type: 'task-status', id: 'shop', status: 'later' },
],
[
'Restore grocery shopping',
{ type: 'task-status', id: 'shop', status: 'todo' },
],
[
'Move grocery shopping up',
{ type: 'task-move', id: 'shop', direction: -1 },
],
['Open reader', { type: 'navigate', to: '/inbox' }],
])('interprets %s', (input, action) => {
expect(interpretAction(input as string, tasks, contacts)).toMatchObject({
action,
})
})
it.each([
"Don't complete grocery shopping",
'Send a reply to Aoki',
])('rejects %s', (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty('error')
})
})
+56 -24
View File
@@ -20,7 +20,7 @@ type Interpretation = { action: HomeAction; label: string } | { error: string }
const unsupported = { const unsupported = {
error: error:
'まだ読み取れません。「買い物を追加」「青木さんに返信」「メモ:気づいたこと」のように、ひとつずつ入力してください。', 'Try one action at a time, such as “Add grocery shopping”, “Reply to Aoki”, or “Note: A thought”.',
} }
/** Local, deterministic prototype. Returns a proposal; never performs an action. */ /** Local, deterministic prototype. Returns a proposal; never performs an action. */
@@ -29,18 +29,50 @@ export function interpretAction(
tasks: Array<{ id: string; title: string; steps?: string[] }>, tasks: Array<{ id: string; title: string; steps?: string[] }>,
contacts: Array<{ id: string; name: string }>, contacts: Array<{ id: string; name: string }>,
): Interpretation { ): Interpretation {
const text = input let text = input
.trim() .trim()
.replace(/[。!!]$/, '') .replace(/[。!!]$/, '')
.trim() .trim()
if (!text) return { error: 'やりたいことを入力してください。' } // Normalize English commands into the same small, deterministic grammar.
if (/\b(?:don't|do not|cancel|send)\b/i.test(text))
return { error: 'Please review this action in the relevant screen.' }
text = text
.replace(
/^open (messages|journal|reader|twitter lite|deck)$/i,
(_, name: string) =>
({
messages: '連絡',
journal: '記録',
reader: 'リーダー',
'twitter lite': 'Twitter Lite',
deck: 'デッキ',
})[name.toLowerCase()] ?? name,
)
.replace(/^note\s*:/i, 'メモ:')
.replace(/^resume(?: point)?\s*:/i, '再開地点:')
.replace(/^weight\s*:?\s*/i, '体重:')
.replace(/^log medication$|^took (?:my )?medication$/i, '服薬した')
.replace(/^reply to (.+)$/i, '$1に返信')
.replace(/^defer (.+)$/i, '$1を保留')
.replace(/^mark (.+) (?:as )?waiting$/i, '$1を相手待ち')
.replace(
/^move (.+) (up|down)$/i,
(_, title: string, direction: string) =>
`${title}を${direction.toLowerCase() === 'up' ? '上' : '下'}に移動`,
)
.replace(/^complete (.+)$/i, '$1を完了')
.replace(/^restore (.+)$/i, '$1を未完了')
.replace(/^add routine\s*:\s*(.+)$/i, '$1をルーティンに追加')
.replace(/^optional\s*:\s*(.+)$/i, '余裕があれば $1')
.replace(/^add\s*:?\s+(.+)$/i, '$1を追加')
if (!text) return { error: 'Enter what you would like to do.' }
if ( if (
/しない|しなく|せず|しないで|やめ|取り消|取消|ではなく|じゃなく|不要|送信|送って|送る/.test( /しない|しなく|せず|しないで|やめ|取り消|取消|ではなく|じゃなく|不要|送信|送って|送る/.test(
text, text,
) )
) { ) {
return { return {
error: 'この内容は自動で変更できません。対象の画面で確認してください。', error: 'Please review this action in the relevant screen.',
} }
} }
@@ -61,7 +93,7 @@ export function interpretAction(
if (route) if (route)
return { return {
action: { type: 'navigate', to: route }, action: { type: 'navigate', to: route },
label: `${destination}を開く`, label: `Open ${{ '/support': 'Messages', '/journal': 'Journal', '/inbox': 'Reader', '/deck': 'Twitter Lite' }[route]}`,
} }
const note = text.match(/^(メモ|再開地点)[::\s]+(.+)$/s) const note = text.match(/^(メモ|再開地点)[::\s]+(.+)$/s)
@@ -71,7 +103,7 @@ export function interpretAction(
if (!body) return unsupported if (!body) return unsupported
return { return {
action: { type: 'journal', kind, text: body }, action: { type: 'journal', kind, text: body },
label: `${kind}を記録する`, label: `Save ${kind === 'メモ' ? 'note' : 'resume point'}`,
} }
} }
const weight = text.match( const weight = text.match(
@@ -80,7 +112,7 @@ export function interpretAction(
if (weight?.[1]) if (weight?.[1])
return { return {
action: { type: 'journal', kind: '体重', text: weight[1] }, action: { type: 'journal', kind: '体重', text: weight[1] },
label: `体重 ${weight[1]} kg を記録する`, label: `Log weight: ${weight[1]} kg`,
} }
if ( if (
/^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test( /^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test(
@@ -88,8 +120,8 @@ export function interpretAction(
) )
) { ) {
return { return {
action: { type: 'journal', kind: '服薬', text: '服薬した' }, action: { type: 'journal', kind: '服薬', text: 'Took medication' },
label: '服薬したことを記録する', label: 'Log medication',
} }
} }
@@ -102,7 +134,9 @@ export function interpretAction(
(item) => (item) =>
item.name === name || item.name === name ||
item.name.replace(/\s/g, '') === name || item.name.replace(/\s/g, '') === name ||
item.name.split(/\s+/)[0] === name, item.name
.split(/\s+/)
.some((part) => part.toLowerCase() === name.toLowerCase()),
) )
const matchingTasks = tasks.filter((task) => task.title === name) const matchingTasks = tasks.filter((task) => task.title === name)
const matchingTask = matchingTasks[0] const matchingTask = matchingTasks[0]
@@ -118,28 +152,28 @@ export function interpretAction(
id: matchingTask.id, id: matchingTask.id,
status: 'later', status: 'later',
}, },
label: `${name}をあとでにする`, label: `Defer ${name}`,
} }
} }
if (matches.length > 0 && matchingTasks.length > 0 && /保留/.test(text)) { if (matches.length > 0 && matchingTasks.length > 0 && /保留/.test(text)) {
return { return {
error: error:
'タスクと連絡先の両方に当てはまります。対象の画面で確認してください。', 'This matches both a task and a contact. Please use the relevant screen.',
} }
} }
const person = matches[0] const person = matches[0]
if (matches.length !== 1 || !person) if (matches.length !== 1 || !person)
return { error: '連絡先をひとりに絞れません。氏名を入力してください。' } return { error: 'Please enter the full name of one contact.' }
if (/相手待ち|保留/.test(text)) { if (/相手待ち|保留/.test(text)) {
const status = text.includes('相手待ち') ? 'waiting' : 'later' const status = text.includes('相手待ち') ? 'waiting' : 'later'
return { return {
action: { type: 'contact-status', id: person.id, status }, action: { type: 'contact-status', id: person.id, status },
label: `${person.name}との連絡を${status === 'waiting' ? '相手待ち' : '保留'}にする`, label: `Mark the conversation with ${person.name} as ${status === 'waiting' ? 'waiting' : 'later'}`,
} }
} }
return { return {
action: { type: 'contact', id: person.id }, action: { type: 'contact', id: person.id },
label: `${person.name}への返信を確認する`, label: `Review reply to ${person.name}`,
} }
} }
@@ -150,8 +184,7 @@ export function interpretAction(
const task = matches[0] const task = matches[0]
if (matches.length !== 1 || !task) if (matches.length !== 1 || !task)
return { return {
error: error: 'Please use the exact name of one task from the list.',
'タスクをひとつに絞れません。一覧の名前をそのまま指定してください。',
} }
return { return {
action: { action: {
@@ -159,7 +192,7 @@ export function interpretAction(
id: task.id, id: task.id,
direction: move[2] === '上' ? -1 : 1, direction: move[2] === '上' ? -1 : 1,
}, },
label: `${task.title}をひとつ${move[2]}に移動する`, label: `Move ${task.title} ${move[2] === '上' ? 'up' : 'down'}`,
} }
} }
@@ -176,8 +209,7 @@ export function interpretAction(
) )
if (matches.length + steps.length !== 1) if (matches.length + steps.length !== 1)
return { return {
error: error: 'Please use the exact name of one task from the list.',
'タスクをひとつに絞れません。一覧の名前をそのまま指定してください。',
} }
const status = /未完了|戻して/.test(change[2]) const status = /未完了|戻して/.test(change[2])
? 'todo' ? 'todo'
@@ -187,7 +219,7 @@ export function interpretAction(
const step = steps[0] const step = steps[0]
if (steps.length === 1 && step) { if (steps.length === 1 && step) {
if (status === 'later') if (status === 'later')
return { error: 'ルーティンの手順は完了・未完了に変更できます。' } return { error: 'Routine steps can be marked done or incomplete.' }
return { return {
action: { action: {
type: 'routine-step', type: 'routine-step',
@@ -195,14 +227,14 @@ export function interpretAction(
step: step.step, step: step.step,
done: status === 'done', done: status === 'done',
}, },
label: `${title}を${status === 'done' ? '完了' : '未完了'}にする`, label: `Mark ${title} as ${status === 'done' ? 'done' : 'incomplete'}`,
} }
} }
const task = matches[0] const task = matches[0]
if (!task) return unsupported if (!task) return unsupported
return { return {
action: { type: 'task-status', id: task.id, status }, action: { type: 'task-status', id: task.id, status },
label: `${title}を${status === 'done' ? '完了' : status === 'later' ? 'あとで' : '未完了'}にする`, label: `Mark ${title} as ${status === 'done' ? 'done' : status === 'later' ? 'later' : 'incomplete'}`,
} }
} }
@@ -218,7 +250,7 @@ export function interpretAction(
const section = routine ? 'routine' : optional ? 'optional' : 'focus' const section = routine ? 'routine' : optional ? 'optional' : 'focus'
return { return {
action: { type: 'add-task', title, section }, action: { type: 'add-task', title, section },
label: `「${title}」を${section === 'routine' ? 'ルーティン' : section === 'optional' ? '余裕があれば' : '今日のタスク'}に追加する`, label: `Add “${title}” to ${section === 'routine' ? 'Routines' : section === 'optional' ? 'If there’s room' : 'Start here'}`,
} }
} }
return unsupported return unsupported
+8 -8
View File
@@ -35,22 +35,22 @@ export function InboxPage() {
<WorkspaceNavigation /> <WorkspaceNavigation />
<Sidebar collapsible="none" className="inbox-list-panel"> <Sidebar collapsible="none" className="inbox-list-panel">
<SidebarHeader className="gap-4 border-b p-5"> <SidebarHeader className="gap-4 border-b p-5">
<h1 className="text-base font-semibold">リーダー</h1> <h1 className="text-base font-semibold">Reader</h1>
<div className="relative"> <div className="relative">
<Search <Search
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute left-3 top-2 size-4 text-muted-foreground" className="pointer-events-none absolute left-3 top-2 size-4 text-muted-foreground"
/> />
<Input <Input
aria-label="記事を検索" aria-label="Search articles"
placeholder="検索" placeholder="Search"
className="pl-9" className="pl-9"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
/> />
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarContent className="gap-0" aria-label="記事一覧"> <SidebarContent className="gap-0" aria-label="Articles">
{visible.map((article) => ( {visible.map((article) => (
<ArticleRow <ArticleRow
key={article.id} key={article.id}
@@ -64,9 +64,9 @@ export function InboxPage() {
))} ))}
{visible.length === 0 && ( {visible.length === 0 && (
<div className="inbox-no-results"> <div className="inbox-no-results">
<p>記事がありません</p> <p>No articles found</p>
<Button variant="outline" onClick={() => setQuery('')}> <Button variant="outline" onClick={() => setQuery('')}>
検索をクリア Clear search
</Button> </Button>
</div> </div>
)} )}
@@ -79,7 +79,7 @@ export function InboxPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="inbox-back" className="inbox-back"
aria-label="記事一覧に戻る" aria-label="Back to articles"
onClick={() => setReadingMobile(false)} onClick={() => setReadingMobile(false)}
> >
<ArrowLeft /> <ArrowLeft />
@@ -91,7 +91,7 @@ export function InboxPage() {
<section <section
className="inbox-reader-scroll" className="inbox-reader-scroll"
key={selected?.id} key={selected?.id}
aria-label="記事本文" aria-label="Article content"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable. // biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable.
tabIndex={0} tabIndex={0}
> >
+64 -64
View File
@@ -12,127 +12,127 @@ export type Article = {
export const articles: Article[] = [ export const articles: Article[] = [
{ {
id: 'agent-browser-workflow', id: 'agent-browser-workflow',
title: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方', title: 'Thinking in the browser: a new workflow with AI agents',
summary: summary:
'コードを書いて終わりにしない。画面を開き、操作し、確かめるところまでをひとつの作業にする。', 'Make implementation and verification one task: open the screen, use it, and check the result.',
source: 'Field Notes', source: 'Field Notes',
author: 'Field Notes 編集部', author: 'Field Notes Editors',
date: '09:40', date: '09:40',
paragraphs: [ paragraphs: [
'UIをつくるとき、コードが正しいことと、画面が使いやすいことの間には距離がある。ボタンが画面の外に出ていないか。入力した文章は画面を切り替えても残るか。こうした問いへの答えは、実際にブラウザを動かして初めて見えてくる。', 'Correct code and a usable interface are different things. Is a button off screen? Does a draft survive navigation? Opening the browser is how we find out.',
'そこで試したいのが、実装と確認をひとつの短い循環にする進め方だ。エージェントに変更を依頼するとき、「記事を選び、詳細を読み、一覧に戻る」といった利用者の動作も添える。作業の終点がファイルの変更から、動作の確認へと具体的になる。', 'When requesting a change, include the user journey: select an article, read it, and return to the list. Completion becomes an observed behavior rather than a modified file.',
'確認には、ふだんの画面幅と狭い画面幅の両方を使う。うまく動いた結果だけでなく、何を操作し、何を確認できなかったかも残しておくと、次の変更で同じ箇所を確かめやすい。最初から網羅するより、重要な操作をひとつ確実に通すことから始めたい。', 'Check both a typical screen width and a narrow one. Record what you tried and what you could not verify, so the next change has a clear starting point.',
'人が担うのは、その体験が目的に合っているかを判断すること。操作の繰り返しをエージェントに任せると、余白や言葉、情報の順序といった、使い心地に向き合う時間を取りやすくなる。', 'People decide whether an experience serves its purpose. Letting an agent repeat interactions leaves more time for wording, spacing, and information order.',
], ],
}, },
{ {
id: 'small-cli-tools', id: 'small-cli-tools',
title: '毎日使いたくなるCLIは、終了時のひと言まで設計されている', title: 'A good CLI considers its final line',
summary: summary:
'ヘルプ、エラー、実行結果。小さな道具の使い心地は、出力の細部で変わる。', 'Help, errors, and results: the smallest output details shape a tool’s experience.',
source: 'Toolbox Letter', source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部', author: 'Toolbox Letter Editors',
date: '09:15', date: '09:15',
paragraphs: [ paragraphs: [
'コマンドを実行したあと、何が起きたのか分からない。これは処理そのものが成功していても、使う側には不安が残る体験だ。作成したファイルの場所や処理件数を一行で伝えるだけで、道具とのやり取りはずっと明快になる。', 'A command can succeed without making clear what happened. Showing the output path or number of processed items helps people understand the result.',
'エラーも、原因と次の行動が近くにあると扱いやすい。「設定が不正です」より、「出力先が見つかりません。存在するディレクトリを指定してください」のほうが、調べ直す手間を減らせる。内部の事情は必要なときにだけ表示できればよい。', 'Keep the cause of an error close to the next useful action. “Choose an existing directory” is more helpful than “Invalid configuration.”',
'人が読む出力と、別のプログラムが読む出力の用途も分けて考えたい。通常は簡潔な文章を返し、構造化された結果が必要なら専用の形式を選べるようにする。小さなCLIほど、こうした一貫性が長く使う理由になる。', 'Human-readable output and machine-readable output serve different purposes. Offer concise text by default and a structured format when needed.',
], ],
}, },
{ {
id: 'three-column-reading', id: 'three-column-reading',
title: '3カラムの余白が、情報を読むリズムをつくる', title: 'Three columns create a rhythm for reading',
summary: summary: 'Choose a place, scan the options, and settle into an article.',
'場所を選ぶ、候補を眺める、ひとつを読む。役割が分かれた画面について。',
source: 'Quiet Interface', source: 'Quiet Interface',
author: 'Quiet Interface 編集部', author: 'Quiet Interface Editors',
date: '08:50', date: '08:50',
paragraphs: [ paragraphs: [
'記事を探すとき、私たちはすべての文章を同じ深さで読んでいるわけではない。関心のある分野を選び、見出しを流し読みし、気になるものにだけ腰を据える。3カラムの画面は、この異なる読み方にそれぞれの場所を用意できる。', 'We do not read every article at the same depth. We choose a subject, scan headlines, and focus on the pieces that interest us. Three columns can give each activity its own space.',
'左は現在地、中央は候補、右は本文。役割が明確なら、記事を開いても一覧の中での位置を見失いにくい。選択中の項目を穏やかに示すことや、一覧のスクロール位置を保つことが、この安心感を支えている。', 'Navigation on the left, options in the middle, and content on the right help preserve context. A quiet selection state and a stable scroll position make returning easy.',
'すべてのカラムを等しい幅にする必要はない。見出しを判断できる一覧の幅を確保し、残りを本文に渡す。本文にも適切な最大幅を設ければ、大きな画面で一行が長くなりすぎることを防げる。', 'Columns do not need equal widths. Give the list enough room for headlines, then use the remaining space for a comfortably sized reading pane.',
'狭い画面では、同じ役割を順番に見せればよい。一覧と本文を切り替えても、戻る場所が明確であること。カラムの数よりも、読む途中の文脈が続いていることが大切だ。', 'On narrow screens, present these roles in sequence. A clear way back matters more than keeping every column visible.',
], ],
}, },
{ {
id: 'agent-context', id: 'agent-context',
title: 'エージェントに渡す文脈は、短くてもよい', title: 'Context for an agent can be brief',
summary: summary:
'大量の説明よりも、目的・制約・完了条件をそろえる。依頼を整理する小さな習慣。', 'Align on the goal, constraints, and completion criteria before adding background.',
source: 'Field Notes', source: 'Field Notes',
author: 'Field Notes 編集部', author: 'Field Notes Editors',
date: '08:30', date: '08:30',
paragraphs: [ paragraphs: [
'依頼を詳しく書こうとするほど、背景と希望と必須条件が混ざってしまうことがある。受け手がエージェントでも人でも、最初に知りたいのは何を実現する作業なのかという一点だ。', 'Long requests can mix context, preferences, and requirements. Whether the recipient is a person or an agent, the first question is what the work should achieve.',
'まず目的を一文で書き、次に変えてはいけない条件を添える。最後に、何を確かめたら作業が終わるのかを決める。参考ファイルやスクリーンショットは、その三つを補うために渡すと役割がはっきりする。', 'Start with a one-sentence goal, then add the constraints that must hold. Finally, decide what to check before calling the work complete. Reference files and screenshots can support these three points.',
'途中で判断が変わったときは、説明を足し続けるより、現在の方針を短く書き直すほうが役立つ場合がある。文脈の量ではなく、いま有効な判断を見つけやすいことを意識したい。', 'When a decision changes, a short rewrite of the current direction can be more useful than appending more explanation. Make the active decisions easy to find.',
], ],
}, },
{ {
id: 'local-first-notes', id: 'local-first-notes',
title: 'ローカルで完結する、小さな情報収集ワークスペース', title: 'A small, local workspace for collecting information',
summary: summary: 'Finding something again matters more than collecting it.',
'集めることより、あとから見つけられること。ファイルを中心にした道具の組み合わせ。',
source: 'Workbench Journal', source: 'Workbench Journal',
author: 'Workbench Journal 編集部', author: 'Workbench Journal Editors',
date: '昨日', date: 'Yesterday',
paragraphs: [ paragraphs: [
'気になった記事を保存する場所は増えがちだ。ブラウザのブックマーク、メモアプリ、チャットの自分宛てメッセージ。集める入口が便利でも、読み返す入口が分かれていると、保存したこと自体を忘れてしまう。', 'Bookmarks, notes, and messages to yourself can scatter saved articles across many places. Convenient capture does not guarantee convenient retrieval.',
'小さく始めるなら、タイトル、出典、保存した理由だけをテキストで残す方法がある。本文をすべて保管する前に、なぜ自分が気になったのかを一行添えておく。その一行が、検索したときの判断材料になる。', 'Start by saving the title, source, and one sentence explaining why it matters. That sentence provides useful context when you find the article again.',
'ローカルのファイルを中心にすると、使い慣れた検索やエディタと組み合わせやすい。一方、複数の端末で扱うなら同期や競合の扱いを決める必要がある。道具を増やす前に、どこで読み、いつ振り返るのかを決めておきたい。', 'Local files work well with familiar search tools and editors. If you use multiple devices, decide how to handle synchronization and conflicts before adding more tools.',
], ],
}, },
{ {
id: 'quiet-design-system', id: 'quiet-design-system',
title: 'デザインシステムを、使う人の言葉で整える', title: 'Build a design system in the user’s language',
summary: 'コンポーネントをそろえた先にある、ラベルや空状態の一貫性。', summary:
'Consistency extends beyond components to labels and empty states.',
source: 'Quiet Interface', source: 'Quiet Interface',
author: 'Quiet Interface 編集部', author: 'Quiet Interface Editors',
date: '昨日', date: 'Yesterday',
paragraphs: [ paragraphs: [
'ボタンの角丸や色がそろっていても、同じ操作を画面ごとに違う言葉で呼んでいると迷いが生まれる。「保存」と「追加」が何を意味するのか。見た目と同じように、言葉にも共通のルールが必要だ。', 'Matching colors and corners do not prevent confusion if every screen names the same action differently. Words need shared rules too.',
'最初に整理したいのは、よく使う操作と状態の名前だ。未読、保存済み、読み込み中などの基本的な表現を決めておくと、新しい画面をつくるときの判断が少なくなる。専門用語を画面に出す前に、使う人がその違いを知る必要があるかも考えたい。', 'Start with the names of common actions and states. Agreeing on terms like unread, saved, and loading reduces decisions when building new screens.',
'空の一覧にも役割がある。何もない理由と、次にできることが簡潔に分かれば、空状態は行き止まりにならない。派手なイラストがなくても、適切な一文と操作があれば十分なことは多い。', 'An empty list should explain why it is empty and what can happen next. A useful sentence and action are often enough.',
], ],
}, },
{ {
id: 'personal-feed', id: 'personal-feed',
title: 'おすすめの理由が見える、パーソナルな記事フィード', title: 'A personal feed with understandable recommendations',
summary: summary: 'Explain why an article connects to someone’s interests.',
'「なぜこの記事?」に答えられることが、興味に基づく推薦の出発点になる。',
source: 'Field Notes', source: 'Field Notes',
author: 'Field Notes 編集部', author: 'Field Notes Editors',
date: '昨日', date: 'Yesterday',
paragraphs: [ paragraphs: [
'興味に合う記事が届くと便利だが、その理由が分からないと、偶然なのか自分の行動が反映されたのか判断しづらい。推薦の根拠を短く添えるだけで、読む側は自分との接点を見つけやすくなる。', 'Recommendations are easier to understand when their reasons are visible. A short explanation can show how an article relates to previous activity.',
'理由は複雑なスコアである必要はない。「選んだトピックに関連」「保存した記事と近いテーマ」など、実際に使っている情報と対応していることが大切だ。まだ推薦処理がない試作なら、その理由もサンプルであることを明示しておく。', 'The reason does not need to be a complex score. It should accurately describe the information used, such as a connection to something previously read.',
'関心は変化するので、利用者が調整できる入口も必要になる。トピックの追加や解除、読みたくない記事の非表示など、小さな操作から始められる。よいフィードは、当て続ける仕組みだけでなく、外れたときに直せる仕組みでもある。', 'Interests change. A useful feed learns from activity and provides a way to correct irrelevant recommendations.',
], ],
}, },
{ {
id: 'behavior-tests', id: 'behavior-tests',
title: 'テストに残したいのは、実装よりも利用者の約束', title: 'Test the promises made to users',
summary: '内部の構造を変えても守りたい動作を、短いシナリオで表す。', summary:
'Describe behavior that should survive changes to the implementation.',
source: 'Toolbox Letter', source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部', author: 'Toolbox Letter Editors',
date: '2日前', date: '2 days ago',
paragraphs: [ paragraphs: [
'コンポーネントを分割しただけなのに、多くのテストが壊れる。そんなときは、テストが利用者の操作よりも内部の構造に寄り添っていないかを見直したい。守りたいのは、どの関数が呼ばれたかではなく、期待する結果が得られたかという約束だ。', 'If splitting a component breaks many tests, consider whether those tests depend too much on internal structure. The promise is an observable result.',
'記事一覧なら、「トピックを選ぶと該当する記事が並ぶ」「記事を保存すると保存済みで見つけられる」といったシナリオを考えられる。画面に表示される名前で操作して、結果も画面から確かめると、何を保証するテストかが読み取りやすい。', 'For an article list, test selecting a topic or finding a saved article. Using the visible names of controls makes the behavior being checked easier to understand.',
'もちろん、すべてをブラウザで確かめる必要はない。複雑な絞り込みの計算は小さな単位で検証し、代表的な操作の流れを画面で確認する。変更の影響に合わせて確認の範囲を選ぶことが、続けやすいテストにつながる。', 'Not everything needs a browser test. Check complex calculations in small tests and representative journeys in the interface. Match the scope to the change.',
], ],
}, },
{ {
id: 'reading-typography', id: 'reading-typography',
title: '読みやすい本文は、文字サイズだけでは決まらない', title: 'Readable text is about more than font size',
summary: '行の長さ、行間、段落の間隔。読み進めやすい記事画面のためのメモ。', summary:
'Line length, line spacing, and paragraph spacing all affect reading.',
source: 'Quiet Interface', source: 'Quiet Interface',
author: 'Quiet Interface 編集部', author: 'Quiet Interface Editors',
date: '2日前', date: '2 days ago',
paragraphs: [ paragraphs: [
'文字が十分に大きくても、一行が長すぎると次の行を見つけるのが難しくなる。逆に幅が狭すぎると、短い単語のまとまりまで細かく折り返され、読むリズムが途切れる。本文の幅は、文字サイズと一緒に考えたい。', 'Even large text is difficult to follow when lines are too long. Very narrow lines can also interrupt the rhythm. Consider width together with font size.',
'行間は一行ごとの読みやすさを、段落の間隔は話のまとまりを支えている。同じ余白として扱わず、それぞれの役割が見えるように調整すると、装飾を増やさなくても文章の構造が伝わる。', 'Line spacing helps readers follow individual lines, while paragraph spacing separates ideas. Their different roles should remain clear.',
'調整するときは、短いダミーテキストだけで判断しない。長い見出しや複数段落の記事を置き、実際に数分読んでみる。画面の見栄えだけでは気づけない、読み疲れや視線の迷いを見つけやすくなる。', 'Use long headlines and several paragraphs when checking a layout. Reading for a few minutes reveals fatigue and confusion that a quick glance can miss.',
], ],
}, },
] ]
+43 -39
View File
@@ -10,6 +10,7 @@ import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { import {
type JournalEntry as Entry, type JournalEntry as Entry,
type EntryKind, type EntryKind,
entryKindLabels,
useWorkspaceState, useWorkspaceState,
} from '#/features/workspace/workspace-state' } from '#/features/workspace/workspace-state'
import './journal.css' import './journal.css'
@@ -19,8 +20,8 @@ const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: ''
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重'] const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
function formatTime(value: string | undefined) { function formatTime(value: string | undefined) {
if (!value) return '不明' if (!value) return 'Unknown'
return new Date(value).toLocaleString('ja-JP', { return new Date(value).toLocaleString('en-US', {
year: 'numeric', year: 'numeric',
month: 'numeric', month: 'numeric',
day: 'numeric', day: 'numeric',
@@ -58,7 +59,7 @@ export function JournalPage() {
<WorkspaceNavigation /> <WorkspaceNavigation />
<SidebarInset className="journal-main"> <SidebarInset className="journal-main">
<header className="journal-header"> <header className="journal-header">
<h1 className="text-base font-semibold">記録</h1> <h1 className="text-base font-semibold">Journal</h1>
<Button <Button
variant="outline" variant="outline"
disabled={current.length === 0} disabled={current.length === 0}
@@ -67,7 +68,7 @@ export function JournalPage() {
setReviewing(true) setReviewing(true)
}} }}
> >
記録をまとめる Summarize entries
</Button> </Button>
</header> </header>
<div className="journal-scroll"> <div className="journal-scroll">
@@ -79,28 +80,30 @@ export function JournalPage() {
append(draft) append(draft)
setDraft(emptyDraft) setDraft(emptyDraft)
}} }}
submitLabel="記録する" submitLabel="Save entry"
/> />
<section className="journal-entries" aria-label="記録一覧"> <section className="journal-entries" aria-label="Journal entries">
{entries.length === 0 && ( {entries.length === 0 && (
<p className="journal-empty">まだ記録はありません</p> <p className="journal-empty">No entries yet</p>
)} )}
{entries.map((entry) => ( {entries.map((entry) => (
<article <article
key={entry.id} key={entry.id}
className="journal-entry" className="journal-entry"
aria-label={`${entry.kind}の記録`} aria-label={`${entryKindLabels[entry.kind]} entry`}
> >
<div className="journal-entry-heading"> <div className="journal-entry-heading">
<span className="text-sm font-medium">{entry.kind}</span> <span className="text-sm font-medium">
{entryKindLabels[entry.kind]}
</span>
{superseded.has(entry.id) && ( {superseded.has(entry.id) && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
訂正前 Original
</span> </span>
)} )}
{entry.correctionOf && ( {entry.correctionOf && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
訂正 Correction
</span> </span>
)} )}
{!superseded.has(entry.id) && ( {!superseded.has(entry.id) && (
@@ -110,7 +113,7 @@ export function JournalPage() {
className="ml-auto" className="ml-auto"
onClick={() => setCorrecting(entry)} onClick={() => setCorrecting(entry)}
> >
訂正する Correct
</Button> </Button>
)} )}
</div> </div>
@@ -119,12 +122,12 @@ export function JournalPage() {
{entry.kind === '体重' ? ' kg' : ''} {entry.kind === '体重' ? ' kg' : ''}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
発生日時: {formatTime(entry.occurredAt)} · 記録日時:{' '} Occurred: {formatTime(entry.occurredAt)} · Recorded:{' '}
{formatTime(entry.recordedAt)} {formatTime(entry.recordedAt)}
</p> </p>
{(entry.kind === '体重' || entry.source) && ( {(entry.kind === '体重' || entry.source) && (
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
記録元: {entry.source || '不明'} Source: {entry.source || 'Unknown'}
</p> </p>
)} )}
</article> </article>
@@ -146,12 +149,12 @@ export function JournalPage() {
)} )}
{reviewing && ( {reviewing && (
<Dialog <Dialog
title="記録をまとめる" title="Summarize entries"
className="sm:max-w-2xl" className="sm:max-w-2xl"
onClose={() => setReviewing(false)} onClose={() => setReviewing(false)}
> >
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
含める記録を選んで確認できます。外部には送信されません。 Select the entries to include. Nothing is sent externally.
</p> </p>
<div className="journal-review-list"> <div className="journal-review-list">
{current.map((entry) => ( {current.map((entry) => (
@@ -174,7 +177,8 @@ export function JournalPage() {
/> />
<span> <span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{entry.kind} · {formatTime(entry.occurredAt)} {entryKindLabels[entry.kind]} ·{' '}
{formatTime(entry.occurredAt)}
</span> </span>
<span className="block text-sm"> <span className="block text-sm">
{entry.text} {entry.text}
@@ -185,7 +189,7 @@ export function JournalPage() {
))} ))}
</div> </div>
<Textarea <Textarea
aria-label="記録のまとめ" aria-label="Entry summary"
className="journal-summary" className="journal-summary"
readOnly readOnly
value={buildSummary( value={buildSummary(
@@ -222,7 +226,7 @@ function EntryEditor({
if (valid) onSubmit() if (valid) onSubmit()
}} }}
> >
<fieldset className="journal-kind-switch" aria-label="記録の種類"> <fieldset className="journal-kind-switch" aria-label="Entry type">
{kinds.map((kind) => ( {kinds.map((kind) => (
<Button <Button
key={kind} key={kind}
@@ -232,7 +236,7 @@ function EntryEditor({
aria-pressed={draft.kind === kind} aria-pressed={draft.kind === kind}
onClick={() => onChange({ ...draft, kind })} onClick={() => onChange({ ...draft, kind })}
> >
{kind} {entryKindLabels[kind]}
</Button> </Button>
))} ))}
</fieldset> </fieldset>
@@ -242,8 +246,8 @@ function EntryEditor({
type="number" type="number"
step="any" step="any"
min="0.1" min="0.1"
aria-label="体重" aria-label="Weight"
placeholder="体重" placeholder="Weight"
value={draft.text} value={draft.text}
onChange={(event) => onChange={(event) =>
onChange({ ...draft, text: event.target.value }) onChange({ ...draft, text: event.target.value })
@@ -253,13 +257,13 @@ function EntryEditor({
</div> </div>
) : ( ) : (
<Textarea <Textarea
aria-label="記録内容" aria-label="Entry text"
placeholder={ placeholder={
draft.kind === '服薬' draft.kind === '服薬'
? '服用した薬の名前' ? 'Name of the medication taken'
: draft.kind === '再開地点' : draft.kind === '再開地点'
? '次にすること、開いておく場所' ? 'What to do next and where to pick up'
: '自由なメモ、次にすること' : 'A thought or your next step'
} }
value={draft.text} value={draft.text}
onChange={(event) => onChange({ ...draft, text: event.target.value })} onChange={(event) => onChange({ ...draft, text: event.target.value })}
@@ -267,8 +271,8 @@ function EntryEditor({
)} )}
{draft.kind === '体重' && ( {draft.kind === '体重' && (
<Input <Input
aria-label="記録元" aria-label="Source"
placeholder="記録元(体重計など・任意)" placeholder="Source (e.g. scale, optional)"
value={draft.source} value={draft.source}
onChange={(event) => onChange={(event) =>
onChange({ ...draft, source: event.target.value }) onChange({ ...draft, source: event.target.value })
@@ -277,11 +281,11 @@ function EntryEditor({
)} )}
<div className="journal-editor-footer"> <div className="journal-editor-footer">
<label className="journal-date-label" htmlFor={dateId}> <label className="journal-date-label" htmlFor={dateId}>
発生日時(任意) When it happened (optional)
<Input <Input
id={dateId} id={dateId}
type="datetime-local" type="datetime-local"
aria-label="発生日時" aria-label="When it happened"
value={draft.occurredAt} value={draft.occurredAt}
onChange={(event) => onChange={(event) =>
onChange({ ...draft, occurredAt: event.target.value }) onChange({ ...draft, occurredAt: event.target.value })
@@ -307,38 +311,38 @@ function CorrectionDialog({
}) { }) {
const [draft, setDraft] = useState<Draft>(entry) const [draft, setDraft] = useState<Draft>(entry)
return ( return (
<Dialog title="記録を訂正" onClose={onClose} className="sm:max-w-xl"> <Dialog title="Correct entry" onClose={onClose} className="sm:max-w-xl">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
元の記録を残して、訂正を追加します。 Add a correction while keeping the original entry.
</p> </p>
<EntryEditor <EntryEditor
draft={draft} draft={draft}
onChange={setDraft} onChange={setDraft}
onSubmit={() => onSave(draft)} onSubmit={() => onSave(draft)}
submitLabel="訂正を追加" submitLabel="Add correction"
/> />
</Dialog> </Dialog>
) )
} }
function buildSummary(entries: Entry[]) { function buildSummary(entries: Entry[]) {
if (!entries.length) return '記録を選択してください。' if (!entries.length) return 'Select an entry.'
const dates = entries const dates = entries
.map((entry) => entry.occurredAt) .map((entry) => entry.occurredAt)
.filter(Boolean) .filter(Boolean)
.sort() .sort()
const period = dates.length const period = dates.length
? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}` ? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}`
: '不明' : 'Unknown'
return [ return [
`発生日時の期間: ${period}`, `Period covered: ${period}`,
`発生日時が不明の記録: ${entries.filter((entry) => !entry.occurredAt).length}件`, `Entries with an unknown event time: ${entries.filter((entry) => !entry.occurredAt).length} entries`,
'', '',
...entries.map( ...entries.map(
(entry) => (entry) =>
`[${entry.kind}] ${entry.text}${entry.kind === '体重' ? ' kg' : ''}\n発生日時: ${formatTime(entry.occurredAt)} / 記録日時: ${formatTime(entry.recordedAt)}${entry.kind === '体重' ? ` / 記録元: ${entry.source || '不明'}` : ''}`, `[${entryKindLabels[entry.kind]}] ${entry.text}${entry.kind === '体重' ? ' kg' : ''}\nOccurred: ${formatTime(entry.occurredAt)} / Recorded: ${formatTime(entry.recordedAt)}${entry.kind === '体重' ? ` / Source: ${entry.source || 'Unknown'}` : ''}`,
), ),
'', '',
'記録のない期間の服薬・体重は不明です。', 'Medication and weight are unknown for periods without entries.',
].join('\n') ].join('\n')
} }
@@ -26,21 +26,21 @@ async function requireOk(response: Response, search = false) {
if (response.status === 401) if (response.status === 401)
throw new MastodonFeedError( throw new MastodonFeedError(
'account-unavailable', 'account-unavailable',
'アカウントを再接続してください。', 'Reconnect your account.',
) )
if (response.status === 429) if (response.status === 429)
throw new MastodonFeedError( throw new MastodonFeedError(
'rate-limit', 'rate-limit',
'取得回数の制限に達しました。しばらく待ってから再試行してください。', 'Rate limit reached. Wait a moment and try again.',
) )
if (search && [400, 422, 501, 503].includes(response.status)) if (search && [400, 422, 501, 503].includes(response.status))
throw new MastodonFeedError( throw new MastodonFeedError(
'search-unavailable', 'search-unavailable',
'検索を実行できません。接続先の対応状況と検索条件を確認してください。', 'Search is unavailable. Check server support and your search query.',
) )
throw new MastodonFeedError( throw new MastodonFeedError(
'request-failed', 'request-failed',
'Mastodonから取得できませんでした。接続と対象を確認してください。', 'Could not load from Mastodon. Check the connection and requested resource.',
) )
} }
@@ -83,7 +83,7 @@ export async function fetchMastodonPage(
if (connection?.platform !== 'mastodon' || connection.status !== 'connected') if (connection?.platform !== 'mastodon' || connection.status !== 'connected')
throw new MastodonFeedError( throw new MastodonFeedError(
'account-unavailable', 'account-unavailable',
'Mastodonアカウントを接続してください。', 'Connect a Mastodon account.',
) )
const params = new URLSearchParams({ limit: String(PAGE_SIZE) }) const params = new URLSearchParams({ limit: String(PAGE_SIZE) })
let path: string let path: string
@@ -19,7 +19,7 @@ export const loadMastodonPosts = createServerFn({ method: 'GET' })
message: message:
error instanceof MastodonFeedError error instanceof MastodonFeedError
? error.message ? error.message
: 'Mastodonから取得できませんでした。', : 'Could not load from Mastodon.',
retryable: retryable:
!(error instanceof MastodonFeedError) || !(error instanceof MastodonFeedError) ||
error.code !== 'account-unavailable', error.code !== 'account-unavailable',
+14 -12
View File
@@ -33,7 +33,9 @@ export const searchPageInputSchema = z.object({
function requireHandle(value: string): string { function requireHandle(value: string): string {
if (!HANDLE.test(value)) { if (!HANDLE.test(value)) {
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。') throw new InputError(
'Handles can only contain letters, numbers, and underscores.',
)
} }
return value return value
} }
@@ -41,7 +43,7 @@ function requireHandle(value: string): string {
export function normalizeUserTarget(raw: string): string { export function normalizeUserTarget(raw: string): string {
const value = raw.trim() const value = raw.trim()
if (!value) { if (!value) {
throw new InputError('ハンドルまたはプロフィール URL を入力してください。') throw new InputError('Enter a handle or profile URL.')
} }
if (value.startsWith('@')) { if (value.startsWith('@')) {
return requireHandle(value.slice(1)) return requireHandle(value.slice(1))
@@ -54,21 +56,21 @@ export function normalizeUserTarget(raw: string): string {
try { try {
url = new URL(value) url = new URL(value)
} catch { } catch {
throw new InputError('プロフィール URL の形式を確認してください。') throw new InputError('Check the profile URL format.')
} }
if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) { if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) {
throw new InputError('x.com または twitter.com の URL を入力してください。') throw new InputError('Enter an x.com or twitter.com URL.')
} }
const segments = url.pathname.split('/').filter(Boolean) const segments = url.pathname.split('/').filter(Boolean)
if (segments.length !== 1) { if (segments.length !== 1) {
throw new InputError('プロフィール URL を入力してください。') throw new InputError('Enter a profile URL.')
} }
return requireHandle(segments[0] ?? '') return requireHandle(segments[0] ?? '')
} }
function requireListId(value: string): string { function requireListId(value: string): string {
if (!LIST_ID.test(value)) { if (!LIST_ID.test(value)) {
throw new InputError('リスト ID は数字で入力してください。') throw new InputError('List IDs must contain only numbers.')
} }
return value return value
} }
@@ -76,7 +78,7 @@ function requireListId(value: string): string {
export function normalizeListTarget(raw: string): string { export function normalizeListTarget(raw: string): string {
const value = raw.trim() const value = raw.trim()
if (!value) { if (!value) {
throw new InputError('リストの URL または ID を入力してください。') throw new InputError('Enter a list URL or ID.')
} }
if (!value.includes('://')) { if (!value.includes('://')) {
return requireListId(value) return requireListId(value)
@@ -86,14 +88,14 @@ export function normalizeListTarget(raw: string): string {
try { try {
url = new URL(value) url = new URL(value)
} catch { } catch {
throw new InputError('リスト URL の形式を確認してください。') throw new InputError('Check the list URL format.')
} }
if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) { if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) {
throw new InputError('x.com または twitter.com の URL を入力してください。') throw new InputError('Enter an x.com or twitter.com URL.')
} }
const segments = url.pathname.split('/').filter(Boolean) const segments = url.pathname.split('/').filter(Boolean)
if (segments.length !== 3 || segments[0] !== 'i' || segments[1] !== 'lists') { if (segments.length !== 3 || segments[0] !== 'i' || segments[1] !== 'lists') {
throw new InputError('リストの URL を入力してください。') throw new InputError('Enter a list URL.')
} }
return requireListId(segments[2] ?? '') return requireListId(segments[2] ?? '')
} }
@@ -101,12 +103,12 @@ export function normalizeListTarget(raw: string): string {
export function buildSearchQuery(raw: string, following: boolean): string { export function buildSearchQuery(raw: string, following: boolean): string {
const query = raw.trim() const query = raw.trim()
if (!query) { if (!query) {
throw new InputError('検索語を入力してください。') throw new InputError('Enter a search query.')
} }
const finalQuery = const finalQuery =
following && !FOLLOWS.test(query) ? `${query} filter:follows` : query following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
if (finalQuery.length > 512) { if (finalQuery.length > 512) {
throw new InputError('検索語は 512 文字以内で入力してください。') throw new InputError('Search queries must be 512 characters or fewer.')
} }
return finalQuery return finalQuery
} }
+6 -6
View File
@@ -81,7 +81,7 @@ describe('loadListChoices', () => {
ok: false, ok: false,
error: { error: {
code: 'upstream', code: 'upstream',
message: 'X から投稿を取得できませんでした。', message: 'Could not load posts from X.',
retryable: true, retryable: true,
}, },
}) })
@@ -123,7 +123,7 @@ describe('loadUserPage', () => {
ok: false, ok: false,
error: { error: {
code: 'user-not-found', code: 'user-not-found',
message: 'ユーザーが見つかりませんでした。', message: 'User not found.',
retryable: false, retryable: false,
}, },
}) })
@@ -164,7 +164,7 @@ describe('loadListPage', () => {
ok: false, ok: false,
error: { error: {
code: 'invalid-input', code: 'invalid-input',
message: 'リスト ID は数字で入力してください。', message: 'List IDs must contain only numbers.',
retryable: false, retryable: false,
}, },
}) })
@@ -182,7 +182,7 @@ describe('loadListPage', () => {
ok: false, ok: false,
error: { error: {
code: 'list-not-found', code: 'list-not-found',
message: 'リストが見つかりませんでした。', message: 'List not found.',
retryable: false, retryable: false,
}, },
}) })
@@ -199,7 +199,7 @@ describe('loadListPage', () => {
ok: false, ok: false,
error: { error: {
code: 'list-unavailable', code: 'list-unavailable',
message: 'このリストは取得できません。', message: 'This list is unavailable.',
retryable: false, retryable: false,
}, },
}) })
@@ -310,7 +310,7 @@ describe('searchPage', () => {
ok: false, ok: false,
error: { error: {
code: 'upstream', code: 'upstream',
message: 'X から投稿を取得できませんでした。', message: 'Could not load posts from X.',
retryable: true, retryable: true,
}, },
}) })
+10 -13
View File
@@ -73,17 +73,14 @@ const failure = (
type FailureSubject = 'user' | 'list' type FailureSubject = 'user' | 'list'
const notFoundFailure: Record<FailureSubject, () => LoadFailure> = { const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
user: () => user: () => failure('user-not-found', 'User not found.', false),
failure('user-not-found', 'ユーザーが見つかりませんでした。', false), list: () => failure('list-not-found', 'List not found.', false),
list: () =>
failure('list-not-found', 'リストが見つかりませんでした。', false),
} }
const unavailableFailure: Record<FailureSubject, () => LoadFailure> = { const unavailableFailure: Record<FailureSubject, () => LoadFailure> = {
user: () => user: () =>
failure('user-unavailable', 'このユーザーの投稿は取得できません。', false), failure('user-unavailable', 'Posts from this user are unavailable.', false),
list: () => list: () => failure('list-unavailable', 'This list is unavailable.', false),
failure('list-unavailable', 'このリストは取得できません。', false),
} }
function upstreamFailure( function upstreamFailure(
@@ -98,7 +95,7 @@ function upstreamFailure(
: '' : ''
const lower = message.toLowerCase() const lower = message.toLowerCase()
if (lower.includes('timeout') || lower.includes('aborted')) { if (lower.includes('timeout') || lower.includes('aborted')) {
return failure('timeout', '取得がタイムアウトしました。', true) return failure('timeout', 'The request timed out.', true)
} }
if (lower.includes('not found')) { if (lower.includes('not found')) {
return notFoundFailure[subject]() return notFoundFailure[subject]()
@@ -107,7 +104,7 @@ function upstreamFailure(
return unavailableFailure[subject]() return unavailableFailure[subject]()
} }
console.error('Unexpected Bird failure:', diagnostic) console.error('Unexpected Bird failure:', diagnostic)
return failure('upstream', 'X から投稿を取得できませんでした。', true) return failure('upstream', 'Could not load posts from X.', true)
} }
function publicPost(tweet: TweetData): Post { function publicPost(tweet: TweetData): Post {
@@ -141,7 +138,7 @@ export async function loadListChoices(
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true) return failure('timeout', 'The request timed out.', true)
} }
return upstreamFailure(error, 'list') return upstreamFailure(error, 'list')
} }
@@ -181,7 +178,7 @@ export async function loadUserPage(
) )
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true) return failure('timeout', 'The request timed out.', true)
} }
if (error instanceof InputError) { if (error instanceof InputError) {
return failure('invalid-input', error.message, false) return failure('invalid-input', error.message, false)
@@ -205,7 +202,7 @@ export async function loadListPage(
) )
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true) return failure('timeout', 'The request timed out.', true)
} }
if (error instanceof InputError) { if (error instanceof InputError) {
return failure('invalid-input', error.message, false) return failure('invalid-input', error.message, false)
@@ -231,7 +228,7 @@ export async function searchPage(
) )
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true) return failure('timeout', 'The request timed out.', true)
} }
if (error instanceof InputError) { if (error instanceof InputError) {
return failure('invalid-input', error.message, false) return failure('invalid-input', error.message, false)
+1 -1
View File
@@ -30,7 +30,7 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
ok: false, ok: false,
error: { error: {
code: 'relay-config', code: 'relay-config',
message: 'TWITTER_RELAY_BASE_URL を設定してください。', message: 'Set TWITTER_RELAY_BASE_URL.',
retryable: false, retryable: false,
}, },
} }
@@ -29,12 +29,12 @@ export async function fetchProfileNames(
export async function requireProfileName(profileName: string): Promise<void> { export async function requireProfileName(profileName: string): Promise<void> {
const profiles = await fetchProfileNames().catch(() => { const profiles = await fetchProfileNames().catch(() => {
throw new ProfileUnavailableError( throw new ProfileUnavailableError(
'プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。', 'Could not verify profiles. Check the relay connection and try again.',
) )
}) })
if (!profiles.includes(profileName)) { if (!profiles.includes(profileName)) {
throw new ProfileUnavailableError( throw new ProfileUnavailableError(
'このプロファイルは利用できません。カラムのプロファイルを選び直してください。', 'This profile is unavailable. Select a different profile for this column.',
) )
} }
} }
@@ -31,7 +31,7 @@ describe('requireProfileName', () => {
) )
await expect(requireProfileName('work')).rejects.toThrow( await expect(requireProfileName('work')).rejects.toThrow(
'プロファイルを確認できませんでした。', 'Could not verify profiles.',
) )
}) })
}) })
+1 -2
View File
@@ -78,8 +78,7 @@ export function createResearchStore(database = getDatabase()): ResearchStore {
}, },
activate(id) { activate(id) {
database.transaction(() => { database.transaction(() => {
if (id !== null && !load(id)) if (id !== null && !load(id)) throw new Error('Conversation not found.')
throw new Error('この会話は見つかりません。')
setActive(id) setActive(id)
}) })
}, },
+1 -3
View File
@@ -141,9 +141,7 @@ describe('research history persistence', () => {
it('rejects a missing conversation without changing the active conversation', () => { it('rejects a missing conversation without changing the active conversation', () => {
const store = createResearchStore(open()) const store = createResearchStore(open())
store.save(run) store.save(run)
expect(() => store.activate('missing')).toThrow( expect(() => store.activate('missing')).toThrow('Conversation not found.')
'この会話は見つかりません。',
)
expect(store.loadActive()).toEqual(run) 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) actions.save.mockResolvedValueOnce(false)
await expect( await expect(
syncResearchDeck(plan, false, bindings, actions), syncResearchDeck(plan, false, bindings, actions),
).rejects.toThrow('一時デッキを更新できませんでした') ).rejects.toThrow('Unable to update the temporary deck')
await expect( await expect(
syncResearchDeck(plan, true, bindings, actions), syncResearchDeck(plan, true, bindings, actions),
).resolves.toBeUndefined() ).resolves.toBeUndefined()
+1 -1
View File
@@ -22,7 +22,7 @@ export async function syncResearchDeck(
if (existing && !existing.persisted) { if (existing && !existing.persisted) {
if (!(await workspace.save({ ...plan, id: existing.id }))) { if (!(await workspace.save({ ...plan, id: existing.id }))) {
throw new Error( throw new Error(
'一時デッキを更新できませんでした。保存処理の完了後に再試行してください。', 'Unable to update the temporary deck. Try again after saving finishes.',
) )
} }
viewId = existing.id viewId = existing.id
+3 -3
View File
@@ -45,15 +45,15 @@ export function ResearchMarkdown({
img: ({ src, alt }) => img: ({ src, alt }) =>
typeof src === 'string' && src ? ( typeof src === 'string' && src ? (
<a href={src} target="_blank" rel="noopener noreferrer"> <a href={src} target="_blank" rel="noopener noreferrer">
{alt || '画像を開く'} {alt || 'Open image'}
</a> </a>
) : ( ) : (
<span>{alt || '画像'}</span> <span>{alt || 'Image'}</span>
), ),
table: ({ children }) => ( table: ({ children }) => (
<section <section
className="research-markdown-table" 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. // biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users need to scroll wide tables inside the narrow chat pane.
tabIndex={0} tabIndex={0}
> >
+69 -67
View File
@@ -106,28 +106,34 @@ it('retains input, selected accounts, and the request ID across responsive view
connection, connection,
{ ...connection, id: 'second', displayName: 'second' }, { ...connection, id: 'second', displayName: 'second' },
]) ])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled()) await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 2件' })) fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 2' }),
)
fireEvent.click(screen.getByRole('checkbox', { name: 'second · Twitter' })) fireEvent.click(screen.getByRole('checkbox', { name: 'second · Twitter' }))
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCPの反応' }, target: { value: 'WebMCPの反応' },
}) })
fireEvent.click(screen.getByRole('button', { name: '送信' })) fireEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(await screen.findByRole('alert')).toHaveTextContent( 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() expect(screen.queryByText(/internal credential/)).toBeNull()
view.rerender(view.ui(true, undefined, 'mobile')) view.rerender(view.ui(true, undefined, 'mobile'))
expect(screen.getByRole('alert')).toHaveTextContent('入力内容は残っています') expect(screen.getByRole('alert')).toHaveTextContent(
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応') 'Your draft has been kept',
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 1件' })) )
expect(screen.getByLabelText('Message')).toHaveValue('WebMCPの反応')
fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 1' }),
)
expect( expect(
screen.getByRole('checkbox', { name: 'second · Twitter' }), screen.getByRole('checkbox', { name: 'second · Twitter' }),
).not.toBeChecked() ).not.toBeChecked()
expect(MockEventSource.instances).toHaveLength(1) expect(MockEventSource.instances).toHaveLength(1)
expect(MockEventSource.instances[0]?.close).not.toHaveBeenCalled() 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)) await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
expect(actions.start.mock.calls[1]?.[0]).toEqual( expect(actions.start.mock.calls[1]?.[0]).toEqual(
actions.start.mock.calls[0]?.[0], 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 () => { it('opens each generated deck version only once for the run started in this browser', async () => {
const view = show() const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled()) await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCPの反応' }, target: { value: 'WebMCPの反応' },
}) })
fireEvent.click(screen.getByRole('button', { name: '送信' })) fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce()) await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce())
act(() => act(() =>
view.client.setQueryData(['research-status'], { 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), { await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledTimes(2), {
timeout: 2500, 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 () => { 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), expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, true),
) )
fireEvent.click( fireEvent.click(
await screen.findByRole('button', { name: '一時デッキで開く' }), await screen.findByRole('button', { name: 'Open temporary deck' }),
) )
await waitFor(() => await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenLastCalledWith(running.deck, true), 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')) .mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue({ ...running, status: 'cancelled' }) .mockResolvedValue({ ...running, status: 'cancelled' })
show() show()
fireEvent.click(await screen.findByRole('button', { name: '調査を中止' })) fireEvent.click(await screen.findByRole('button', { name: 'Stop research' }))
expect(await screen.findByRole('alert')).toHaveTextContent( 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(() => await waitFor(() =>
expect(screen.queryByRole('button', { name: '調査を中止' })).toBeNull(), expect(screen.queryByRole('button', { name: 'Stop research' })).toBeNull(),
) )
expect(actions.cancel).toHaveBeenCalledWith({ data: { id: running.id } }) 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') await screen.findByText('/home/owner/reports/webmcp.md')
fireEvent.click(screen.getByText('接続アカウント · 6件')) fireEvent.click(screen.getByText('Connected accounts · 6'))
expect( expect(
screen.getByRole('checkbox', { name: 'account6 · Twitter' }), screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
).toHaveAttribute('aria-disabled', 'true') ).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 () => { it('waits for the workspace before restoring a completed plan from the stream', async () => {
const view = show([connection], false) const view = show([connection], false)
await screen.findByLabelText('メッセージ') await screen.findByLabelText('Message')
act(() => act(() =>
MockEventSource.instances[0]?.onmessage?.({ MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ 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] const stream = MockEventSource.instances[0]
expect(stream?.url).toBe('/api/research/events') expect(stream?.url).toBe('/api/research/events')
act(() => stream?.onerror?.()) act(() => stream?.onerror?.())
expect(screen.getByText(/配信に再接続/)).toBeVisible() expect(screen.getByText(/Reconnecting to research updates/)).toBeVisible()
act(() => stream?.onopen?.()) act(() => stream?.onopen?.())
expect(screen.queryByText(/配信に再接続/)).toBeNull() expect(screen.queryByText(/Reconnecting to research updates/)).toBeNull()
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1200)) 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() const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled()) await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCP' }, target: { value: 'WebMCP' },
}) })
fireEvent.click(screen.getByRole('button', { name: '送信' })) fireEvent.click(screen.getByRole('button', { name: 'Send' }))
act(() => act(() =>
MockEventSource.instances[0]?.onmessage?.({ MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ configured: true, run: running }), 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() const view = show()
expect(await screen.findByText('検索結果を比較しました。')).toBeVisible() expect(await screen.findByText('検索結果を比較しました。')).toBeVisible()
expect(screen.getByText('検索カラムを追加しました')).toBeVisible() expect(screen.getByText('検索カラムを追加しました')).toBeVisible()
expect(screen.getByText('あなた')).toBeVisible() expect(screen.getByText('You')).toBeVisible()
const editedDeck = { id: 'other', title: '切り替えたデッキ', columns: [] } const editedDeck = { id: 'other', title: '切り替えたデッキ', columns: [] }
view.rerender(view.ui(true, editedDeck)) view.rerender(view.ui(true, editedDeck))
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'このデッキを絞って' }, target: { value: 'このデッキを絞って' },
}) })
fireEvent.click(screen.getByRole('button', { name: '送信' })) fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => await waitFor(() =>
expect(actions.start).toHaveBeenCalledWith({ expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
@@ -359,9 +367,7 @@ it('shows streamed conversation and sends follow-up input with the current deck
}), }),
}), }),
) )
await waitFor(() => await waitFor(() => expect(screen.getByLabelText('Message')).toHaveValue(''))
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
const updatedDeck = { id: 'plan', title: '絞り込み結果', columns: [] } const updatedDeck = { id: 'plan', title: '絞り込み結果', columns: [] }
act(() => act(() =>
MockEventSource.instances[0]?.onmessage?.({ 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 () => { it('selects newly loaded connected accounts until the user changes their selection', async () => {
const view = show([]) const view = show([])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled()) await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 0件' })) fireEvent.click(
await screen.findByText('接続アカウントを先に追加してください。') screen.getByRole('button', { name: 'Connected accounts · 0' }),
)
await screen.findByText('Add a connected account first.')
view.rerender( view.rerender(
<QueryClientProvider client={view.client}> <QueryClientProvider client={view.client}>
<ResearchPanel <ResearchPanel
@@ -403,10 +411,10 @@ it('selects newly loaded connected accounts until the user changes their selecti
/> />
</QueryClientProvider>, </QueryClientProvider>,
) )
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '調べて' }, 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 () => { 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 }) .mockResolvedValue({ configured: true, run: null })
const view = show() const view = show()
await waitFor(() => await waitFor(() =>
expect( expect(screen.getByRole('button', { name: 'New chat' })).toBeEnabled(),
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
) )
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '下書き' }, target: { value: '下書き' },
}) })
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' })) fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
expect(await screen.findByRole('alert')).toHaveTextContent( 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 const callsBeforeReset = view.onOpenDeck.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' })) fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
await waitFor(() => await waitFor(() => expect(screen.getByLabelText('Message')).toHaveValue(''))
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
expect(screen.queryByText('WebMCPの反応')).toBeNull() expect(screen.queryByText('WebMCPの反応')).toBeNull()
expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset) expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset)
expect(screen.getByText('手動デッキ')).toBeVisible() expect(screen.getByText('手動デッキ')).toBeVisible()
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '新しい話題' }, target: { value: '新しい話題' },
}) })
fireEvent.click(screen.getByRole('button', { name: '送信' })) fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledOnce()) await waitFor(() => expect(actions.start).toHaveBeenCalledOnce())
expect(actions.start.mock.calls[0]?.[0].data).not.toHaveProperty('runId') expect(actions.start.mock.calls[0]?.[0].data).not.toHaveProperty('runId')
expect(actions.reset).toHaveBeenCalledWith({ data: { id: running.id } }) 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 } current = { configured: true, run: running }
const view = show() const view = show()
await screen.findByText('WebMCPの反応') 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 const callsBeforeReset = view.onOpenDeck.mock.calls.length
act(() => act(() =>
MockEventSource.instances[0]?.onmessage?.({ 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() const view = show()
await waitFor(() => await waitFor(() =>
expect( expect(screen.getByRole('button', { name: 'New chat' })).toBeEnabled(),
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
) )
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' })) fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
const newer = { const newer = {
configured: true, configured: true,
run: { run: {
@@ -596,11 +598,11 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
connection, connection,
{ ...connection, id: 'second', displayName: 'account2' }, { ...connection, id: 'second', displayName: 'account2' },
]) ])
await screen.findByLabelText('チャット履歴') await screen.findByLabelText('Chat history')
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '別の下書き' }, target: { value: '別の下書き' },
}) })
fireEvent.change(screen.getByLabelText('チャット履歴'), { fireEvent.change(screen.getByLabelText('Chat history'), {
target: { value: saved.id }, target: { value: saved.id },
}) })
expect(await screen.findByText('保存した会話')).toBeVisible() 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(() => await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true), expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true),
) )
expect(screen.getByLabelText('メッセージ')).toHaveValue('') expect(screen.getByLabelText('Message')).toHaveValue('')
expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible() expect(screen.getByText('Interrupted · Ready to resume')).toBeVisible()
fireEvent.click(screen.getByText('接続アカウント · 1件')) fireEvent.click(screen.getByText('Connected accounts · 1'))
expect( expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }), screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).not.toBeChecked() ).not.toBeChecked()
@@ -631,24 +633,24 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
MockEventSource.instances[0]?.onmessage?.({ MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ data: JSON.stringify({
configured: true, configured: true,
run: { ...saved, message: '更新' }, run: { ...saved, message: 'Refresh' },
history, history,
}), }),
}), }),
) )
await waitFor(() => await waitFor(() =>
expect(view.client.getQueryData(['research-status'])).toMatchObject({ expect(view.client.getQueryData(['research-status'])).toMatchObject({
run: { message: '更新' }, run: { message: 'Refresh' },
}), }),
) )
expect( expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }), screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).toBeChecked() ).toBeChecked()
expect(view.onOpenDeck).toHaveBeenCalledOnce() expect(view.onOpenDeck).toHaveBeenCalledOnce()
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '続きを調べて' }, target: { value: '続きを調べて' },
}) })
fireEvent.click(screen.getByRole('button', { name: '送信' })) fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => await waitFor(() =>
expect(actions.start).toHaveBeenCalledWith({ expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
@@ -680,7 +682,7 @@ it('restores a context-only chat on reload and prevents switching during a runni
await waitFor(() => await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true), 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 () => { 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() const view = show()
fireEvent.change(await screen.findByLabelText('チャット履歴'), { fireEvent.change(await screen.findByLabelText('Chat history'), {
target: { value: saved.id }, target: { value: saved.id },
}) })
const newer = { configured: true, run: { ...running, id: 'remote' }, history } const newer = { configured: true, run: { ...running, id: 'remote' }, history }
+47 -40
View File
@@ -52,12 +52,12 @@ import {
import './research-panel.css' import './research-panel.css'
const statusNames: Record<ResearchRun['status'], string> = { const statusNames: Record<ResearchRun['status'], string> = {
starting: '開始しています', starting: 'Starting',
running: '調査中', running: 'Researching',
complete: '完了', complete: 'Complete',
failed: '失敗', failed: 'Failed',
cancelled: '中止しました', cancelled: 'Cancelled',
interrupted: '中断しました・続きから再開できます', interrupted: 'Interrupted · Ready to resume',
} }
type ResearchInput = { topic: string; connectionIds: string[] } type ResearchInput = { topic: string; connectionIds: string[] }
type ResearchDraft = { type ResearchDraft = {
@@ -186,7 +186,7 @@ export function ResearchPanel({
pendingTurn.current = null pendingTurn.current = null
void syncDeck(deck, activate).catch(() => { void syncDeck(deck, activate).catch(() => {
setError( setError(
'調査デッキを同期できませんでした。「一時デッキで開く」から再試行できます。', 'Unable to sync the research deck. Use “Open temporary deck” to try again.',
) )
}) })
}, [run, syncDeck, ready]) }, [run, syncDeck, ready])
@@ -222,7 +222,7 @@ export function ResearchPanel({
} catch { } catch {
pendingTurn.current = null pendingTurn.current = null
setError( setError(
'送信できませんでした。接続を確認して再試行してください。入力内容は残っています。', 'Unable to send. Check your connection and try again. Your draft has been kept.',
) )
return false return false
} finally { } finally {
@@ -247,7 +247,7 @@ export function ResearchPanel({
: existing, : existing,
) )
} catch { } catch {
setError('調査を中止できませんでした。もう一度お試しください。') setError('Unable to stop research. Please try again.')
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -268,7 +268,7 @@ export function ResearchPanel({
updateDraft({ topic: '' }) updateDraft({ topic: '' })
} catch { } catch {
setError( setError(
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。', 'Unable to start a new chat. Your conversation and draft have been kept. Please try again.',
) )
} finally { } finally {
setBusy(false) setBusy(false)
@@ -287,7 +287,7 @@ export function ResearchPanel({
) )
} catch { } catch {
setError( setError(
'チャットを開けませんでした。入力内容は残っています。もう一度お試しください。', 'Unable to open the chat. Your draft has been kept. Please try again.',
) )
} finally { } finally {
setBusy(false) setBusy(false)
@@ -301,7 +301,7 @@ export function ResearchPanel({
await syncDeck(deck, true) await syncDeck(deck, true)
} catch { } catch {
setError( setError(
'調査デッキを開けませんでした。保存処理の完了後に再試行してください。', 'Unable to open the research deck. Try again after saving finishes.',
) )
} finally { } finally {
setBusy(false) setBusy(false)
@@ -309,9 +309,9 @@ export function ResearchPanel({
} }
const renderPanel: RenderResearchPanel = (onCitationNavigated) => ( const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
<aside className="research-panel" aria-label="調査チャット"> <aside className="research-panel" aria-label="Research chat">
<header className="research-heading"> <header className="research-heading">
<h2>調査チャット</h2> <h2>Research chat</h2>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -319,16 +319,16 @@ export function ResearchPanel({
disabled={busy || !run || isResearchActive(run)} disabled={busy || !run || isResearchActive(run)}
title={ title={
isResearchActive(run) isResearchActive(run)
? '調査を中止してから新しいチャットを開始できます' ? 'Stop research before starting a new chat'
: undefined : undefined
} }
onClick={() => void newChat()} onClick={() => void newChat()}
> >
新しいチャット New chat
</Button> </Button>
{(status.data?.history?.length ?? 0) > 0 && ( {(status.data?.history?.length ?? 0) > 0 && (
<label className="research-history" htmlFor={historyId}> <label className="research-history" htmlFor={historyId}>
<span>チャット履歴</span> <span>Chat history</span>
<NativeSelect <NativeSelect
id={historyId} id={historyId}
size="sm" size="sm"
@@ -337,7 +337,7 @@ export function ResearchPanel({
onChange={(event) => void selectChat(event.target.value)} onChange={(event) => void selectChat(event.target.value)}
> >
<NativeSelectOption value="" disabled> <NativeSelectOption value="" disabled>
新しいチャット New chat
</NativeSelectOption> </NativeSelectOption>
{status.data?.history?.map((entry) => ( {status.data?.history?.map((entry) => (
<NativeSelectOption key={entry.id} value={entry.id}> <NativeSelectOption key={entry.id} value={entry.id}>
@@ -350,14 +350,15 @@ export function ResearchPanel({
</header> </header>
{reconnecting && ( {reconnecting && (
<p className="deck-save-status" role="status"> <p className="deck-save-status" role="status">
調査状況の配信に再接続しています。処理は自宅サーバーで続きます。 Reconnecting to research updates. Processing continues on your home
server.
</p> </p>
)} )}
{status.isError && ( {status.isError && (
<p className="deck-error" role="alert"> <p className="deck-error" role="alert">
調査状況を取得できませんでした。 Unable to load research status.
<Button type="button" onClick={() => void status.refetch()}> <Button type="button" onClick={() => void status.refetch()}>
再試行 Retry
</Button> </Button>
</p> </p>
)} )}
@@ -367,9 +368,11 @@ export function ResearchPanel({
</p> </p>
)} )}
{status.isPending ? ( {status.isPending ? (
<p role="status">調査の接続を確認しています…</p> <p role="status">Checking the research connection…</p>
) : !status.data?.configured ? ( ) : !status.data?.configured ? (
<p role="status">自宅のCodexへの接続が設定されていません。</p> <p role="status">
The connection to Codex on your home server is not configured.
</p>
) : null} ) : null}
<ResearchConversation <ResearchConversation
key={`conversation:${run?.id ?? 'new'}`} key={`conversation:${run?.id ?? 'new'}`}
@@ -416,16 +419,20 @@ function ResearchConversation({
citations: ResearchCitation[] citations: ResearchCitation[]
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
}) { }) {
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' } const names = { user: 'You', assistant: 'Codex', tool: 'Tool activity' }
return ( return (
<MessageScrollerProvider autoScroll> <MessageScrollerProvider autoScroll>
<MessageScroller className="research-conversation"> <MessageScroller className="research-conversation">
<MessageScrollerViewport aria-label="調査メッセージ"> <MessageScrollerViewport aria-label="Research messages">
<MessageScrollerContent aria-label="調査の会話" className="gap-5 p-4"> <MessageScrollerContent
aria-label="Research conversation"
className="gap-5 p-4"
>
{messages.length === 0 ? ( {messages.length === 0 ? (
<MessageScrollerItem messageId="empty"> <MessageScrollerItem messageId="empty">
<p className="research-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> </p>
</MessageScrollerItem> </MessageScrollerItem>
) : ( ) : (
@@ -471,7 +478,7 @@ function ResearchConversation({
)} )}
</MessageScrollerContent> </MessageScrollerContent>
</MessageScrollerViewport> </MessageScrollerViewport>
<MessageScrollerButton aria-label="最新のメッセージへ" /> <MessageScrollerButton aria-label="Jump to latest message" />
</MessageScroller> </MessageScroller>
</MessageScrollerProvider> </MessageScrollerProvider>
) )
@@ -508,7 +515,7 @@ function ResearchComposer({
return ( return (
<form <form
className="research-start-form" className="research-start-form"
aria-label="調査メッセージ" aria-label="Research messages"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
if (!topic.trim() || validSelection.length === 0 || disabled) return if (!topic.trim() || validSelection.length === 0 || disabled) return
@@ -521,18 +528,18 @@ function ResearchComposer({
}} }}
> >
<p className="research-context"> <p className="research-context">
送信するデッキ: <strong>{contextTitle}</strong> Deck to send: <strong>{contextTitle}</strong>
</p> </p>
<fieldset disabled={disabled}> <fieldset disabled={disabled}>
<Collapsible className="research-account-picker" disabled={disabled}> <Collapsible className="research-account-picker" disabled={disabled}>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}> <CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
接続アカウント · {validSelection.length}件 Connected accounts · {validSelection.length}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<fieldset className="research-accounts"> <fieldset className="research-accounts">
<legend>調査に使う接続アカウント(最大6件)</legend> <legend>Accounts for research (up to 6)</legend>
{available.length === 0 ? ( {available.length === 0 ? (
<p>接続アカウントを先に追加してください。</p> <p>Add a connected account first.</p>
) : ( ) : (
available.map((connection) => ( available.map((connection) => (
<label key={connection.id} htmlFor={`${id}-${connection.id}`}> <label key={connection.id} htmlFor={`${id}-${connection.id}`}>
@@ -562,7 +569,7 @@ function ResearchComposer({
</fieldset> </fieldset>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<label htmlFor={id}>メッセージ</label> <label htmlFor={id}>Message</label>
<InputGroup> <InputGroup>
<InputGroupTextarea <InputGroupTextarea
id={id} id={id}
@@ -570,7 +577,7 @@ function ResearchComposer({
onChange={(event) => onTopicChange(event.target.value)} onChange={(event) => onTopicChange(event.target.value)}
maxLength={1000} maxLength={1000}
rows={3} rows={3}
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。" placeholder="e.g. Compare reactions to WebMCP, then refine this deck."
required required
disabled={disabled} disabled={disabled}
/> />
@@ -583,7 +590,7 @@ function ResearchComposer({
disabled || !topic.trim() || validSelection.length === 0 disabled || !topic.trim() || validSelection.length === 0
} }
> >
送信 Send
</InputGroupButton> </InputGroupButton>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
@@ -605,7 +612,7 @@ function ResearchRunStatus({
}) { }) {
const deck = run.deck ?? run.contextDeck const deck = run.deck ?? run.contextDeck
return ( return (
<section className="research-run" aria-label="調査状況"> <section className="research-run" aria-label="Research status">
<Marker role="status"> <Marker role="status">
<MarkerContent>{statusNames[run.status]}</MarkerContent> <MarkerContent>{statusNames[run.status]}</MarkerContent>
</Marker> </Marker>
@@ -616,7 +623,7 @@ function ResearchRunStatus({
)} )}
{run.reportPath && ( {run.reportPath && (
<p> <p>
Markdownの保存先 Markdown location
<br /> <br />
<code className="research-report-path">{run.reportPath}</code> <code className="research-report-path">{run.reportPath}</code>
</p> </p>
@@ -630,7 +637,7 @@ function ResearchRunStatus({
disabled={busy} disabled={busy}
onClick={onCancel} onClick={onCancel}
> >
調査を中止 Stop research
</Button> </Button>
)} )}
{deck && ( {deck && (
@@ -642,7 +649,7 @@ function ResearchRunStatus({
onOpenDeck(deck) onOpenDeck(deck)
}} }}
> >
一時デッキで開く Open temporary deck
</Button> </Button>
)} )}
</div> </div>
+29 -23
View File
@@ -94,7 +94,7 @@ export function createResearchService(
if (isResearchActive(run) && run) { if (isResearchActive(run) && run) {
run.status = 'interrupted' run.status = 'interrupted'
run.error = run.error =
'サーバーの再起動で調査が中断されました。メッセージを送ると同じ会話を再開できます。' 'Research was interrupted by a server restart. Send a message to resume this conversation.'
deps.store.save(run) deps.store.save(run)
} }
let state: Execution | undefined let state: Execution | undefined
@@ -124,7 +124,7 @@ export function createResearchService(
if (run) { if (run) {
run.status = 'failed' run.status = 'failed'
run.error = 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) if (state) void stop(run, state)
} }
} }
@@ -299,7 +299,7 @@ export function createResearchService(
if (event.data.turn.status !== 'completed') { if (event.data.turn.status !== 'completed') {
fail( fail(
current, current,
'Codexの調査が完了しませんでした。接続・認証・実行権限を確認してください。', 'Codex could not complete the research. Check its connection, authentication, and permissions.',
) )
return return
} }
@@ -332,7 +332,7 @@ export function createResearchService(
} catch { } catch {
fail( fail(
current, 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') throw new Error('inactive research')
const id = call.callId ?? randomUUID() const id = call.callId ?? randomUUID()
message(current, { id, role: 'tool', text: `${call.tool} 実行中` }) message(current, { id, role: 'tool', text: `${call.tool} Running` })
emit() emit()
if (!active(current)) throw new Error('inactive research') if (!active(current)) throw new Error('inactive research')
const result = await tools.execute(call.tool, call.arguments) const result = await tools.execute(call.tool, call.arguments)
@@ -357,7 +357,7 @@ export function createResearchService(
message(current, { message(current, {
id, id,
role: 'tool', role: 'tool',
text: `${call.tool} ${success ? '完了' : '失敗'}`, text: `${call.tool} ${success ? 'Complete' : 'Failed'}`,
}) })
emit() emit()
return { return {
@@ -368,7 +368,7 @@ export function createResearchService(
() => () =>
fail( fail(
current, current,
'Codexとの接続が切れました。ホスト上の途中ファイルは残ります。', 'Disconnected from Codex. Files created so far remain on the host.',
), ),
) )
execution.client = connection execution.client = connection
@@ -427,7 +427,7 @@ export function createResearchService(
{ {
type: 'text', type: 'text',
text_elements: [], 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 { } catch {
fail( fail(
current, 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, status: snapshot,
select(id: string, currentId: string | null) { select(id: string, currentId: string | null) {
if (isResearchActive(run) || state?.stopping) 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) if ((run?.id ?? null) !== currentId)
throw new Error( throw new Error(
'別のチャットが開かれています。最新の状態を確認してください。', 'Another chat is open. Refresh to see the latest state.',
) )
const selected = deps.store.load(id) const selected = deps.store.load(id)
if (!selected) throw new Error('会話が見つかりません。') if (!selected) throw new Error('Conversation not found.')
deps.store.activate(id) deps.store.activate(id)
run = selected run = selected
state = undefined state = undefined
@@ -464,11 +466,11 @@ export function createResearchService(
reset(id: string) { reset(id: string) {
if (isResearchActive(run) || state?.stopping) if (isResearchActive(run) || state?.stopping)
throw new Error( throw new Error(
'調査中です。中止してから新しいチャットを開始してください。', 'Research is running. Stop it before starting a new chat.',
) )
if (run && run.id !== id) if (run && run.id !== id)
throw new Error( throw new Error(
'別のチャットが開かれています。最新の状態を確認してください。', 'Another chat is open. Refresh to see the latest state.',
) )
deps.store.activate(null) deps.store.activate(null)
run = null run = null
@@ -489,21 +491,25 @@ export function createResearchService(
const previous = requests.get(input.requestId) const previous = requests.get(input.requestId)
if (previous) { if (previous) {
if (previous.input !== key) if (previous.input !== key)
throw new Error('同じリクエストIDの内容が変わっています。') throw new Error('The content for this request ID has changed.')
return structuredClone(previous.run) return structuredClone(previous.run)
} }
if (isResearchActive(run) || state?.stopping) 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)) if (input.runId && (!run || run.id !== input.runId || !run.threadId))
throw new Error( throw new Error(
'続ける会話が見つかりません。新しい会話を開始してください。', 'The conversation to continue could not be found. Start a new conversation.',
) )
const config = deps.config() const config = deps.config()
if (!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) if (requests.size >= 100)
throw new Error( throw new Error(
'プロトタイプの実行上限です。アプリを再起動してください。', 'The prototype run limit has been reached. Restart the app.',
) )
const current: ResearchRun = { const current: ResearchRun = {
...(input.runId && run ? structuredClone(run) : {}), ...(input.runId && run ? structuredClone(run) : {}),
@@ -514,7 +520,7 @@ export function createResearchService(
status: 'starting', status: 'starting',
startedAt: Date.now(), startedAt: Date.now(),
deckVersion: input.runId && run ? run.deckVersion : 0, deckVersion: input.runId && run ? run.deckVersion : 0,
message: 'Codexに調査を依頼しています…', message: 'Asking Codex to research…',
messages: [ messages: [
...(input.runId && run ? structuredClone(run.messages) : []), ...(input.runId && run ? structuredClone(run.messages) : []),
{ id: input.requestId, role: 'user', text: input.topic }, { id: input.requestId, role: 'user', text: input.topic },
@@ -532,7 +538,7 @@ export function createResearchService(
if (!active(current)) return if (!active(current)) return
current.status = 'failed' current.status = 'failed'
current.error = current.error =
'調査の制限時間(15分)に達しました。途中のMarkdownはホストに残ります。' 'Research reached its 15-minute time limit. Markdown files created so far remain on the host.'
void stop(current, execution) void stop(current, execution)
emit() emit()
}, MAX_RUN_MS) }, MAX_RUN_MS)
@@ -542,14 +548,14 @@ export function createResearchService(
return structuredClone(current) return structuredClone(current)
}, },
async cancel(id: string) { 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) if (!state) return structuredClone(run)
const current = run const current = run
const execution = state const execution = state
if (active(current)) { if (active(current)) {
current.status = 'cancelled' current.status = 'cancelled'
current.message = current.message =
'調査を中断しました。ホストに作成済みのファイルは残ります。' 'Research stopped. Files already created remain on the host.'
const stopping = stop(current, execution) const stopping = stop(current, execution)
emit() emit()
await stopping 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) const first = service.start(request)
expect(service.start(request).id).toBe(first.id) expect(service.start(request).id).toBe(first.id)
expect(() => service.start({ ...request, topic: 'Different' })).toThrow( expect(() => service.start({ ...request, topic: 'Different' })).toThrow(
'内容が変わっています', 'content for this request ID has changed',
) )
const client = await running() const client = await running()
expect(clients).toHaveLength(1) 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 () => { it('rejects a second active research run', async () => {
service.start(input()) service.start(input())
await running() await running()
expect(() => service.start(input())).toThrow('調査が実行中') expect(() => service.start(input())).toThrow('Research is running')
expect(clients).toHaveLength(1) 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) const cancelled = service.cancel(first.id)
expect(service.status().run?.status).toBe('cancelled') expect(service.status().run?.status).toBe('cancelled')
expect(old.close).not.toHaveBeenCalled() expect(old.close).not.toHaveBeenCalled()
expect(() => service.start(input())).toThrow('調査が実行中') expect(() => service.start(input())).toThrow('Research is running')
resolveTurn({ turn: { id: old.turnId } }) resolveTurn({ turn: { id: old.turnId } })
await cancelled await cancelled
expect(old.calls).toHaveBeenCalledWith('turn/interrupt', { 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' }), expect.objectContaining({ role: 'user', text: 'WebMCP' }),
{ id: 'reply-one', role: 'assistant', text: 'どんな調査をしますか?' }, { id: 'reply-one', role: 'assistant', text: 'どんな調査をしますか?' },
expect.objectContaining({ role: 'user', 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: '読みました' }, { id: 'reply-two', role: 'assistant', text: '読みました' },
]) ])
old.disconnected() old.disconnected()
@@ -437,7 +440,7 @@ it('resumes the same conversation with fresh deck context and preserves prior me
it('rejects unknown continuation IDs', () => { it('rejects unknown continuation IDs', () => {
expect(() => service.start({ ...input(), runId: randomUUID() })).toThrow( 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(service.reset(first.id).run).toBeNull()
expect(await readFile(reportPath, 'utf8')).toBe('# Saved research') expect(await readFile(reportPath, 'utf8')).toBe('# Saved research')
expect(() => service.start({ ...input(), runId: first.id })).toThrow( expect(() => service.start({ ...input(), runId: first.id })).toThrow(
'続ける会話が見つかりません', 'The conversation to continue could not be found',
) )
const next = service.start(input()) const next = service.start(input())
const current = await running() 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 () => { it('requires an active turn to stop before resetting and rejects a stale reset from another viewer', async () => {
const first = service.start(input()) const first = service.start(input())
await running() await running()
expect(() => service.reset(first.id)).toThrow('中止してから') expect(() => service.reset(first.id)).toThrow('Stop it before')
await service.cancel(first.id) 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.status().run?.id).toBe(first.id)
expect(service.reset(first.id).run).toBeNull() 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 () => { it('rejects switching conversations while running or from a stale viewer', async () => {
const first = service.start(input()) const first = service.start(input())
const client = await running() 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() client.completed()
await vi.waitFor(() => expect(service.status().run?.status).toBe('complete')) await vi.waitFor(() => expect(service.status().run?.status).toBe('complete'))
expect(() => service.select(first.id, null)).toThrow('別のチャット') expect(() => service.select(first.id, null)).toThrow('Another chat')
expect(() => service.select(randomUUID(), first.id)).toThrow('見つかりません') expect(() => service.select(randomUUID(), first.id)).toThrow('not found')
expect(service.status().run?.id).toBe(first.id) 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({ expect(service.status().run).toMatchObject({
status: 'failed', status: 'failed',
error: expect.stringContaining('保存できません'), error: expect.stringContaining('Unable to save'),
}) })
await vi.waitFor(() => expect(client.close).toHaveBeenCalledTimes(1)) await vi.waitFor(() => expect(client.close).toHaveBeenCalledTimes(1))
expect(client.calls).toHaveBeenCalledWith('turn/interrupt', { expect(client.calls).toHaveBeenCalledWith('turn/interrupt', {
@@ -750,7 +755,7 @@ function orphanedRun(): ResearchRun {
turnId: 'orphan-turn', turnId: 'orphan-turn',
connectionIds: [account.id], connectionIds: [account.id],
deckVersion: 0, deckVersion: 0,
message: '調査中', message: 'Researching',
citations: [], citations: [],
messages: [{ id: 'original', role: 'user', text: 'WebMCP' }], 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({ expect(service.status().run).toMatchObject({
...interrupted, ...interrupted,
status: 'interrupted', status: 'interrupted',
error: expect.stringContaining('再起動'), error: expect.stringContaining('server restart'),
}) })
expect(store.loadActive()?.status).toBe('interrupted') expect(store.loadActive()?.status).toBe('interrupted')
expect(clients).toHaveLength(0) expect(clients).toHaveLength(0)
@@ -119,7 +119,7 @@ it.each([
act(() => { act(() => {
expect(result.current.navigate(citation.post.url, [citation])).toBe(true) 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') expect(result.current.target?.deckId).toBe('created')
}) })
@@ -83,7 +83,7 @@ export function useCitationNavigation({
})) }))
return true return true
} }
const deck = createTemporary('引用元の投稿', [citation.column]) const deck = createTemporary('Cited posts', [citation.column])
setTarget((previous) => ({ setTarget((previous) => ({
...citation, ...citation,
deckId: deck.id, deckId: deck.id,
+25 -25
View File
@@ -32,7 +32,7 @@ export function ConversationDetail({
return ( return (
<article <article
className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10" className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10"
aria-label={`${conversation.name}との会話`} aria-label={`Conversation with ${conversation.name}`}
> >
<header className="mb-8 space-y-2"> <header className="mb-8 space-y-2">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -44,13 +44,13 @@ export function ConversationDetail({
<p className="text-sm text-muted-foreground">{conversation.subject}</p> <p className="text-sm text-muted-foreground">{conversation.subject}</p>
</header> </header>
<section className="mb-8 space-y-4" aria-label="会話の要点"> <section className="mb-8 space-y-4" aria-label="Conversation summary">
<p className="text-sm leading-7">{conversation.summary}</p> <p className="text-sm leading-7">{conversation.summary}</p>
<div className="border-l-2 border-border pl-4"> <div className="border-l-2 border-border pl-4">
<p className="text-sm leading-6">{conversation.nextStep}</p> <p className="text-sm leading-6">{conversation.nextStep}</p>
{conversation.deadline && ( {conversation.deadline && (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
相手の指定期限 · {conversation.deadline} Requested deadline · {conversation.deadline}
</p> </p>
)} )}
</div> </div>
@@ -58,7 +58,7 @@ export function ConversationDetail({
<details className="group mb-8 border-y border-border py-3"> <details className="group mb-8 border-y border-border py-3">
<summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"> <summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
元のやりとり{' '} Original messages{' '}
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" /> <ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
</summary> </summary>
<div className="space-y-5 pt-5"> <div className="space-y-5 pt-5">
@@ -70,7 +70,7 @@ export function ConversationDetail({
tabIndex={-1} tabIndex={-1}
> >
<p className="mb-2 text-xs text-muted-foreground"> <p className="mb-2 text-xs text-muted-foreground">
{message.direction === 'in' ? conversation.name : '自分'} ·{' '} {message.direction === 'in' ? conversation.name : 'You'} ·{' '}
{message.channel} · {message.time} {message.channel} · {message.time}
</p> </p>
<p className="whitespace-pre-wrap text-sm leading-7"> <p className="whitespace-pre-wrap text-sm leading-7">
@@ -81,13 +81,13 @@ export function ConversationDetail({
</div> </div>
</details> </details>
<section aria-label="返信を作る" className="space-y-3"> <section aria-label="Compose a reply" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<label <label
htmlFor={`support-reply-${conversation.id}`} htmlFor={`support-reply-${conversation.id}`}
className="text-sm font-medium" className="text-sm font-medium"
> >
返信 Reply
</label> </label>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{conversation.replyChannel} → {conversation.name} {conversation.replyChannel} → {conversation.name}
@@ -122,11 +122,11 @@ export function ConversationDetail({
{conversation.scheduleOptions.length > 0 && ( {conversation.scheduleOptions.length > 0 && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<NativeSelect <NativeSelect
aria-label="日程候補" aria-label="Suggested times"
value={slot} value={slot}
onChange={(event) => setSlot(event.target.value)} onChange={(event) => setSlot(event.target.value)}
> >
<NativeSelectOption value="">候補日時を選ぶ</NativeSelectOption> <NativeSelectOption value="">Choose a time</NativeSelectOption>
{conversation.scheduleOptions.map((option) => ( {conversation.scheduleOptions.map((option) => (
<NativeSelectOption key={option} value={option}> <NativeSelectOption key={option} value={option}>
{option} {option}
@@ -139,18 +139,18 @@ export function ConversationDetail({
disabled={!slot} disabled={!slot}
onClick={() => { onClick={() => {
setDraft( setDraft(
(current) => `${current}\n\n${slot}はいかがでしょうか。`, (current) => `${current}\n\nWould ${slot} work for you?`,
) )
setTone('') setTone('')
}} }}
> >
返信に入れる Add to reply
</Button> </Button>
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
disabled={!slot} disabled={!slot}
aria-label="予定を仮置きする" aria-label="Preview calendar hold"
onClick={() => setReview('schedule')} onClick={() => setReview('schedule')}
> >
<CalendarPlus /> <CalendarPlus />
@@ -163,13 +163,13 @@ export function ConversationDetail({
className="flex items-center gap-2 text-xs text-muted-foreground" className="flex items-center gap-2 text-xs text-muted-foreground"
> >
<Check className="size-3" /> <Check className="size-3" />
{scheduled} をこの画面に仮置きしました {scheduled} held in this preview
</p> </p>
)} )}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2"> <div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<Button disabled={!draft.trim()} onClick={() => setReview('send')}> <Button disabled={!draft.trim()} onClick={() => setReview('send')}>
<Send /> <Send />
送信内容を確認 Review reply
</Button> </Button>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Button <Button
@@ -177,21 +177,21 @@ export function ConversationDetail({
size="sm" size="sm"
onClick={() => onResolve('later')} onClick={() => onResolve('later')}
> >
今は置いておく Leave for later
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onResolve('waiting')} onClick={() => onResolve('waiting')}
> >
相手待ち Waiting
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onResolve('dismissed')} onClick={() => onResolve('dismissed')}
> >
返信せず閉じる Close without replying
</Button> </Button>
</div> </div>
</div> </div>
@@ -199,7 +199,7 @@ export function ConversationDetail({
<details className="group mt-10 border-t border-border pt-4"> <details className="group mt-10 border-t border-border pt-4">
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"> <summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
人物メモ{' '} About this person{' '}
<ChevronDown className="size-3 transition-transform group-open:rotate-180" /> <ChevronDown className="size-3 transition-transform group-open:rotate-180" />
</summary> </summary>
<ul className="mt-4 space-y-3 text-sm leading-6"> <ul className="mt-4 space-y-3 text-sm leading-6">
@@ -221,7 +221,7 @@ export function ConversationDetail({
source?.focus() source?.focus()
}} }}
> >
出典 Source
</a> </a>
</li> </li>
))} ))}
@@ -237,12 +237,12 @@ export function ConversationDetail({
<DialogContent className="sm:max-w-xl"> <DialogContent className="sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{review === 'schedule' ? '予定の確認' : '送信内容の確認'} {review === 'schedule' ? 'Review calendar hold' : 'Review reply'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{review === 'schedule' {review === 'schedule'
? '外部カレンダーには登録せず、この画面だけに仮置きします。' ? 'This creates a hold in this preview only. Your calendar will not change.'
: '外部には送信されません。完了後の表示を試せます。'} : 'Try the completed state. No message will be sent.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
@@ -260,7 +260,7 @@ export function ConversationDetail({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setReview(null)}> <Button variant="outline" onClick={() => setReview(null)}>
戻る Back
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@@ -268,12 +268,12 @@ export function ConversationDetail({
else else
onResolve( onResolve(
'sent', 'sent',
`${conversation.replyChannel} → ${conversation.name}(送信プレビュー)\n${draft}`, `${conversation.replyChannel} → ${conversation.name} (reply preview)\n${draft}`,
) )
setReview(null) setReview(null)
}} }}
> >
{review === 'schedule' ? '仮置きを試す' : '送信を試す'} {review === 'schedule' ? 'Try calendar hold' : 'Try sending'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+89 -63
View File
@@ -27,159 +27,185 @@ export type Conversation = {
export const conversations: Conversation[] = [ export const conversations: Conversation[] = [
{ {
id: 'design-review', id: 'design-review',
name: '青木 遥', name: 'Haruka Aoki',
role: 'プロダクトデザイナー', role: 'Product designer',
subject: '来週のレビュー、日程を決める', subject: 'Schedule next week’s design review',
channels: ['Slack', 'Gmail'], channels: ['Slack', 'Gmail'],
replyChannel: 'Slack', replyChannel: 'Slack',
updatedAt: '10:42', updatedAt: '10:42',
deadline: '9月29日 17:00', deadline: 'Sep 29, 17:00',
summary: summary:
'新しい画面のレビュー依頼。資料はメールで届いていて、Slackで日程の返事を待っている。30分あればよい。', 'A request to review the new screens. The materials arrived by email, and Haruka is waiting on Slack to arrange a 30-minute review.',
nextStep: '資料を確認して、30分の候補日時を返す。', nextStep: 'Review the materials and suggest a time for a 30-minute call.',
messages: [ messages: [
{ {
id: 'aoki-1', id: 'aoki-1',
channel: 'Gmail', channel: 'Gmail',
time: '9月27日 16:20', time: 'Sep 27, 16:20',
direction: 'in', direction: 'in',
text: 'レビュー用の画面案を共有します。特に初回の導線について意見を聞きたいです。確認のお時間は30分ほどを想定しています。', text: 'Sharing the screens for review. I would especially appreciate your thoughts on the first-time experience. The review should take about 30 minutes.',
}, },
{ {
id: 'aoki-2', id: 'aoki-2',
channel: 'Slack', channel: 'Slack',
time: '9月28日 10:42', time: 'Sep 28, 10:42',
direction: 'in', direction: 'in',
text: '昨日メールしたレビューの件、来週どこかでお時間いただけますか? 9月29日17時までに候補をいただけると助かります。午後だとうれしいです。', text: 'Could we find a time next week for the review I emailed yesterday? Please send a few options by September 29 at 17:00. An afternoon would be ideal.',
}, },
], ],
facts: [ facts: [
{ text: 'レビューは30分を想定している。', sourceId: 'aoki-1' }, {
{ text: '今回の日程は午後を希望している。', sourceId: 'aoki-2' }, text: 'The review should take 30 minutes.',
sourceId: 'aoki-1',
},
{
text: 'An afternoon is preferred for this review.',
sourceId: 'aoki-2',
},
], ],
drafts: [ drafts: [
{ {
label: '短く', label: 'Brief',
text: '資料ありがとうございます。確認します! 来週の午後で、30分ほどお話しできればと思います。', text: 'Thanks for the materials! I will take a look. Let’s find 30 minutes one afternoon next week.',
}, },
{ {
label: '丁寧に', label: 'Polite',
text: '資料をご共有いただきありがとうございます。初回の導線を中心に確認いたします。来週の午後に30分ほどお時間をいただけますでしょうか。', text: 'Thank you for sharing the materials. I will focus on the first-time experience. Would you have 30 minutes one afternoon next week?',
}, },
{ {
label: 'まず受け取る', label: 'Acknowledge',
text: 'ご連絡ありがとうございます。資料、受け取りました。日程を確認して9月29日17時までにお返事します。', text: 'Thanks for getting in touch. I have the materials and will check my schedule and reply by September 29 at 17:00.',
}, },
], ],
scheduleOptions: ['10月5日(月)14:00–14:30', '10月6日(火)15:00–15:30'], scheduleOptions: ['Mon, Oct 5, 14:00–14:30', 'Tue, Oct 6, 15:00–15:30'],
}, },
{ {
id: 'weekend-coffee', id: 'weekend-coffee',
name: '森 直人', name: 'Naoto Mori',
role: '友人', role: 'Friend',
subject: '久しぶりに、お茶でも', subject: 'Catch up over coffee',
channels: ['LINE'], channels: ['LINE'],
replyChannel: 'LINE', replyChannel: 'LINE',
updatedAt: '昨日', updatedAt: 'Yesterday',
summary: summary:
'近くに来るのでお茶をしようという誘い。急ぎの返事は求められていない。', 'An invitation to meet for coffee while Naoto is nearby. There is no rush to reply.',
nextStep: '会いたければ都合を返す。今決めなくてもよい。', nextStep:
'Share your availability if you would like to meet. You do not need to decide right now.',
messages: [ messages: [
{ {
id: 'mori-1', id: 'mori-1',
channel: 'LINE', channel: 'LINE',
time: '9月27日 19:15', time: 'Sep 27, 19:15',
direction: 'in', direction: 'in',
text: '来週そっちの方に行くかも。時間合ったらお茶しない? 土曜の午後なら空いてる。急ぎじゃないので、予定わかったら!', text: 'I might be in your area next week. Want to grab coffee if our schedules line up? Saturday afternoon works for me. No rush—let me know when you know!',
},
],
facts: [
{
text: 'Saturday afternoon is available this time.',
sourceId: 'mori-1',
}, },
], ],
facts: [{ text: '今回は土曜の午後が空いている。', sourceId: 'mori-1' }],
drafts: [ drafts: [
{ {
label: '短く', label: 'Brief',
text: 'いいね、久しぶりに会いたい! 土曜の午後で予定見てみるね。', text: 'Sounds good, it would be great to catch up! I will check Saturday afternoon.',
}, },
{ {
label: '丁寧に', label: 'Polite',
text: '誘ってくれてありがとう! ぜひお茶したいです。土曜の午後、都合を確認して連絡するね。', text: 'Thanks for the invitation! I would love to grab coffee. I will check my plans for Saturday afternoon and let you know.',
}, },
{ {
label: 'まず受け取る', label: 'Acknowledge',
text: 'ありがとう! まだ予定が見えないので、わかったら連絡するね。', text: 'Thanks! I am not sure about my schedule yet, but I will let you know.',
}, },
], ],
scheduleOptions: ['10月3日(土)14:00–15:00', '10月3日(土)16:00–17:00'], scheduleOptions: ['Sat, Oct 3, 14:00–15:00', 'Sat, Oct 3, 16:00–17:00'],
}, },
{ {
id: 'estimate-check', id: 'estimate-check',
name: '藤井 理沙', name: 'Risa Fujii',
role: '制作パートナー', role: 'Project partner',
subject: '見積もりの前提を確認する', subject: 'Confirm the scope for an estimate',
channels: ['Gmail'], channels: ['Gmail'],
replyChannel: 'Gmail', replyChannel: 'Gmail',
updatedAt: '9:10', updatedAt: '9:10',
deadline: '9月30日 12:00', deadline: 'Sep 30, 12:00',
summary: summary:
'見積もりを進めるため、対象画面が3画面でよいか確認が届いている。回答期限は9月30日の正午。', 'Risa needs confirmation that the project covers three screens before preparing an estimate. Please reply by noon on September 30.',
nextStep: '対象画面数を確認して返答する。未確認のまま確約しない。', nextStep:
'Confirm the number of screens before replying. Avoid committing before checking.',
messages: [ messages: [
{ {
id: 'fujii-1', id: 'fujii-1',
channel: 'Gmail', channel: 'Gmail',
time: '9月28日 9:10', time: 'Sep 28, 9:10',
direction: 'in', direction: 'in',
text: '概算の作成にあたり、対象は一覧・詳細・設定の3画面という理解でよろしいでしょうか。9月30日正午までにご確認いただければ、同日中にお見積もりをお送りします。', text: 'Before preparing an estimate, could you confirm that the scope is the list, detail, and settings screens? If you can confirm by noon on September 30, I can send the estimate the same day.',
}, },
], ],
facts: [ facts: [
{ text: '対象画面数の確認後に概算を作成する予定。', sourceId: 'fujii-1' }, {
text: 'The estimate will be prepared after the screen count is confirmed.',
sourceId: 'fujii-1',
},
], ],
drafts: [ drafts: [
{ {
label: '短く', label: 'Brief',
text: 'ありがとうございます。対象の3画面について確認し、9月30日正午までにお返事します。', text: 'Thanks. I will check the three screens and reply by noon on September 30.',
}, },
{ {
label: '丁寧に', label: 'Polite',
text: 'ご確認ありがとうございます。一覧・詳細・設定の3画面で相違ないか確認のうえ、9月30日正午までに回答いたします。', text: 'Thank you for checking. I will confirm whether the list, detail, and settings screens cover the scope and reply by noon on September 30.',
}, },
{ {
label: 'まず受け取る', label: 'Acknowledge',
text: 'ご連絡を受け取りました。対象範囲を確認して、改めてご連絡します。', text: 'Thanks for your message. I will check the scope and get back to you.',
}, },
], ],
scheduleOptions: [], scheduleOptions: [],
}, },
{ {
id: 'reading-note', id: 'reading-note',
name: '田辺 悠', name: 'Yu Tanabe',
role: '勉強会の仲間', role: 'Study group friend',
subject: '前に話していた記事', subject: 'The article we talked about',
channels: ['Slack'], channels: ['Slack'],
replyChannel: 'Slack', replyChannel: 'Slack',
updatedAt: '金曜日', updatedAt: 'Friday',
summary: summary:
'前の勉強会で話題になった記事を共有してくれた。質問や返答の依頼はない。', 'Yu shared an article discussed at the last study group. There are no questions or requests for a reply.',
nextStep: '読む時間がなければ、返信せず閉じてよい。', nextStep:
'If you do not have time to read it, you can close this without replying.',
messages: [ messages: [
{ {
id: 'tanabe-1', id: 'tanabe-1',
channel: 'Slack', channel: 'Slack',
time: '9月25日 18:30', time: 'Sep 25, 18:30',
direction: 'in', direction: 'in',
text: '前に話していた、道具を増やさず仕事を進める話の記事を見つけたので共有! 時間あるときにどうぞ。返信不要です。', text: 'Found the article we discussed about getting work done with fewer tools! Read it when you have time. No reply needed.',
}, },
], ],
facts: [ facts: [
{ text: 'この共有への返信は不要と明記している。', sourceId: 'tanabe-1' }, {
text: 'This message explicitly says no reply is needed.',
sourceId: 'tanabe-1',
},
], ],
drafts: [ drafts: [
{ label: '短く', text: '共有ありがとう! あとで読んでみるね。' },
{ {
label: '丁寧に', label: 'Brief',
text: '覚えていてくれてありがとう。ちょうど気になっていた話なので、時間を取って読んでみます。', text: 'Thanks for sharing! I will read it later.',
},
{
label: 'Polite',
text: 'Thanks for remembering. I have been thinking about this topic, so I will set aside some time to read it.',
},
{
label: 'Acknowledge',
text: 'Thanks, got it!',
}, },
{ label: 'まず受け取る', text: 'ありがとう、受け取りました!' },
], ],
scheduleOptions: [], scheduleOptions: [],
}, },
+21 -19
View File
@@ -20,11 +20,11 @@ import { conversations } from './mock-conversations'
import './support.css' import './support.css'
const views = [ const views = [
{ id: 'now', label: '今' }, { id: 'now', label: 'Now' },
{ id: 'later', label: 'あとで' }, { id: 'later', label: 'Later' },
{ id: 'waiting', label: '相手待ち' }, { id: 'waiting', label: 'Waiting' },
{ id: 'done', label: '履歴' }, { id: 'done', label: 'History' },
{ id: 'all', label: 'すべて' }, { id: 'all', label: 'All' },
] as const ] as const
export function SupportPage() { export function SupportPage() {
const [view, setView] = useState<string>('now') const [view, setView] = useState<string>('now')
@@ -50,12 +50,12 @@ export function SupportPage() {
if (note) setOutcomes((current) => ({ ...current, [id]: note })) if (note) setOutcomes((current) => ({ ...current, [id]: note }))
setNotice( setNotice(
status === 'sent' status === 'sent'
? '返信の操作を記録しました' ? 'Reply preview recorded'
: status === 'later' : status === 'later'
? 'あとでに移しました' ? 'Moved to Later'
: status === 'waiting' : status === 'waiting'
? '相手待ちに移しました' ? 'Moved to Waiting'
: '対応不要にしました', : 'Marked as no action needed',
) )
setReading(false) setReading(false)
} }
@@ -66,7 +66,7 @@ export function SupportPage() {
<WorkspaceNavigation /> <WorkspaceNavigation />
<Sidebar collapsible="none" className="support-list"> <Sidebar collapsible="none" className="support-list">
<SidebarHeader className="border-b p-5 gap-4"> <SidebarHeader className="border-b p-5 gap-4">
<h1 className="text-base font-semibold">連絡</h1> <h1 className="text-base font-semibold">Messages</h1>
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{views.map((tab) => ( {views.map((tab) => (
<Button <Button
@@ -88,8 +88,8 @@ export function SupportPage() {
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" /> <Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
<Input <Input
aria-label="連絡を検索" aria-label="Search messages"
placeholder="検索" placeholder="Search"
className="pl-9" className="pl-9"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -116,12 +116,14 @@ export function SupportPage() {
))} ))}
{!visible.length && ( {!visible.length && (
<p className="p-5 text-sm text-muted-foreground"> <p className="p-5 text-sm text-muted-foreground">
{query ? '見つかりませんでした' : 'いま扱う連絡はありません'} {query
? 'No results found'
: 'No messages to handle right now'}
</p> </p>
)} )}
{view === 'now' && matches.length > 3 && ( {view === 'now' && matches.length > 3 && (
<p className="p-5 text-xs text-muted-foreground"> <p className="p-5 text-xs text-muted-foreground">
ほか {matches.length - 3} 件 {matches.length - 3} more
</p> </p>
)} )}
</SidebarContent> </SidebarContent>
@@ -133,7 +135,7 @@ export function SupportPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="support-back" className="support-back"
aria-label="連絡一覧に戻る" aria-label="Back to messages"
onClick={() => setReading(false)} onClick={() => setReading(false)}
> >
<ArrowLeft /> <ArrowLeft />
@@ -148,7 +150,7 @@ export function SupportPage() {
</header> </header>
<section <section
className="support-detail" className="support-detail"
aria-label="連絡の詳細" aria-label="Message details"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Independent region needs keyboard scrolling. // biome-ignore lint/a11y/noNoninteractiveTabindex: Independent region needs keyboard scrolling.
tabIndex={0} tabIndex={0}
> >
@@ -169,7 +171,7 @@ export function SupportPage() {
<> <>
{outcomes[selected.id] && ( {outcomes[selected.id] && (
<div className="support-outcome"> <div className="support-outcome">
<h2 className="font-medium text-sm">活動の記録</h2> <h2 className="font-medium text-sm">Activity log</h2>
<p className="whitespace-pre-wrap mt-2 text-sm"> <p className="whitespace-pre-wrap mt-2 text-sm">
{outcomes[selected.id]} {outcomes[selected.id]}
</p> </p>
@@ -187,12 +189,12 @@ export function SupportPage() {
setView('now') setView('now')
}} }}
> >
今に戻す Move to Now
</Button> </Button>
)} )}
</> </>
) : ( ) : (
<div className="support-empty">連絡を選択</div> <div className="support-empty">Select a conversation</div>
)} )}
</section> </section>
</SidebarInset> </SidebarInset>
+7 -6
View File
@@ -74,7 +74,7 @@ export function VitalsPage() {
<button <button
className="vital-mobile-close" className="vital-mobile-close"
type="button" type="button"
aria-label="メニューを閉じる" aria-label="Close menu"
onClick={() => setMobileMenu(false)} onClick={() => setMobileMenu(false)}
> >
<X /> <X />
@@ -118,7 +118,7 @@ export function VitalsPage() {
<button <button
className="vital-mobile-toggle" className="vital-mobile-toggle"
type="button" type="button"
aria-label="Healthメニューを開く" aria-label="Open Health menu"
onClick={() => setMobileMenu(true)} onClick={() => setMobileMenu(true)}
> >
<Menu /> <Menu />
@@ -131,7 +131,7 @@ export function VitalsPage() {
</span> </span>
<button <button
type="button" type="button"
aria-label="通知を開く" aria-label="Open notifications"
onClick={() => setDetail('Notifications')} onClick={() => setDetail('Notifications')}
> >
<Bell /> <Bell />
@@ -308,7 +308,7 @@ export function VitalsPage() {
<div <div
className="vital-anatomy" className="vital-anatomy"
role="img" role="img"
aria-label="人体と各系統の位置を示す参照図" aria-label="Reference illustration of the body and its systems"
> >
<img src="/vitals/body-reference.jpg" alt="" /> <img src="/vitals/body-reference.jpg" alt="" />
</div> </div>
@@ -399,7 +399,8 @@ export function VitalsPage() {
onClose={() => setDetail(undefined)} onClose={() => setDetail(undefined)}
> >
<p className="text-sm leading-relaxed text-muted-foreground"> <p className="text-sm leading-relaxed text-muted-foreground">
画像をもとにした表示サンプルです。実際の測定・診断・機器の操作には接続していません。 This preview uses sample data from the reference image. It is not
connected to health measurements, diagnoses, or device controls.
</p> </p>
{systems.find((item) => item.name === detail) && ( {systems.find((item) => item.name === detail) && (
<p className="text-3xl"> <p className="text-3xl">
@@ -410,7 +411,7 @@ export function VitalsPage() {
</p> </p>
)} )}
<Button variant="outline" onClick={() => setDetail(undefined)}> <Button variant="outline" onClick={() => setDetail(undefined)}>
閉じる Close
</Button> </Button>
</Dialog> </Dialog>
)} )}
+11 -4
View File
@@ -18,6 +18,13 @@ export type Task = {
export type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed' export type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
export type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重' export type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
export const entryKindLabels: Record<EntryKind, string> = {
メモ: 'Note',
再開地点: 'Resume point',
服薬: 'Medication',
体重: 'Weight',
}
export type JournalEntry = { export type JournalEntry = {
id: string id: string
kind: EntryKind kind: EntryKind
@@ -45,18 +52,18 @@ export function WorkspaceStateProvider({ children }: { children: ReactNode }) {
const [tasks, setTasks] = useState<Task[]>([ const [tasks, setTasks] = useState<Task[]>([
{ {
id: 'review', id: 'review',
title: 'レビュー資料を確認する', title: 'Review the documents',
section: 'focus', section: 'focus',
status: 'todo', status: 'todo',
}, },
{ {
id: 'morning', id: 'morning',
title: '朝の支度', title: 'Morning routine',
section: 'routine', section: 'routine',
status: 'todo', status: 'todo',
steps: ['水を飲む', '身支度をする', '朝食をとる'], steps: ['Drink water', 'Get ready', 'Have breakfast'],
}, },
{ id: 'walk', title: '散歩する', section: 'optional', status: 'todo' }, { id: 'walk', title: 'Go for a walk', section: 'optional', status: 'todo' },
]) ])
const [statuses, setStatuses] = useState<Record<string, Status>>({}) const [statuses, setStatuses] = useState<Record<string, Status>>({})
const [outcomes, setOutcomes] = useState<Record<string, string>>({}) const [outcomes, setOutcomes] = useState<Record<string, string>>({})
+1 -1
View File
@@ -43,7 +43,7 @@ function RootContent() {
function RootDocument({ children }: { children: React.ReactNode }) { function RootDocument({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="ja" className="dark"> <html lang="en" className="dark">
<head> <head>
<HeadContent /> <HeadContent />
</head> </head>