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
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,
flexible tasks/routines, messages, notes, and reading. `/deck` opens Twitter Lite.
The shared sidebar also opens
+6 -6
View File
@@ -1,7 +1,7 @@
# Execution support prototype
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts
The shared sidebar opens `/support` (Messages) and `/journal` (Journal), alongside
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
journal entries are shared between routes for the current browser session.
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
`/` 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
steps. Communications can be reviewed directly in a home dialog; changes also
appear in `/support`. Journal entries and corrections appear on the home, and
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,
contact reply/review or deferral, notes/weight/medication records, and navigation.
Confirming a proposal dispatches the corresponding operation. Unknown or
ambiguous input does not change state. Message sending still goes through the
existing simulated review UI; this prototype performs no external send.
Examples: 「買い物を追加」「朝の支度をあとで」「青木さんに返信」
「メモ:次は資料の2ページ目から」「体重は65.2kg」「リーダーを開く」.
Examples: `Add grocery shopping`, `Defer Morning routine`, `Reply to Aoki`,
`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),
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
[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
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 expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
const manage = page.getByRole('button', { name: 'Manage connected accounts' })
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
}
await expect(manage).toBeEnabled()
await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
const dialog = page.getByRole('dialog', { name: 'Connected accounts' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'),
dialog.getByText('No Mastodon servers have been configured yet.'),
).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Mastodonで認可する' }),
dialog.getByRole('button', { name: 'Authorize with Mastodon' }),
).toHaveCount(0)
await page.keyboard.press('Escape')
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.waitForLoadState('networkidle')
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
const manage = page.getByRole('button', { name: 'Manage connected accounts' })
await expect(manage).toBeVisible()
await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
const dialog = page.getByRole('dialog', { name: 'Connected accounts' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'),
dialog.getByText('No Mastodon servers have been configured yet.'),
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
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 ({
@@ -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 expect(page).toHaveURL(/\/deck\?mastodon=failed$/)
await expect(page.getByRole('alert')).toContainText(
'Mastodonの認可を完了できませんでした。',
'Could not complete Mastodon authorization.',
)
await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
const manage = page.getByRole('button', { name: 'Manage connected accounts' })
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
}
await expect(
page.getByRole('button', { name: '接続アカウントを管理' }),
page.getByRole('button', { name: 'Manage connected accounts' }),
).toBeEnabled()
})
+76 -76
View File
@@ -10,26 +10,26 @@ async function addColumn(
kind = 'search',
) {
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
await editor.getByLabel('カラム名', { exact: true }).fill(title)
await page.getByRole('button', { name: 'Add column', exact: true }).click()
const editor = page.getByRole('form', { name: 'Add column', exact: true })
await editor.getByLabel('Column name', { exact: true }).fill(title)
await editor
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: profile })
await editor
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.getByRole('combobox', { name: 'Column type', exact: true })
.selectOption(kind)
await editor
.getByLabel(
kind === 'search'
? 'Twitterの検索条件'
? 'Twitter search query'
: kind === 'user'
? 'ユーザー'
: 'リスト',
? 'User'
: 'List',
{ exact: true },
)
.fill(query)
await editor.getByRole('button', { name: '追加して検索' }).click()
await editor.getByRole('button', { name: 'Add and search' }).click()
await closeMobileSidebar(page)
await expect(
page
@@ -42,13 +42,13 @@ async function addColumn(
test.beforeEach(async ({ page }) => {
await page.goto('/deck')
await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
page.getByRole('button', { name: 'Save as deck', exact: true }),
).toBeEnabled()
await page
.getByRole('button', { name: 'デッキとして保存', exact: true })
.click()
await page.getByRole('button', { name: 'Save as deck', exact: true }).click()
await showDeckManagement(page)
await expect(page.getByText('サーバーに保存・端末間で共有')).toBeVisible()
await expect(
page.getByText('Saved to server · Shared across devices'),
).toBeVisible()
await closeMobileSidebar(page)
})
@@ -65,19 +65,19 @@ test('binds identical searches to independent profiles through paging, editing a
await expect(
second.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
await first.getByRole('button', { name: '続きを読み込む' }).click()
await first.getByRole('button', { name: 'Load more' }).click()
await expect(first.locator('article')).toHaveCount(2)
await expect(second.locator('article')).toHaveCount(1)
await second.getByRole('button', { name: '続きを読み込む' }).click()
await second.getByRole('button', { name: 'Load more' }).click()
await expect(
second.getByText('alternate · Latest · all page 2'),
).toBeVisible()
await second.getByRole('button', { name: '別の接続の操作' }).click()
await page.getByRole('menuitem', { name: '編集', exact: true }).click()
await second.getByRole('button', { name: 'Actions for 別の接続' }).click()
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click()
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'e2e' })
await page.getByRole('button', { name: '変更を保存' }).click()
await page.getByRole('button', { name: 'Save changes' }).click()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).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 ({
page,
}) => {
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
await page.getByRole('button', { name: '名前を保存' }).click()
await page.getByRole('button', { name: 'Rename' }).click()
await page.getByLabel('Research topic / deck name').fill('最初の調査')
await page.getByRole('button', { name: 'Save name' }).click()
await addColumn(page, '最初の観点', 'first-deck')
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('別の調査')
await page.getByRole('button', { name: '作成', exact: true }).click()
await page.getByRole('button', { name: 'Create deck', exact: true }).click()
await page.getByLabel('New deck name').fill('別の調査')
await page.getByRole('button', { name: 'Create', exact: true }).click()
await expect(page.locator('.deck-column')).toHaveCount(0)
await addColumn(page, '別の観点', 'second-deck', 'alternate')
await showDeckManagement(page)
await page
.getByLabel('デッキプロファイル', { exact: true })
.getByLabel('Deck profile', { exact: true })
.selectOption({ label: '最初の調査' })
await closeMobileSidebar(page)
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 showDeckManagement(page)
await page
.getByLabel('デッキプロファイル', { exact: true })
.getByLabel('Deck profile', { exact: true })
.selectOption({ label: '別の調査' })
await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
await page.getByRole('button', { name: '削除する', exact: true }).click()
await page.getByRole('button', { name: 'Delete deck', exact: true }).click()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page)
await expect(
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
page.getByLabel('Deck profile', { exact: true }).locator('option'),
).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 })
await expect(user.getByText('alternate profile 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 list.getByRole('button', { name: 'リストの投稿の操作' }).click()
await page.getByRole('menuitem', { name: 'リストの投稿を左へ' }).click()
await list.getByRole('button', { name: 'Actions for リストの投稿' }).click()
await page.getByRole('menuitem', { name: 'Move リストの投稿 left' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿',
'アカウント',
])
await list.getByRole('button', { name: 'リストの投稿の操作' }).click()
await page.getByRole('menuitem', { name: 'リストの投稿を削除' }).click()
await page.getByRole('button', { name: '元に戻す' }).click()
await list.getByRole('button', { name: 'Actions for リストの投稿' }).click()
await page.getByRole('menuitem', { name: 'Delete リストの投稿' }).click()
await page.getByRole('button', { name: 'Undo' }).click()
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')
const failed = 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.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 failed.getByRole('button', { name: '再試行' }).click()
await failed.getByRole('button', { name: 'Retry' }).click()
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,
height: 900,
})
await expect(
page.getByRole('navigation', { name: 'カラム一覧' }),
).toHaveCount(0)
await expect(page.getByRole('navigation', { name: 'Columns' })).toHaveCount(0)
await expect(
page.getByRole('heading', { name: '公式アカウント', exact: true }),
).toBeVisible()
@@ -213,7 +211,7 @@ test('keeps the deck and editor accessible within narrow viewports', async ({
fullPage: true,
})
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([])
await page.screenshot({
path: testInfo.outputPath('deck.png'),
@@ -225,20 +223,22 @@ test('closes the column editor with Escape and restores focus without applying e
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 expect(
page.getByRole('dialog', { name: 'カラムを追加', exact: true }),
page.getByRole('dialog', { name: 'Add column', exact: true }),
).toBeVisible()
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
await page
.getByLabel('Column name', { exact: true })
.fill('キャンセルする観点')
await page.keyboard.press('Escape')
await expect(
page.getByRole('dialog', { name: 'カラムを追加', exact: true }),
page.getByRole('dialog', { name: 'Add column', exact: true }),
).toHaveCount(0)
await expect(opener).toBeFocused()
await expect(page.locator('.deck-column')).toHaveCount(0)
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 }) => {
@@ -246,7 +246,7 @@ test('does not overwrite unreadable storage on reload', async ({ page }) => {
localStorage.setItem('twitter-lite-research-deck', '{broken'),
)
await page.reload()
await expect(page.getByRole('alert')).toContainText('読み込めませんでした')
await expect(page.getByRole('alert')).toContainText('Unable to load')
expect(
await page.evaluate(() =>
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,
}) => {
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
await page.getByRole('button', { name: 'Add column', exact: true }).click()
await page.getByLabel('Column name', { exact: true }).fill('購読リスト')
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'e2e' })
await page
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.getByRole('combobox', { name: 'Column type', exact: true })
.selectOption('list')
await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }),
).toBeVisible()
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.getByRole('combobox', { name: 'Connection profile', exact: true })
.selectOption({ label: 'alternate' })
await expect(
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
.getByRole('button', { name: 'Alternate List', exact: true })
.click()
await expect(page.getByLabel('リスト', { exact: true })).toHaveValue(
await expect(page.getByLabel('List', { exact: true })).toHaveValue(
'1234567890',
)
await page.getByRole('button', { name: '追加して検索' }).click()
await page.getByRole('button', { name: 'Add and search' }).click()
await closeMobileSidebar(page)
await expect(
page
@@ -302,27 +302,27 @@ test('keeps an open draft through remote edits and rejects its stale save', asyn
try {
const device = await other.newPage()
await device.goto('/deck')
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('編集中の名前')
await device.getByRole('button', { name: '名前を変更' }).click()
await device.getByLabel('調査テーマ・デッキ名').fill('別の端末の変更')
await device.getByRole('button', { name: '名前を保存' }).click()
await page.getByRole('button', { name: 'Rename' }).click()
await page.getByLabel('Research topic / deck name').fill('編集中の名前')
await device.getByRole('button', { name: 'Rename' }).click()
await device.getByLabel('Research topic / deck name').fill('別の端末の変更')
await device.getByRole('button', { name: 'Save name' }).click()
await expect(device.getByRole('heading', { level: 1 })).toHaveText(
'別の端末の変更',
)
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(
'別の端末で変更されました',
'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.getByRole('button', { name: '最新のデッキを再読み込み' }).click()
await page.getByRole('button', { name: 'Reload latest deck' }).click()
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 showDeckManagement(page)
const selector = page.getByLabel('デッキプロファイル', { exact: true })
const selector = page.getByLabel('Deck profile', { exact: true })
await expect(selector.locator('option')).toHaveCount(1)
await closeMobileSidebar(page)
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await page.getByRole('button', { name: 'Import legacy decks' }).click()
await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2)
await selector.selectOption({ label: '以前の調査' })
@@ -388,9 +388,9 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
legacy,
)
await page.reload()
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await page.getByRole('button', { name: 'Import legacy decks' }).click()
await expect(
page.getByRole('button', { name: '旧デッキを取り込む' }),
page.getByRole('button', { name: 'Import legacy decks' }),
).toHaveCount(0)
await showDeckManagement(page)
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,
}) => {
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'デッキを作成', exact: true })
await dialog.getByLabel('新しいデッキ名').fill('画面を回転しても残る調査')
await page.getByRole('button', { name: 'Create deck', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Create deck', exact: true })
await dialog.getByLabel('New deck name').fill('画面を回転しても残る調査')
for (const width of [1024, 375, 1024]) {
await page.setViewportSize({ width, height: 900 })
await expect(dialog).toHaveCount(1)
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 dialog.getByRole('button', { name: '作成', exact: true }).click()
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
await expect(dialog).toHaveCount(0)
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.waitForLoadState('networkidle')
const memo = '明日のレビューは導線から確認する'
await page
.getByRole('textbox', { name: 'ホームへの入力' })
.fill(`メモ: ${memo}`)
await page.getByRole('button', { name: '候補を表示' }).click()
const execute = page.getByRole('button', { name: '実行する', exact: true })
await page.getByRole('textbox', { name: 'Home input' }).fill(`Note: ${memo}`)
await page.getByRole('button', { name: 'Preview action' }).click()
const execute = page.getByRole('button', { name: 'Apply', exact: true })
await expect(execute).toBeVisible()
await execute.click()
await page.getByRole('link', { name: '記録', exact: true }).first().click()
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText(
await page.getByRole('link', { name: 'Journal', exact: true }).first().click()
await expect(page.getByRole('article', { name: 'Note entry' })).toContainText(
memo,
)
await page.getByRole('link', { name: 'ホーム', exact: true }).first().click()
await expect(
page.getByRole('textbox', { name: 'ホームへの入力' }),
).toBeVisible()
await page.getByRole('link', { name: 'Home', exact: true }).first().click()
await expect(page.getByRole('textbox', { name: 'Home input' })).toBeVisible()
})
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]) {
await page.setViewportSize({ width, height: 850 })
await expect(
page.getByRole('textbox', { name: 'ホームへの入力' }),
page.getByRole('textbox', { name: 'Home input' }),
).toBeVisible()
expect(
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.waitForLoadState('networkidle')
const input = page.getByRole('textbox', { name: 'ホームへの入力' })
const input = page.getByRole('textbox', { name: 'Home input' })
const complete = page.getByRole('button', {
name: '買い物を完了',
name: 'Complete grocery shopping',
exact: true,
})
await input.fill('買い物を追加')
await page.getByRole('button', { name: '候補を表示' }).click()
await input.fill('Add grocery shopping')
await page.getByRole('button', { name: 'Preview action' }).click()
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 input.fill('買い物をあとで')
await page.getByRole('button', { name: '候補を表示' }).click()
await input.fill('Defer grocery shopping')
await page.getByRole('button', { name: 'Preview action' }).click()
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 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 expect(complete).toHaveCount(0)
await page.locator('.home-rest summary').click()
await expect(
page.getByRole('button', { name: '買い物を戻す', exact: true }),
page.getByRole('button', { name: 'Restore grocery shopping', exact: true }),
).toBeVisible()
})
@@ -78,30 +76,37 @@ test('resolves communication within home and carries the status to the shared qu
}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: '青木 遥との会話を開く' }).click()
const dialog = page.getByRole('dialog', { name: '青木 遥との連絡' })
await page
.getByRole('button', { name: 'Open conversation with Haruka Aoki' })
.click()
const dialog = page.getByRole('dialog', {
name: 'Conversation with Haruka Aoki',
})
await expect(
dialog.getByRole('textbox', { name: '返信', exact: true }),
dialog.getByRole('textbox', { name: 'Reply', exact: true }),
).toBeVisible()
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(
page.getByRole('button', { name: '青木 遥との会話を開く' }),
page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }),
).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')
await expect(
rows.filter({ hasText: '来週のレビュー、日程を決める' }),
rows.filter({ hasText: 'Schedule next week’s design review' }),
).toHaveCount(0)
await page.getByRole('button', { name: 'あとで', exact: true }).click()
await rows.filter({ hasText: '来週のレビュー、日程を決める' }).click()
await page.getByRole('button', { name: '今に戻す' }).click()
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
await page.getByRole('button', { name: 'Later', exact: true }).click()
await rows.filter({ hasText: 'Schedule next week’s design review' }).click()
await page.getByRole('button', { name: 'Move to Now' }).click()
const back = page.getByRole('button', { name: 'Back to messages' })
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(
page.getByRole('button', { name: '青木 遥との会話を開く' }),
page.getByRole('button', { name: 'Open conversation with Haruka Aoki' }),
).toBeVisible()
})
@@ -110,25 +115,25 @@ test('updates the prose brief as priorities change and opens the referenced conv
}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
const brief = page.getByRole('region', { name: '今日のブリーフ' })
await expect(brief).toContainText('レビュー資料を確認する')
await expect(brief).toContainText('4件')
const brief = page.getByRole('region', { name: 'Daily brief' })
await expect(brief).toContainText('Review the documents')
await expect(brief).toContainText('4 conversations')
await page
.getByRole('button', { name: 'レビュー資料を確認するを完了', exact: true })
.getByRole('button', { name: 'Complete Review the documents', exact: true })
.click()
await expect(brief).not.toContainText('レビュー資料を確認する')
await brief.getByRole('button', { name: '青木 遥さんへの返信' }).click()
await expect(brief).not.toContainText('Review the documents')
await brief.getByRole('button', { name: 'Reply to Haruka Aoki' }).click()
const dialog = page.getByRole('dialog', {
name: '青木 遥との連絡',
name: 'Conversation with Haruka Aoki',
exact: true,
})
await dialog.getByRole('button', { name: '今は置いておく' }).click()
await expect(brief).toContainText('3件')
await expect(brief).not.toContainText('青木 遥')
await dialog.getByRole('button', { name: 'Leave for later' }).click()
await expect(brief).toContainText('3 conversations')
await expect(brief).not.toContainText('Haruka Aoki')
await page
.getByRole('textbox', { name: 'ホームへの入力' })
.getByRole('textbox', { name: 'Home input' })
.fill('再開地点:資料の2ページ目から')
await page.getByRole('button', { name: '候補を表示' }).click()
await page.getByRole('button', { name: '実行する', exact: true }).click()
await page.getByRole('button', { name: 'Preview action' }).click()
await page.getByRole('button', { name: 'Apply', exact: true }).click()
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')
const rows = page.locator('.inbox-article-row')
await expect(rows).toHaveCount(9)
const articleTitle = 'エージェントに渡す文脈は、短くてもよい'
const articleTitle = 'Context for an agent can be brief'
await rows.filter({ hasText: articleTitle }).click()
const reader = page.getByRole('article', { name: articleTitle, exact: true })
await expect(reader).toBeVisible()
@@ -16,26 +16,26 @@ test('selects articles and keeps the reader focused on their content', async ({
).toBeVisible()
await expect(
reader.getByText(
'まず目的を一文で書き、次に変えてはいけない条件を添える。',
'Start with a one-sentence goal, then add the constraints that must hold.',
{ exact: false },
),
).toBeVisible()
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(
page.getByText('この記事が届いた理由', { exact: true }),
page.getByRole('button', { name: /For you|Read later|Unread only/ }),
).toHaveCount(0)
await expect(
page.getByRole('button', { name: /あなたへ|あとで読む|未読のみ/ }),
).toHaveCount(0)
const back = page.getByRole('button', { name: '記事一覧に戻る' })
const back = page.getByRole('button', { name: 'Back to articles' })
if (await back.isVisible()) await back.click()
await rows
.filter({ hasText: '3カラムの余白が、情報を読むリズムをつくる' })
.filter({ hasText: 'Three columns create a rhythm for reading' })
.click()
await expect(
page.getByRole('article', {
name: '3カラムの余白が、情報を読むリズムをつくる',
name: 'Three columns create a rhythm for reading',
exact: true,
}),
).toBeVisible()
@@ -49,21 +49,21 @@ test('recovers from empty search and shares navigation with Twitter Lite', async
await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row')
await page
.getByRole('textbox', { name: '記事を検索' })
.fill('存在しない検索語xyz')
.getByRole('textbox', { name: 'Search articles' })
.fill('nonexistent-query-xyz')
await expect(rows).toHaveCount(0)
await expect(
page.getByText('記事がありません', { exact: true }),
page.getByText('No articles found', { exact: true }),
).toBeVisible()
await page.getByRole('button', { name: '検索をクリア' }).click()
await page.getByRole('button', { name: 'Clear search' }).click()
await expect(rows).toHaveCount(9)
expect((await a11y().analyze()).violations).toEqual([])
await expect(
page.getByRole('button', { name: '興味を編集', exact: true }),
page.getByRole('button', { name: 'Edit interests', exact: true }),
).toHaveCount(0)
await page.getByRole('link', { name: 'Twitter Lite', exact: true }).click()
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(rows).toHaveCount(9)
})
@@ -77,14 +77,14 @@ test('keeps article navigation readable without horizontal overflow', async ({
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 })
await expect(
page.getByRole('textbox', { name: '記事を検索' }),
page.getByRole('textbox', { name: 'Search articles' }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.locator('.inbox-article-row').first().click()
const reader = page.getByRole('article', {
name: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
name: 'Thinking in the browser: a new workflow with AI agents',
exact: true,
})
await expect(reader).toBeVisible()
@@ -95,7 +95,7 @@ test('keeps article navigation readable without horizontal overflow', async ({
path: testInfo.outputPath(`inbox-reader-${width}.png`),
})
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()
}
})
+28 -28
View File
@@ -43,9 +43,9 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
}) => {
await page.goto('/deck')
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', {
name: '接続プロファイル',
name: 'Connection profile',
exact: true,
})
await accounts.selectOption({ label: 'e2e' })
@@ -98,7 +98,7 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
)
await page.reload()
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('list_decks: 完了')
await expect(conversation).toContainText('既存の検索条件を再利用しています。')
@@ -135,11 +135,11 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
])
await closeMobileSidebar(page)
await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
page.getByRole('button', { name: 'Save as deck', exact: true }),
).toBeEnabled()
await showResearch(page)
const accountPicker = page.getByRole('button', {
name: '接続アカウント · 1件',
name: 'Connected accounts · 1',
})
await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'true')
@@ -148,13 +148,11 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
).toBeChecked()
await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'false')
await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく')
await page.getByLabel('Message', { exact: true }).fill('この観点を詳しく')
await expect(
page.getByRole('button', { name: '送信', exact: true }),
page.getByRole('button', { name: 'Send', exact: true }),
).toBeEnabled()
await page
.getByRole('button', { name: 'サイドバーを閉じる', exact: true })
.click()
await page.getByRole('button', { name: 'Close sidebar', exact: true }).click()
run.deckVersion = 3
if (run.deck) run.deck.title = '閉じている間に更新された調査'
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 },
)
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 })
@@ -170,20 +168,20 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(1)
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.setViewportSize({ width: 1100, height: 900 })
await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(0)
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(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).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"]'),
).toHaveCount(1)
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(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).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.goto('/deck')
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', {
name: '接続プロファイル',
name: 'Connection profile',
exact: true,
})
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 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: '調査結果' })).toHaveCSS(
'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).toHaveClass(/citation-highlight/)
await expect(card).toBeFocused()
await expect(card).toContainText('調査時に取得した投稿')
await expect(card).toContainText('Post saved during research')
await expect(card).toContainText('AIが調査時に取得した引用本文')
expect(
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()
const originalTitle = await heading.textContent()
await showResearch(page)
const panel = page.getByRole('complementary', { name: '調査チャット' })
const panel = page.getByRole('complementary', { name: 'Research chat' })
await expect(panel).toBeVisible()
await expect(
panel.getByText('自宅のCodexへの接続が設定されていません。'),
panel.getByText(
'The connection to Codex on your home server is not configured.',
),
).toBeVisible()
await expect(
panel.getByRole('button', { name: '送信', exact: true }),
panel.getByRole('button', { name: 'Send', exact: true }),
).toBeDisabled()
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled()
await expect(panel.getByLabel('Message', { exact: true })).toBeDisabled()
const accessibility = await a11y().analyze()
expect(accessibility.violations).toEqual([])
await closeMobileSidebar(page)
await expect(heading).toHaveText(originalTitle ?? '')
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('手動の調査')
await page.getByRole('button', { name: '作成', exact: true }).click()
await page.getByRole('button', { name: 'Create deck', exact: true }).click()
await page.getByLabel('New deck name').fill('手動の調査')
await page.getByRole('button', { name: 'Create', exact: true }).click()
await closeMobileSidebar(page)
await expect(heading).toHaveText('手動の調査')
await showResearch(page)
+63 -52
View File
@@ -1,7 +1,7 @@
import { expect, test } from '../fixtures'
const reviewSubject = '来週のレビュー、日程を決める'
const coffeeSubject = '久しぶりに、お茶でも'
const reviewSubject = 'Schedule next week’s design review'
const coffeeSubject = 'Catch up over coffee'
test('reviews an edited reply and keeps the simulated result in history', async ({
page,
@@ -12,28 +12,31 @@ test('reviews an edited reply and keeps the simulated result in history', async
const rows = page.locator('.support-row')
await expect(rows).toHaveCount(3)
await rows.filter({ hasText: reviewSubject }).click()
await page.getByRole('button', { name: '丁寧に', exact: true }).click()
const reply = page.getByRole('textbox', { name: '返信', exact: true })
await expect(reply).toHaveValue(/資料をご共有/)
const edited = '資料を確認しました。初回の導線について当日お話しします。'
await page.getByRole('button', { name: 'Polite', exact: true }).click()
const reply = page.getByRole('textbox', { name: 'Reply', exact: true })
await expect(reply).toHaveValue(/Thank you for sharing/)
const edited =
'I reviewed the materials. Let’s discuss the first-time experience.'
await reply.fill(edited)
const slot = '10月5日(月)14:00–14:30'
await page.getByRole('combobox', { name: '日程候補' }).selectOption(slot)
await page.getByRole('button', { name: '返信に入れる' }).click()
await expect(reply).toHaveValue(`${edited}\n\n${slot}はいかがでしょうか。`)
await page.getByRole('button', { name: '送信内容を確認' }).click()
const dialog = page.getByRole('dialog', { name: '送信内容の確認' })
const slot = 'Mon, Oct 5, 14:00–14:30'
await page
.getByRole('combobox', { name: 'Suggested times' })
.selectOption(slot)
await page.getByRole('button', { name: 'Add to reply' }).click()
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(slot)
await expect(dialog).toContainText('外部には送信されません')
await expect(dialog).toContainText('No message will be sent')
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 page.getByRole('button', { name: '送信内容を確認' }).click()
await dialog.getByRole('button', { name: '送信を試す' }).click()
await page.getByRole('button', { name: 'Review reply' }).click()
await dialog.getByRole('button', { name: 'Try sending' }).click()
await expect(dialog).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 expect(page.locator('.support-outcome')).toContainText(edited)
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')
await rows.filter({ hasText: coffeeSubject }).click()
await page
.getByRole('textbox', { name: '返信', exact: true })
.getByRole('textbox', { name: 'Reply', exact: true })
.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 page.getByRole('button', { name: 'あとで', exact: true }).click()
await page.getByRole('button', { name: 'Later', exact: true }).click()
await rows.filter({ hasText: coffeeSubject }).click()
await expect(
page.getByRole('textbox', { name: '返信', exact: true }),
page.getByRole('textbox', { name: 'Reply', exact: true }),
).toHaveValue('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: '今に戻す' }).click()
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
await page.getByRole('button', { name: 'Move to Now' }).click()
const back = page.getByRole('button', { name: 'Back to messages' })
if (await back.isVisible()) await back.click()
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible()
await expect(
page.getByRole('button', { name: '今', exact: true }),
page.getByRole('button', { name: 'Now', exact: 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.waitForLoadState('networkidle')
await expect(
page.getByRole('button', { name: '記録する', exact: true }),
page.getByRole('button', { name: 'Save entry', exact: true }),
).toBeDisabled()
await page
.getByRole('textbox', { name: '記録内容' })
.getByRole('textbox', { name: 'Entry text' })
.fill('次はレビュー資料の導線を確認する')
await page.getByRole('button', { name: '記録する', exact: true }).click()
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText(
'発生日時: 不明',
await page.getByRole('button', { name: 'Save entry', exact: true }).click()
await expect(page.getByRole('article', { name: 'Note entry' })).toContainText(
'Occurred: Unknown',
)
await page.getByRole('button', { name: '体重', 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.getByRole('button', { name: 'Weight', exact: true }).click()
await page
.getByRole('article', { name: '体重の記録' })
.getByRole('button', { name: '訂正する' })
.getByRole('spinbutton', { name: 'Weight', exact: true })
.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()
const correction = page.getByRole('dialog', { name: '記録を訂正' })
const correction = page.getByRole('dialog', { name: 'Correct entry' })
await correction
.getByRole('spinbutton', { name: '体重', exact: true })
.getByRole('spinbutton', { name: 'Weight', exact: true })
.fill('65.8')
await correction.getByRole('button', { name: '訂正を追加' }).click()
const weights = page.getByRole('article', { name: '体重の記録' })
await correction.getByRole('button', { name: 'Add correction' }).click()
const weights = page.getByRole('article', { name: 'Weight entry' })
await expect(weights).toHaveCount(2)
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('訂正前')
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText('訂正')
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('Original')
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText(
'Correction',
)
await page
.getByRole('button', { name: '記録をまとめる', exact: true })
.getByRole('button', { name: 'Summarize entries', exact: true })
.click()
const review = page.getByRole('dialog', { name: '記録をまとめる' })
const summary = review.getByRole('textbox', { name: '記録のまとめ' })
const review = page.getByRole('dialog', { name: 'Summarize entries' })
const summary = review.getByRole('textbox', { name: 'Entry summary' })
await expect(summary).toHaveValue(/65.8 kg/)
await expect(summary).not.toHaveValue(/65.2 kg/)
await expect(summary).toHaveValue(/記録元: 自宅の体重計/)
await expect(summary).toHaveValue(/発生日時が不明の記録: 2件/)
await expect(summary).toHaveValue(/Source: 自宅の体重計/)
await expect(summary).toHaveValue(
/Entries with an unknown event time: 2 entries/,
)
await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck()
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([])
})
@@ -126,18 +137,18 @@ test('fits communication and journal interactions in narrow screens', async ({
).toBe(width)
await page.locator('.support-row').first().click()
await expect(
page.getByRole('textbox', { name: '返信', exact: true }),
page.getByRole('textbox', { name: 'Reply', exact: true }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
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()
await page.getByRole('link', { name: '記録', exact: true }).click()
await page.getByRole('button', { name: '体重', exact: true }).click()
await page.getByRole('link', { name: 'Journal', exact: true }).click()
await page.getByRole('button', { name: 'Weight', exact: true }).click()
await expect(
page.getByRole('spinbutton', { name: '体重', exact: true }),
page.getByRole('spinbutton', { name: 'Weight', exact: true }),
).toBeVisible()
expect(
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' })
await expect(dialog).toContainText('68')
await dialog
.getByRole('button', { name: '閉じる', exact: true })
.getByRole('button', { name: 'Close', exact: true })
.last()
.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.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()
await page
.getByRole('complementary', { name: 'Health navigation' })
@@ -95,7 +95,7 @@ test('keeps both dashboards within the viewport', async ({
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
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()
const navigation = page.getByRole('complementary', {
name: 'Health navigation',
+5 -3
View File
@@ -117,9 +117,9 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
).toBeVisible()
await page
.getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: '日本語の操作' })
.getByRole('button', { name: 'Actions for 日本語' })
.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', {
deckId: deck.id,
title: '更新した調査',
@@ -234,7 +234,9 @@ test('keeps temporary views independent and loses only unsaved views on reload',
).toBe(false)
expect(savedCount()).toEqual({ count: 0 })
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 ({
+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]) {
await page.setViewportSize({ width, height: 850 })
await showResearch(page)
const chat = page.getByRole('complementary', { name: '調査チャット' })
const chat = page.getByRole('complementary', { name: 'Research chat' })
await expect(chat).toBeVisible()
const composer = chat.getByRole('textbox', {
name: 'メッセージ',
name: 'Message',
exact: true,
})
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([])
await closeMobileSidebar(page)
}
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
await expect(
page.getByRole('complementary', { name: '調査チャット' }),
page.getByRole('complementary', { name: 'Research chat' }),
).toBeHidden()
await page.getByRole('button', { name: '調査チャット', exact: true }).click()
await page.getByRole('button', { name: 'Research chat', exact: true }).click()
await expect(
page.getByRole('complementary', { name: '調査チャット' }),
page.getByRole('complementary', { name: 'Research chat' }),
).toBeVisible()
})
+7 -7
View File
@@ -2,28 +2,28 @@ import { expect, type Page } from '@playwright/test'
export async function showDeckManagement(page: Page) {
await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const navigation = page.getByRole('button', {
name: 'デッキ管理',
name: 'Deck manager',
exact: true,
})
if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
}
await navigation.click()
}
export async function showResearch(page: Page) {
await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
page.getByRole('button', { name: 'Rename', includeHidden: true }),
).toBeEnabled()
const navigation = page.getByRole('button', {
name: '調査チャット',
name: 'Research chat',
exact: true,
})
if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
await page.getByRole('button', { name: 'Toggle sidebar' }).click()
}
await navigation.click()
}
@@ -32,7 +32,7 @@ export async function closeMobileSidebar(page: Page) {
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]')
if (await sheet.isVisible()) {
await page
.getByRole('button', { name: 'サイドバーを閉じる', exact: true })
.getByRole('button', { name: 'Close sidebar', exact: true })
.click()
await expect(sheet).toBeHidden()
}
+9 -9
View File
@@ -46,8 +46,8 @@ function Workspace({
const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
const { setOpen, setOpenMobile, isMobile } = useSidebar()
const navigation = [
{ id: 'chat', title: '調査チャット', icon: MessageSquare },
{ id: 'decks', title: 'デッキ管理', icon: Columns3 },
{ id: 'chat', title: 'Research chat', icon: MessageSquare },
{ id: 'decks', title: 'Deck manager', icon: Columns3 },
] as const
return (
@@ -61,8 +61,8 @@ function Workspace({
footer={
<SidebarMenuItem>
<SidebarMenuButton
aria-label="サイドバーを閉じる"
tooltip={{ children: 'サイドバーを閉じる', hidden: false }}
aria-label="Close sidebar"
tooltip={{ children: 'Close sidebar', hidden: false }}
onClick={() =>
isMobile ? setOpenMobile(false) : setOpen(false)
}
@@ -104,29 +104,29 @@ function Workspace({
{researchChat(() => setOpenMobile(false))}
</div>
<section
aria-label="デッキ管理"
aria-label="Deck manager"
className={
panel === 'decks'
? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4'
: '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>
</section>
</Sidebar>
</Sidebar>
<SidebarInset className="min-w-0 overflow-hidden">
<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="ml-auto text-xs text-muted-foreground">
調査ワークスペース
Research workspace
</span>
</header>
<section
className="reader flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="デッキ"
aria-label="Decks"
>
{children}
</section>
+1 -1
View File
@@ -41,7 +41,7 @@ export function Dialog({
variant="ghost"
size="icon-sm"
className="absolute right-2 top-2"
aria-label="閉じる"
aria-label="Close"
/>
}
>
+4 -2
View File
@@ -189,8 +189,10 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>ワークスペース</SheetTitle>
<SheetDescription>調査チャットとデッキ管理</SheetDescription>
<SheetTitle>Workspace</SheetTitle>
<SheetDescription>
Research chat and deck management
</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-row">{children}</div>
</SheetContent>
+8 -8
View File
@@ -38,27 +38,27 @@ export function WorkspaceNavigation({
const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [
{ title: 'ホーム', to: '/', icon: House, active: pathname === '/' },
{ title: 'Home', to: '/', icon: House, active: pathname === '/' },
{
title: 'バイタル',
title: 'Vitals',
to: '/vitals',
icon: Activity,
active: pathname === '/vitals',
},
{
title: '連絡',
title: 'Messages',
to: '/support',
icon: MessagesSquare,
active: pathname === '/support',
},
{
title: '記録',
title: 'Journal',
to: '/journal',
icon: NotebookPen,
active: pathname === '/journal',
},
{
title: 'リーダー',
title: 'Reader',
to: '/inbox',
icon: Inbox,
active: pathname === '/inbox',
@@ -80,7 +80,7 @@ export function WorkspaceNavigation({
<div
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
role="img"
aria-label="ワークスペース"
aria-label="Workspace"
>
<Layers3 className="size-5" aria-hidden="true" />
</div>
@@ -123,8 +123,8 @@ export function WorkspaceNavigation({
<SidebarMenu className="mt-2">
<SidebarMenuItem>
<SidebarMenuButton
aria-label="接続アカウントを管理"
tooltip={{ children: '接続アカウントを管理', hidden: false }}
aria-label="Manage connected accounts"
tooltip={{ children: 'Manage connected accounts', hidden: false }}
className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)}
>
@@ -20,14 +20,18 @@ export function ConnectionManagerDialog({
})
return (
<Dialog title="接続アカウント" onClose={onClose} className="sm:max-w-2xl">
<Dialog
title="Connected accounts"
onClose={onClose}
className="sm:max-w-2xl"
>
{connections.isPending ? (
<p role="status">接続アカウントを読み込んでいます…</p>
<p role="status">Loading connected accounts…</p>
) : connections.isError ? (
<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()}>
再試行
Retry
</Button>
</div>
) : (
@@ -60,11 +60,11 @@ it('shows per-account status and warns about temporary decks without blocking ac
vi.fn(),
true,
)
expect(screen.getByText('接続済み')).toBeVisible()
expect(screen.getByText('再接続が必要')).toBeVisible()
expect(screen.getByText(/一時デッキは消えます/)).toBeVisible()
expect(screen.getByText('Connected')).toBeVisible()
expect(screen.getByText('Reconnect required')).toBeVisible()
expect(screen.getByText(/Temporary decks will be lost/)).toBeVisible()
expect(
await screen.findByRole('button', { name: 'Mastodonで認可する' }),
await screen.findByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled()
})
@@ -73,29 +73,31 @@ it('uses the selected instance and reports a safe error when authorization fails
new Error('private server credential detail'),
)
show()
fireEvent.change(await screen.findByLabelText('Mastodonサーバー'), {
fireEvent.change(await screen.findByLabelText('Mastodon server'), {
target: { value: 'https://second.invalid' },
})
fireEvent.click(screen.getByRole('button', { name: 'Mastodonで認可する' }))
fireEvent.click(
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
)
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
data: { origin: 'https://second.invalid' },
}),
)
expect(await screen.findByRole('alert')).toHaveTextContent(
'認可を開始できませんでした',
'Could not start Mastodon authorization',
)
expect(screen.queryByText(/private server/)).toBeNull()
expect(
screen.getByRole('button', { name: 'Mastodonで認可する' }),
screen.getByRole('button', { name: 'Authorize with Mastodon' }),
).toBeEnabled()
})
it('binds reconnect to the selected account rather than whichever instance is selected for addition', async () => {
actions.authorize.mockRejectedValue(new Error('offline'))
show()
await screen.findByLabelText('Mastodonサーバー')
fireEvent.click(screen.getByRole('button', { name: '@firstを再接続' }))
await screen.findByLabelText('Mastodon server')
fireEvent.click(screen.getByRole('button', { name: 'Reconnect @first' }))
await waitFor(() =>
expect(actions.authorize).toHaveBeenCalledWith({
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)
actions.disconnect.mockResolvedValue({ disconnected: true })
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce())
expect(actions.disconnect).toHaveBeenCalledWith({
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'))
const onChanged = vi.fn()
show([account], onChanged)
fireEvent.click(screen.getByRole('button', { name: '@firstの接続を解除' }))
fireEvent.click(screen.getByRole('button', { name: 'Disconnect @first' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'接続を解除できませんでした',
'Could not disconnect the account',
)
expect(onChanged).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: '@firstの接続を解除' }),
screen.getByRole('button', { name: 'Disconnect @first' }),
).toBeEnabled()
})
it('does not offer Mastodon reconnect or revoke for Twitter relay accounts', async () => {
show([{ ...account, platform: 'twitter', id: 'twitter-first' }])
await screen.findByLabelText('Mastodonサーバー')
expect(screen.queryByRole('button', { name: '@firstを再接続' })).toBeNull()
expect(
screen.queryByRole('button', { name: '@firstの接続を解除' }),
).toBeNull()
await screen.findByLabelText('Mastodon server')
expect(screen.queryByRole('button', { name: 'Reconnect @first' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Disconnect @first' })).toBeNull()
})
+26 -22
View File
@@ -14,9 +14,9 @@ import type { Connection } from './model'
import './connection-manager.css'
const statusNames = {
connected: '接続済み',
disconnected: '未接続',
expired: '再接続が必要',
connected: 'Connected',
disconnected: 'Disconnected',
expired: 'Reconnect required',
}
export function ConnectionManager({
@@ -52,7 +52,7 @@ export function ConnectionManager({
window.location.assign(result.authorizationUrl)
} catch {
setError(
'Mastodonの認可を開始できませんでした。接続先を確認して再試行してください。',
'Could not start Mastodon authorization. Check the server and try again.',
)
} finally {
setBusy(false)
@@ -68,24 +68,27 @@ export function ConnectionManager({
await onChanged()
} catch {
setError(
'接続は解除しましたが、一覧を更新できませんでした。画面を再読み込みしてください。',
'Account disconnected, but the list could not be refreshed. Reload the page.',
)
}
} catch {
setError('接続を解除できませんでした。時間をおいて再試行してください。')
setError('Could not disconnect the account. Try again later.')
} finally {
setBusy(false)
}
}
return (
<section className="connection-manager" aria-label="接続アカウント管理">
<section
className="connection-manager"
aria-label="Connected account management"
>
<p className="connection-manager-description">
追加したアカウントは、どの端末のカラムからも選べます。
Connected accounts are available in columns on all your devices.
</p>
{error && <p role="alert">{error}</p>}
{connections.length === 0 ? (
<p>接続アカウントはまだありません。</p>
<p>No connected accounts yet.</p>
) : (
<ul className="connection-manager-list">
{connections.map((connection) => (
@@ -110,22 +113,22 @@ export function ConnectionManager({
variant="outline"
type="button"
disabled={busy}
aria-label={`${connection.displayName}を再接続`}
aria-label={`Reconnect ${connection.displayName}`}
onClick={() =>
void connect(connection.origin, connection.id)
}
>
再接続
Reconnect
</Button>
{connection.status !== 'disconnected' && (
<Button
variant="destructive"
type="button"
disabled={busy}
aria-label={`${connection.displayName}の接続を解除`}
aria-label={`Disconnect ${connection.displayName}`}
onClick={() => void remove(connection.id)}
>
接続解除
Disconnect
</Button>
)}
</div>
@@ -141,25 +144,25 @@ export function ConnectionManager({
if (origin) void connect(origin)
}}
>
<h3>Mastodonアカウントを追加</h3>
<h3>Add a Mastodon account</h3>
{instances.isPending ? (
<p role="status">接続先を確認しています…</p>
<p role="status">Checking servers…</p>
) : instances.isError ? (
<p role="alert">
接続先を取得できませんでした。
Could not load servers.
<Button
variant="outline"
type="button"
onClick={() => void instances.refetch()}
>
再試行
Retry
</Button>
</p>
) : 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
className="w-full"
id={selectId}
@@ -174,16 +177,17 @@ export function ConnectionManager({
))}
</NativeSelect>
<Button type="submit" disabled={busy || !origin}>
Mastodonで認可する
Authorize with Mastodon
</Button>
</>
)}
{hasTemporaryDecks && (
<p className="connection-manager-note">
認可画面へ移動すると、一時デッキは消えます。残したいデッキは先に保存してください。
Temporary decks will be lost when you leave to authorize. Save any
decks you want to keep first.
</p>
)}
{busy && <p role="status">処理しています…</p>}
{busy && <p role="status">Working…</p>}
</form>
</section>
)
@@ -9,9 +9,7 @@ import type { Connection } from './model'
function relayOrigin(): string {
const configured = process.env.TWITTER_RELAY_BASE_URL
if (!configured)
throw new ProfileUnavailableError(
'TWITTER_RELAY_BASE_URL を設定してください。',
)
throw new ProfileUnavailableError('Set TWITTER_RELAY_BASE_URL.')
return new URL(configured).origin
}
@@ -23,7 +21,7 @@ async function syncTwitterConnections(
const profiles = new Set(
await fetchProfiles(origin).catch(() => {
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
) {
throw new ProfileUnavailableError(
'この接続は利用できません。カラムの接続アカウントを確認してください。',
'This connection is unavailable. Check the connected account for this column.',
)
}
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(
requireTwitterConnection(id, database, async () => []),
).rejects.toThrow('この接続は利用できません')
).rejects.toThrow('This connection is unavailable')
expect(
(await listConnections(database, async () => ['first'])).connections,
).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 () => [
'first',
]),
).rejects.toThrow('この接続は利用できません')
).rejects.toThrow('This connection is unavailable')
})
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)
expect(failed.connections).toEqual(original.connections)
expect(failed.relayError).toContain(
'Relay への接続を確認して再試行してください。',
'Check the relay connection and try again.',
)
expect(database.select().from(connections).get()?.status).toBe('connected')
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 () => [
'first',
]),
).rejects.toThrow('この接続は利用できません')
).rejects.toThrow('This connection is unavailable')
expect(
database
.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 () => {
await expect(
requireTwitterConnection('first', database, async () => ['first']),
).rejects.toThrow('この接続は利用できません')
).rejects.toThrow('This connection is unavailable')
})
+30 -30
View File
@@ -75,7 +75,7 @@ function show() {
/>
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('カラム名'), {
fireEvent.change(screen.getByLabelText('Column name'), {
target: { value: '調査' },
})
return onSave
@@ -83,16 +83,16 @@ function show() {
it('creates a Mastodon search without Twitter ordering or following fields', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
expect(screen.queryByLabelText('表示順')).toBeNull()
expect(screen.queryByLabelText('フォロー中のみ')).toBeNull()
expect(screen.getByText(/結果が0件でも/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodonの検索条件'), {
expect(screen.queryByLabelText('Sort by')).toBeNull()
expect(screen.queryByLabelText('Following only')).toBeNull()
expect(screen.getByText(/Zero results/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodon search query'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
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 () => {
const save = show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'hashtag' },
})
fireEvent.change(screen.getByLabelText('ハッシュタグ'), {
fireEvent.change(screen.getByLabelText('Hashtag'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
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' },
})
expect(screen.getByLabelText('カラムの種類')).toHaveValue('search')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'ハッシュタグ' })).toBeNull()
expect(screen.getByLabelText('Column type')).toHaveValue('search')
expect(screen.getByLabelText('Twitter search query')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'Hashtag' })).toBeNull()
})
it('loads lists for each account and clears the selected list when the binding changes', async () => {
show()
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('カラムの種類'), {
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'list' },
})
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
expect(screen.getByLabelText('リスト')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('接続プロファイル'), {
expect(screen.getByLabelText('List')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-second' },
})
expect(screen.getByLabelText('リスト')).toHaveValue('')
expect(screen.getByLabelText('List')).toHaveValue('')
fireEvent.click(
await screen.findByRole('button', { name: 'mastodon-second' }),
)
expect(screen.getByLabelText('リスト')).toHaveValue('20')
expect(screen.getByLabelText('List')).toHaveValue('20')
expect(lists).toHaveBeenCalledWith({
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 () => {
const save = show()
save.mockResolvedValue(false)
fireEvent.change(screen.getByLabelText('Twitterの検索条件'), {
fireEvent.change(screen.getByLabelText('Twitter search query'), {
target: { value: 'from:owner WebMCP' },
})
fireEvent.change(screen.getByLabelText('表示順'), {
fireEvent.change(screen.getByLabelText('Sort by'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' }))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'Following only' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
expect(screen.getByLabelText('Twitterの検索条件')).toHaveValue(
expect(screen.getByLabelText('Column name')).toHaveValue('調査')
expect(screen.getByLabelText('Twitter search query')).toHaveValue(
'from:owner WebMCP',
)
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
expect(screen.getByLabelText('Sort by')).toHaveValue('Top')
expect(screen.getByRole('checkbox', { name: 'Following only' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
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 (
<form
className="deck-editor"
aria-label={column ? 'カラムを編集' : 'カラムを追加'}
aria-label={column ? 'Edit column' : 'Add column'}
onSubmit={async (event) => {
event.preventDefault()
setError(undefined)
@@ -55,7 +55,7 @@ export function ColumnEditor({
if (!parsed.success || !validConnection) {
setError(
parsed.success
? '接続プロファイルを選択してください。'
? 'Select a connection profile.'
: parsed.error.issues[0]?.message,
)
return
@@ -65,18 +65,18 @@ export function ColumnEditor({
>
<div className="deck-editor-fields">
<Label htmlFor={`${formId}-title`}>
カラム名
Column name
<Input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
maxLength={80}
placeholder="例:発表元の投稿"
placeholder="e.g. Announcements"
required
/>
</Label>
<Label htmlFor={`${formId}-profile`}>
接続プロファイル
Connection profile
<NativeSelect
className="w-full"
id={`${formId}-profile`}
@@ -92,11 +92,11 @@ export function ColumnEditor({
}}
>
<NativeSelectOption value="" disabled>
選択してください
Select an option
</NativeSelectOption>
{connectionId && !validConnection && (
<NativeSelectOption value={connectionId} disabled>
{connectionId}(利用不可)
{connectionId} (unavailable)
</NativeSelectOption>
)}
{available.map((connection) => (
@@ -114,7 +114,7 @@ export function ColumnEditor({
onChange={setSource}
/>
<p className="deck-help">
このカラムは選択した接続プロファイルで取得します。
This column uses the selected connection profile.
</p>
{error && (
<p role="alert" className="deck-error">
@@ -123,10 +123,10 @@ export function ColumnEditor({
)}
<div className="deck-actions">
<Button type="submit" disabled={!validConnection}>
{column ? '変更を保存' : '追加して検索'}
{column ? 'Save changes' : 'Add and search'}
</Button>
<Button variant="outline" type="button" onClick={onCancel}>
キャンセル
Cancel
</Button>
</div>
</form>
+4 -4
View File
@@ -19,7 +19,7 @@ vi.mock('./research-post-card', () => ({
}) => (
<article>
{post.text}
{citationSnapshot ? '調査時に取得した投稿' : ''}
{citationSnapshot ? 'Post saved during research' : ''}
</article>
),
}))
@@ -71,11 +71,11 @@ it('temporarily renders missing evidence without inserting it into feed pages or
/>,
)
expect(screen.getByRole('article')).toHaveTextContent(
'Snapshot調査時に取得した投稿',
'SnapshotPost saved during research',
)
expect(page.posts).toHaveLength(0)
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 })
})
@@ -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.getByRole('article')).toHaveTextContent('Live')
expect(
screen.queryByText(/Snapshot|調査時に取得した投稿/),
screen.queryByText(/Snapshot|Post saved during research/),
).not.toBeInTheDocument()
})
+20 -22
View File
@@ -53,7 +53,7 @@ export function ResearchColumn({
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: '投稿を取得できませんでした。', retryable: true }
: { message: 'Unable to load posts.', retryable: true }
const headingId = `column-${column.id}`
const sourceDescription = describeSource(column.source)
return (
@@ -65,8 +65,8 @@ export function ResearchColumn({
<Button
variant="ghost"
size="icon-sm"
aria-label="更新"
title="更新"
aria-label="Refresh"
title="Refresh"
type="button"
onClick={() => void query.refetch()}
disabled={query.isFetching}
@@ -79,45 +79,45 @@ export function ResearchColumn({
<Button
variant="ghost"
size="icon-sm"
aria-label={`${column.title}の操作`}
title="カラムの操作"
aria-label={`Actions for ${column.title}`}
title="Column actions"
/>
}
>
<Icon name="more" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
<DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(-1)}
disabled={first}
aria-label={`${column.title}を左へ`}
aria-label={`Move ${column.title} left`}
>
← 左へ移動
← Move left
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(1)}
disabled={last}
aria-label={`${column.title}を右へ`}
aria-label={`Move ${column.title} right`}
>
右へ移動 →
Move right →
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={onRemove}
aria-label={`${column.title}を削除`}
aria-label={`Delete ${column.title}`}
>
カラムを削除
Delete column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="deck-column-label">
<span
title={`接続プロファイル: ${connectionLabel ?? '接続が見つかりません'}`}
title={`Connection profile: ${connectionLabel ?? 'Connection not found'}`}
>
{connectionLabel ?? '接続が見つかりません'}
{connectionLabel ?? 'Connection not found'}
</span>
<Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} />
@@ -126,16 +126,14 @@ export function ResearchColumn({
</div>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
{query.isFetching
? '投稿を取得しています…'
: `${posts.length}件を読み込み済み`}
{query.isFetching ? 'Loading posts…' : `${posts.length} posts loaded`}
</p>
</header>
<section
className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
tabIndex={0}
aria-label={`${column.title}の投稿`}
aria-label={`Posts in ${column.title}`}
>
{visiblePosts.map((post) => (
<ResearchPostCard
@@ -163,13 +161,13 @@ export function ResearchColumn({
: query.refetch())
}
>
再試行
Retry
</Button>
) : null}
</div>
) : null}
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
<p className="deck-message">条件に一致する投稿はありません。</p>
<p className="deck-message">No posts match your filters.</p>
) : null}
{query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message">
@@ -179,7 +177,7 @@ export function ResearchColumn({
disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
>
続きを読み込む
Load more
</Button>
</div>
) : null}
@@ -187,7 +185,7 @@ export function ResearchColumn({
!query.isError &&
posts.length > 0 &&
!query.hasNextPage ? (
<p className="deck-message">これ以上の投稿はありません。</p>
<p className="deck-message">No more posts.</p>
) : null}
</section>
</section>
+34 -32
View File
@@ -126,7 +126,7 @@ export function DeckPage() {
void refresh(true)
}}
>
最新のデッキを再読み込み
Reload latest deck
</Button>
</p>
) : null
@@ -187,8 +187,8 @@ export function DeckPage() {
/>
<Button
className="w-full"
aria-label="カラムを追加"
title="カラムを追加"
aria-label="Add column"
title="Add column"
type="button"
disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS
@@ -196,12 +196,12 @@ export function DeckPage() {
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
<span>Add column</span>
</Button>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
? 'Saved to server · Shared across devices'
: 'Temporary view · This tab only'}
</p>
</>
}
@@ -210,7 +210,7 @@ export function DeckPage() {
<div>
<h1>{deck.title}</h1>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
{deck.columns.length} / {MAX_COLUMNS} columns
</span>
</div>
<div className="deck-actions">
@@ -220,11 +220,11 @@ export function DeckPage() {
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
createTemporary(`${deck.title} copy`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
Create temporary copy
</Button>
) : (
<Button
@@ -233,14 +233,14 @@ export function DeckPage() {
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
Save as deck
</Button>
)}
<Button
variant="outline"
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
aria-label="Rename"
title="Rename"
type="button"
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
@@ -251,47 +251,48 @@ export function DeckPage() {
</div>
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
Saving…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
Legacy decks are available in this browser.
<Button
variant="outline"
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
Import legacy decks
</Button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
Loading decks…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
Unable to load connection profiles.
<Button
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
再試行
Retry
</Button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
Could not complete Mastodon authorization. Try again from
Connected accounts.
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
Mastodon account connected. You can now use it in a column.
</p>
) : null}
{profiles.data?.relayError ? (
@@ -300,13 +301,13 @@ export function DeckPage() {
</p>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
<p role="status">Loading connection profiles…</p>
) : null}
{!renaming && editing === null && !switcherEditing
? storageErrorNotice
: null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
@@ -319,7 +320,7 @@ export function DeckPage() {
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
Research topic / deck name
<Input
id="deck-title"
name="title"
@@ -329,7 +330,7 @@ export function DeckPage() {
/>
</label>
<Button variant="outline" type="submit">
名前を保存
Save name
</Button>
</form>
{storageErrorNotice}
@@ -337,7 +338,7 @@ export function DeckPage() {
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
title={editing === 'new' ? 'Add column' : 'Edit column'}
onClose={() => setEditing(null)}
>
<ColumnEditor
@@ -356,7 +357,7 @@ export function DeckPage() {
) : null}
{removed ? (
<div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span>
<span>Deleted “{removed.column.title}”.</span>
<Button
variant="outline"
type="button"
@@ -369,7 +370,7 @@ export function DeckPage() {
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
Undo
</Button>
</div>
) : null}
@@ -380,16 +381,17 @@ export function DeckPage() {
<Icon name="columns" />
</EmptyMedia>
<EmptyTitle>
<h2>調べたいことから、デッキを作りましょう</h2>
<h2>Start a deck with a question</h2>
</EmptyTitle>
<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>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setEditing('new')}>
手動でカラムを追加
Add column manually
</Button>
</EmptyContent>
</Empty>
@@ -397,7 +399,7 @@ export function DeckPage() {
{ready && deck.columns.length > 0 ? (
<section
className="deck-board"
aria-label="調査カラム"
aria-label="Research columns"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
tabIndex={0}
>
+16 -16
View File
@@ -35,7 +35,7 @@ export function DeckSwitcher({
return (
<div className="deck-workspaces">
<div className="deck-actions">
<Label htmlFor="deck-selection">デッキプロファイル</Label>
<Label htmlFor="deck-selection">Deck profile</Label>
<NativeSelect
className="w-full"
id="deck-selection"
@@ -49,15 +49,15 @@ export function DeckSwitcher({
{decks.map((deck) => (
<NativeSelectOption key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : '(一時)'}
{deck.persisted ? '' : ' (temporary)'}
</NativeSelectOption>
))}
</NativeSelect>
<Button
variant="outline"
className="workspace-create"
aria-label="デッキを作成"
title="デッキを作成"
aria-label="Create deck"
title="Create deck"
type="button"
disabled={!ready}
onClick={() => {
@@ -65,13 +65,13 @@ export function DeckSwitcher({
}}
>
<Icon name="plus" />
<span>デッキを作成</span>
<span>Create deck</span>
</Button>
<Button
variant="ghost"
className="workspace-delete"
aria-label="デッキを削除"
title="デッキを削除"
aria-label="Delete deck"
title="Delete deck"
type="button"
disabled={!ready}
onClick={() => {
@@ -79,11 +79,11 @@ export function DeckSwitcher({
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
<span>Delete deck</span>
</Button>
</div>
{dialog === 'create' ? (
<Dialog title="デッキを作成" onClose={() => onDialogChange(null)}>
<Dialog title="Create deck" onClose={() => onDialogChange(null)}>
<form
className="deck-rename"
onSubmit={async (event) => {
@@ -94,7 +94,7 @@ export function DeckSwitcher({
}}
>
<Label htmlFor="new-deck-title">
新しいデッキ名
New deck name
<Input
id="new-deck-title"
name="title"
@@ -104,22 +104,22 @@ export function DeckSwitcher({
required
/>
</Label>
<Button type="submit">作成</Button>
<Button type="submit">Create</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
Cancel
</Button>
</form>
{errorNotice}
</Dialog>
) : null}
{dialog === 'delete' ? (
<Dialog title="デッキを削除" onClose={() => onDialogChange(null)}>
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
<div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p>
<p>Delete this deck and its column settings.</p>
<Button
variant="destructive"
type="button"
@@ -127,14 +127,14 @@ export function DeckSwitcher({
if (await onDelete()) onDialogChange(null)
}}
>
削除する
Delete
</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
Cancel
</Button>
</div>
{errorNotice}
+2 -2
View File
@@ -29,7 +29,7 @@ export function importLegacyWorkspace(
if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError(
'conflict',
'旧デッキはすでに取り込み済みです。別の内容で上書きできません。',
'Legacy decks have already been imported and cannot be overwritten.',
)
return listDecks(database)
}
@@ -56,7 +56,7 @@ export function importLegacyWorkspace(
if (!connection)
throw new DeckPersistenceError(
'connection-unavailable',
`旧プロファイル「${profileName}」に対応する接続がありません。接続一覧を更新してください。`,
`No connection matches the legacy profile “${profileName}”. Refresh your connections.`,
)
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,
'https://relay.invalid',
),
).toThrow('旧デッキはすでに取り込み済みです')
).toThrow('Legacy decks have already been imported')
expect(listDecks(database)).toEqual(result)
})
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,
'https://relay.invalid',
),
).toThrow('対応する接続がありません')
).toThrow('No connection matches')
expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([])
})
+2 -2
View File
@@ -16,7 +16,7 @@ const legacyDeck = z
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
'Column IDs must be unique.',
)
export const legacyWorkspaceSchema = z
.object({
@@ -28,5 +28,5 @@ export const legacyWorkspaceSchema = z
.refine(
(value) =>
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 (
<>
<Label htmlFor={id}>
カラムの種類
Column type
<NativeSelect
className="w-full"
id={id}
@@ -50,10 +50,10 @@ export function MastodonSourceEditor({
)
}
>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
<NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption>
<NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">List</NativeSelectOption>
<NativeSelectOption value="hashtag">Hashtag</NativeSelectOption>
</NativeSelect>
</Label>
<MastodonSourceFields
@@ -96,7 +96,7 @@ function MastodonSearchEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
Mastodonの検索条件
Mastodon search query
<Input
id={id}
value={source.query}
@@ -104,12 +104,13 @@ function MastodonSearchEditor({
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP"
placeholder="e.g. WebMCP"
required
/>
</Label>
<p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
Full-text search depends on server settings. Zero results do not
necessarily mean search is supported.
</p>
</div>
)
@@ -123,14 +124,14 @@ function MastodonUserEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
ユーザー
User
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="user@server またはアカウントID"
placeholder="user@server or account ID"
maxLength={256}
required
/>
@@ -147,14 +148,14 @@ function MastodonHashtagEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
ハッシュタグ
Hashtag
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="# を除いたタグ名"
placeholder="Tag without #"
maxLength={128}
required
/>
@@ -174,14 +175,14 @@ function MastodonListEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
リスト
List
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="このアカウントのリストID"
placeholder="List ID for this account"
maxLength={32}
required
/>
@@ -209,24 +210,24 @@ function MastodonListChoices({
queryFn: () => fetchLists({ data: { connectionId } }),
retry: false,
})
if (lists.isPending) return <p role="status">リストを取得しています…</p>
if (lists.isPending) return <p role="status">Loading lists…</p>
if (lists.isError)
return (
<p role="alert">
リストを取得できませんでした。
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
Retry
</Button>
</p>
)
return (
<section className="deck-list-choices" aria-label="このアカウントのリスト">
<section className="deck-list-choices" aria-label="Lists for this account">
{lists.data.lists.length === 0 ? (
<p>このアカウントのリストはありません。</p>
<p>This account has no lists.</p>
) : (
lists.data.lists.map((list) => (
<Button
+4 -7
View File
@@ -24,9 +24,7 @@ const searchSourceSchema = z
ctx.addIssue({
code: 'custom',
message:
error instanceof Error
? error.message
: '検索条件を確認してください。',
error instanceof Error ? error.message : 'Check the search query.',
})
}
})
@@ -43,8 +41,7 @@ function targetSchema(normalize: (target: string) => string) {
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error ? error.message : '対象を確認してください。',
message: error instanceof Error ? error.message : 'Check the target.',
})
return z.NEVER
}
@@ -93,7 +90,7 @@ export const deckSchema = z
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'カラム ID が重複しています。',
'Column IDs must be unique.',
)
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 const EMPTY_DECK: Deck = {
id: 'initial',
title: '新しい調査',
title: 'New research',
columns: [],
}
export const EMPTY_WORKSPACE: Workspace = {
+3 -3
View File
@@ -22,12 +22,12 @@ export class DeckPersistenceError extends Error {
const conflict = () =>
new DeckPersistenceError(
'conflict',
'別の端末で変更されました。最新のデッキを読み直してください。',
'This deck changed on another device. Reload the latest deck.',
)
const missing = () =>
new DeckPersistenceError(
'not-found',
'このデッキは削除されたか、見つかりません。',
'This deck was deleted or could not be found.',
)
export function loadDeck(
@@ -79,7 +79,7 @@ function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
) {
throw new DeckPersistenceError(
'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, title: 'Different request' }, second),
).toThrow('別の端末で変更されました')
).toThrow('This deck changed on another device')
expect(listDecks(database)).toEqual([saved])
})
@@ -105,7 +105,7 @@ it('replaces title, ordered columns and bindings atomically, then rejects stale
createdAt: initial.createdAt,
})
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'別の端末で変更されました',
'This deck changed on another device',
)
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(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('別の端末で変更されました')
).toThrow('This deck changed on another device')
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
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)
expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'このデッキは削除されたか',
'This deck was deleted',
)
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('このデッキは削除されたか')
).toThrow('This deck was deleted')
})
it.each([
@@ -152,7 +152,7 @@ it.each([
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database,
),
).toThrow('カラムの接続アカウントが利用できません')
).toThrow('The account for this column is unavailable')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
@@ -163,7 +163,7 @@ it('rejects a connection from another platform', () => {
.where(eq(connections.id, 'twitter-one'))
.run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
@@ -171,7 +171,7 @@ it('rejects a connection from another platform', () => {
it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow(
'カラムの接続アカウントが利用できません',
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
@@ -186,7 +186,7 @@ it('rejects duplicate column IDs without changing the saved version', () => {
},
database,
),
).toThrow('カラム ID が重複しています')
).toThrow('Column IDs must be unique')
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',
behavior: 'instant',
})
expect(screen.getByText('調査時に取得した投稿')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '内容を表示' })).toHaveAttribute(
expect(screen.getByText('Post saved during research')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show content' })).toHaveAttribute(
'aria-expanded',
'false',
)
@@ -44,7 +44,9 @@ it('focuses and briefly highlights a cited card without revealing sensitive cont
rerender(<ResearchPostCard post={post} citationRequest={1} />)
expect(scroll).toHaveBeenCalledTimes(2)
expect(article).toHaveFocus()
expect(screen.queryByText('調査時に取得した投稿')).not.toBeInTheDocument()
expect(
screen.queryByText('Post saved during research'),
).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(2500)
})
@@ -76,12 +78,12 @@ it('waits for reveal before mounting CW content or sensitive media', () => {
],
}
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.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.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()
})
+8 -8
View File
@@ -6,7 +6,7 @@ import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
@@ -53,10 +53,10 @@ export function ResearchPostCard({
data-post-url={post.url}
>
{citationSnapshot ? (
<Badge variant="secondary">調査時に取得した投稿</Badge>
<Badge variant="secondary">Post saved during research</Badge>
) : null}
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
<p className="handle">Boosted by {post.boostedBy.name}</p>
) : null}
<header>
<Avatar className="size-9 shrink-0">
@@ -77,7 +77,7 @@ export function ResearchPostCard({
</header>
{concealed ? (
<div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<p>{post.contentWarning || 'This post contains sensitive media'}</p>
<Button
variant="outline"
size="sm"
@@ -85,7 +85,7 @@ export function ResearchPostCard({
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? '内容を隠す' : '内容を表示'}
{revealed ? 'Hide content' : 'Show content'}
</Button>
</div>
) : null}
@@ -118,7 +118,7 @@ export function ResearchPostCard({
target="_blank"
rel="noreferrer noopener"
>
動画を開く
Open video
</a>
),
)}
@@ -133,7 +133,7 @@ export function ResearchPostCard({
target="_blank"
rel="noreferrer noopener"
>
引用元を開く
Open quoted post
</a>
</blockquote>
) : null}
@@ -141,7 +141,7 @@ export function ResearchPostCard({
) : null}
<footer>
<a href={post.url} target="_blank" rel="noreferrer noopener">
元の投稿を開く <Icon name="arrow" />
Open original post <Icon name="arrow" />
</a>
</footer>
</article>
+2 -2
View File
@@ -24,14 +24,14 @@ async function run<T>(
ok: false,
error: {
code: 'invalid-input',
message: 'デッキの入力内容を確認してください。',
message: 'Check the deck details.',
},
}
return {
ok: false,
error: {
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) {
case 'search':
return {
label: `${source.product === 'Latest' ? '最新' : '話題'}${source.following ? ' · フォロー中' : ''}`,
label: `${source.product === 'Latest' ? 'Latest' : 'Top'}${source.following ? ' · Following' : ''}`,
query: source.query,
}
case 'user':
return { label: 'ユーザー投稿', query: source.target }
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
return { label: 'List', query: source.target }
}
break
case 'mastodon':
switch (source.kind) {
case 'search':
return { label: '全文検索', query: source.query }
return { label: 'Full-text search', query: source.query }
case 'user':
return { label: 'ユーザー投稿', query: source.target }
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'リスト', query: source.target }
return { label: 'List', query: source.target }
case 'hashtag':
return { label: 'ハッシュタグ', query: `#${source.target}` }
return { label: 'Hashtag', query: `#${source.target}` }
}
break
}
+21 -24
View File
@@ -39,7 +39,7 @@ export function TwitterSourceEditor({
return (
<>
<Label htmlFor={id}>
カラムの種類
Column type
<NativeSelect
className="w-full"
id={id}
@@ -48,9 +48,9 @@ export function TwitterSourceEditor({
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
<NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">List</NativeSelectOption>
</NativeSelect>
</Label>
<TwitterSourceFields
@@ -91,7 +91,7 @@ function TwitterSearchEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={`${id}-query`}>
Twitterの検索条件
Twitter search query
<Input
id={`${id}-query`}
value={source.query}
@@ -99,12 +99,12 @@ function TwitterSearchEditor({
onChange({ ...source, query: event.target.value })
}
maxLength={512}
placeholder="例:WebMCP lang:ja"
placeholder="e.g. WebMCP lang:en"
required
/>
</Label>
<Label htmlFor={`${id}-product`}>
表示順
Sort by
<NativeSelect
className="w-full"
id={`${id}-product`}
@@ -116,12 +116,12 @@ function TwitterSearchEditor({
})
}
>
<NativeSelectOption value="Latest">最新</NativeSelectOption>
<NativeSelectOption value="Top">話題</NativeSelectOption>
<NativeSelectOption value="Latest">Latest</NativeSelectOption>
<NativeSelectOption value="Top">Top</NativeSelectOption>
</NativeSelect>
</Label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
Use Twitter search operators such as from:, lang:, and since:.
</p>
<Label className="deck-checkbox">
<Checkbox
@@ -130,7 +130,7 @@ function TwitterSearchEditor({
onChange({ ...source, following: checked })
}
/>
フォロー中のみ
Following only
</Label>
</div>
)
@@ -144,14 +144,14 @@ function TwitterUserEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
ユーザー
User
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="@handle またはプロフィールURL"
placeholder="@handle or profile URL"
maxLength={256}
required
/>
@@ -169,14 +169,14 @@ function TwitterListEditor({
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
リスト
List
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
placeholder="リストのURLまたはID"
placeholder="List URL or ID"
maxLength={256}
required
/>
@@ -199,27 +199,24 @@ function TwitterListChoices({
onSelect: (target: string) => void
}) {
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)
return (
<p role="alert">
リストを取得できませんでした。
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
Retry
</Button>
</p>
)
return (
<section
className="deck-list-choices"
aria-label="このプロファイルのリスト"
>
<section className="deck-list-choices" aria-label="Lists for this profile">
{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) => (
<Button
@@ -229,7 +226,7 @@ function TwitterListChoices({
onClick={() => onSelect(list.id)}
>
{list.name}
{list.isPrivate ? ' · 非公開' : ''}
{list.isPrivate ? ' · Private' : ''}
</Button>
))
)}
+1 -1
View File
@@ -194,7 +194,7 @@ it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
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')
})
+9 -11
View File
@@ -88,7 +88,7 @@ export function useDeck(editing = false) {
const id = newId()
if (preferred === view.id) preferred = id
setStorageError(
'保存済みの内容と異なる編集を、一時コピーとして残しました。',
'Your unsaved changes have been kept as a temporary copy.',
)
return [{ ...view, id }]
})
@@ -106,7 +106,7 @@ export function useDeck(editing = false) {
? preferred
: (all[0]?.id ?? '')
if (initialized.current && activeDeckId !== preferred)
setStorageError('表示中のデッキが別の端末で削除されました。')
setStorageError('This deck was deleted on another device.')
initialized.current = true
apply({ activeDeckId, decks: all })
},
@@ -138,9 +138,7 @@ export function useDeck(editing = false) {
requestGeneration !== generation.current
)
return false
setStorageError(
'保存済みデッキを読み込めませんでした。再試行してください。',
)
setStorageError('Unable to load saved decks. Please try again.')
return false
}
},
@@ -156,7 +154,7 @@ export function useDeck(editing = false) {
}
} catch {
setStorageError(
'旧デッキを読み込めませんでした。ブラウザの保存内容は変更していません。',
'Unable to load legacy decks. Your browser data has not been changed.',
)
}
void refresh()
@@ -176,7 +174,7 @@ export function useDeck(editing = false) {
accept: (value: T) => void,
) {
if (busy.current) {
setStorageError('保存処理が完了するまでお待ちください。')
setStorageError('Please wait until saving finishes.')
return false
}
busy.current = true
@@ -191,7 +189,7 @@ export function useDeck(editing = false) {
setStorageError(
error instanceof Error
? error.message
: '保存できませんでした。再試行してください。',
: 'Unable to save. Please try again.',
)
return false
} finally {
@@ -233,7 +231,7 @@ export function useDeck(editing = false) {
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('デッキが見つかりません。')
throw new Error('Deck not found.')
apply({ ...current.current, activeDeckId: id })
return true
}
@@ -247,7 +245,7 @@ export function useDeck(editing = false) {
}
async function persist(id: string) {
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
return mutate(
async () => {
@@ -266,7 +264,7 @@ export function useDeck(editing = false) {
}
async function remove(id: string, expectedRevision?: number) {
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 previous = current.current
const decks = previous.decks.filter((deck) => deck.id !== id)
+10 -10
View File
@@ -24,11 +24,11 @@ export function DailyBrief({
)
return (
<section className="home-brief" aria-label="今日のブリーフ">
<section className="home-brief" aria-label="Daily brief">
<p>
{focus
? `今日は「${focus.title}」から始められそうです。`
: 'いま優先して取りかかるタスクはありません。'}
? `Start with “${focus.title}” today.`
: 'There are no priority tasks right now.'}
{firstContact ? (
<>
{' '}
@@ -36,27 +36,27 @@ export function DailyBrief({
type="button"
onClick={() => onOpenContact(firstContact.id)}
>
{firstContact.name}さんへの返信
Reply to {firstContact.name}
</button>
{contacts.length > 1
? `など、確認できる連絡が${contacts.length}件あります。`
: 'が残っています。'}
? ` — one of ${contacts.length} conversations to review.`
: ' when you’re ready.'}
</>
) : (
' いま返信に取りかかる連絡もありません。'
' There are no conversations awaiting your attention.'
)}
</p>
{resume ? (
<p className="home-brief-secondary">
再開メモには「
Your resume note: “
{resume.text.length > 100
? `${resume.text.slice(0, 100)}…`
: resume.text}
」と残っています。
”
</p>
) : optional ? (
<p className="home-brief-secondary">
「{optional.title}」は、余裕があるときに。
“{optional.title}” can wait until you have room.
</p>
) : null}
</section>
+49 -48
View File
@@ -23,7 +23,10 @@ import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { articles } from '#/features/inbox/mock-articles'
import { ConversationDetail } from '#/features/support/conversation-detail'
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 { type HomeAction, interpretAction } from './interpret-action'
import './home.css'
@@ -138,7 +141,7 @@ export function HomePage() {
void navigate({ to: action.to })
break
}
setNotice(proposal?.label ?? '反映しました')
setNotice(proposal?.label ?? 'Updated')
setProposal(null)
setPrompt('')
}
@@ -171,8 +174,8 @@ export function HomePage() {
<div className="home-scroll">
<div className="home-content">
<header className="home-greeting">
<p className="text-sm text-muted-foreground">今日</p>
<h1>おはようございます、Yutaさん</h1>
<p className="text-sm text-muted-foreground">Today</p>
<h1>Good morning, Yuta</h1>
<DailyBrief onOpenContact={setContactId} />
</header>
<form
@@ -184,8 +187,8 @@ export function HomePage() {
>
<Textarea
ref={input}
aria-label="ホームへの入力"
placeholder="頭にあることを、そのまま。"
aria-label="Home input"
placeholder="What’s on your mind?"
value={prompt}
onChange={(event) => {
setPrompt(event.target.value)
@@ -205,12 +208,12 @@ export function HomePage() {
/>
<div className="home-composer-footer">
<span className="text-xs text-muted-foreground">
やること、返信、メモ。
Tasks, replies, notes.
</span>
<Button
type="submit"
size="icon"
aria-label="候補を表示"
aria-label="Preview action"
disabled={!prompt.trim()}
>
<ArrowUp />
@@ -219,9 +222,9 @@ export function HomePage() {
</form>
<div className="home-suggestions">
{[
'買い物を追加',
'青木さんに返信',
'メモ:次は資料の2ページ目から',
'Add grocery shopping',
'Reply to Aoki',
'Note: Resume on page 2',
].map((text) => (
<button
type="button"
@@ -236,13 +239,11 @@ export function HomePage() {
{proposal && (
<div className="home-proposal">
<p>{proposal.label}</p>
<Button onClick={() => apply(proposal.action)}>
実行する
</Button>
<Button onClick={() => apply(proposal.action)}>Apply</Button>
<Button
variant="ghost"
size="icon"
aria-label="候補を取り消す"
aria-label="Dismiss suggestion"
onClick={() => setProposal(null)}
>
<X />
@@ -253,12 +254,12 @@ export function HomePage() {
{notice}
</p>
<div className="home-columns">
<section className="home-plan" aria-label="今日の予定">
<section className="home-plan" aria-label="Today’s plan">
{(
[
{ id: 'focus', label: 'まずこれ' },
{ id: 'routine', label: 'いつものこと' },
{ id: 'optional', label: '余裕があれば' },
{ id: 'focus', label: 'Start here' },
{ id: 'routine', label: 'Routines' },
{ id: 'optional', label: 'If there’s room' },
] as const
).map((section) => (
<section key={section.id} className="home-section">
@@ -267,14 +268,14 @@ export function HomePage() {
<Button
variant="ghost"
size="icon-sm"
aria-label={`${section.label}に追加`}
aria-label={`Add to ${section.label}`}
onClick={() =>
suggest(
section.id === 'routine'
? 'ルーティンに追加:'
? 'Add routine:'
: section.id === 'optional'
? '余裕があれば '
: '追加:',
? 'Optional: '
: 'Add:',
)
}
>
@@ -292,7 +293,7 @@ export function HomePage() {
<button
type="button"
className="home-check"
aria-label={`${task.title}を完了`}
aria-label={`Complete ${task.title}`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
@@ -309,7 +310,7 @@ export function HomePage() {
<p>{task.title}</p>
{task.steps && (
<details>
<summary>小さく進める</summary>
<summary>One step at a time</summary>
<div className="home-steps">
{task.steps.map((step) => (
<label
@@ -360,7 +361,7 @@ export function HomePage() {
<Button
variant="ghost"
size="icon-sm"
aria-label={`${task.title}を上へ`}
aria-label={`Move ${task.title} up`}
onClick={() => moveTask(task.id, -1)}
>
<ArrowUp />
@@ -368,7 +369,7 @@ export function HomePage() {
<Button
variant="ghost"
size="icon-sm"
aria-label={`${task.title}を下へ`}
aria-label={`Move ${task.title} down`}
onClick={() => moveTask(task.id, 1)}
>
<ArrowDown />
@@ -376,7 +377,7 @@ export function HomePage() {
<Button
variant="ghost"
size="sm"
aria-label={`${task.title}をあとで`}
aria-label={`Defer ${task.title}`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
@@ -387,7 +388,7 @@ export function HomePage() {
)
}
>
あとで
Later
</Button>
</div>
</div>
@@ -402,7 +403,7 @@ export function HomePage() {
onClick={() => setContactId(item.id)}
>
<span className="block">
{item.name}さんに返信
Reply to {item.name}
</span>
<span className="text-xs text-muted-foreground">
{item.subject}
@@ -411,7 +412,7 @@ export function HomePage() {
<Button
variant="ghost"
size="icon-sm"
aria-label={`${item.name}との会話を開く`}
aria-label={`Open conversation with ${item.name}`}
onClick={() => setContactId(item.id)}
>
<ChevronRight />
@@ -424,7 +425,7 @@ export function HomePage() {
) &&
(section.id !== 'focus' || !waiting.length) && (
<p className="home-empty">
ここは空けておいても大丈夫。
It’s okay to leave some room.
</p>
)}
</section>
@@ -432,7 +433,7 @@ export function HomePage() {
{rest.length > 0 && (
<details className="home-rest">
<summary>
あとで・終えたこと <span>{rest.length}</span>
Later & done <span>{rest.length}</span>
</summary>
{rest.map((task) => (
<div className="home-task" key={task.id}>
@@ -445,7 +446,7 @@ export function HomePage() {
<Button
variant="ghost"
size="icon-sm"
aria-label={`${task.title}を戻す`}
aria-label={`Restore ${task.title}`}
onClick={() =>
setTasks((current) =>
current.map((item) =>
@@ -463,11 +464,11 @@ export function HomePage() {
</details>
)}
</section>
<aside className="home-context" aria-label="今日のつながり">
<aside className="home-context" aria-label="Today’s context">
<section className="home-section">
<header className="home-section-heading">
<h2>連絡</h2>
<Link to="/support" aria-label="連絡をすべて見る">
<h2>Messages</h2>
<Link to="/support" aria-label="View all messages">
<ArrowUpRight className="size-4" />
</Link>
</header>
@@ -488,9 +489,9 @@ export function HomePage() {
<span>{item.name}</span>
<span className="text-xs text-muted-foreground">
{statuses[item.id] === 'waiting'
? '相手待ち'
? 'Waiting'
: statuses[item.id] === 'later'
? 'あとで'
? 'Later'
: item.channels.join(' / ')}
</span>
</button>
@@ -498,15 +499,15 @@ export function HomePage() {
</section>
<section className="home-section">
<header className="home-section-heading">
<h2>記録</h2>
<Link to="/journal" aria-label="記録を開く">
<h2>Journal</h2>
<Link to="/journal" aria-label="Open journal">
<ArrowUpRight className="size-4" />
</Link>
</header>
{currentEntries.slice(0, 2).map((entry) => (
<p className="home-record" key={entry.id}>
<span className="text-xs text-muted-foreground">
{entry.kind}
{entryKindLabels[entry.kind]}
</span>
<span>
{entry.text}
@@ -518,17 +519,17 @@ export function HomePage() {
<button
type="button"
className="home-context-row"
onClick={() => suggest('メモ:')}
onClick={() => suggest('Note:')}
>
<span>ひとこと残す</span>
<span>Leave a note</span>
<Plus className="size-4" />
</button>
)}
</section>
<section className="home-section">
<header className="home-section-heading">
<h2>読みもの</h2>
<Link to="/inbox" aria-label="リーダーを開く">
<h2>Reading</h2>
<Link to="/inbox" aria-label="Open reader">
<ArrowUpRight className="size-4" />
</Link>
</header>
@@ -547,7 +548,7 @@ export function HomePage() {
))}
</section>
<Link to="/deck" className="home-deck-link">
Twitter Liteで調べる
Explore in Twitter Lite
<ArrowUpRight className="size-4" />
</Link>
</aside>
@@ -557,7 +558,7 @@ export function HomePage() {
</SidebarInset>
{contact && (
<Dialog
title={`${contact.name}との連絡`}
title={`Conversation with ${contact.name}`}
className="sm:max-w-3xl"
onClose={() => setContactId(undefined)}
>
+54 -1
View File
@@ -122,7 +122,7 @@ describe('home natural-language proposals', () => {
['メモ:散歩でアイデアを思いついた', 'メモ', '散歩でアイデアを思いついた'],
['再開地点:レビューの続き', '再開地点', 'レビューの続き'],
['体重は65.2kg', '体重', '65.2'],
['薬を飲んだ', '服薬', '服薬した'],
['薬を飲んだ', '服薬', 'Took medication'],
])('records explicit user statements: %s', (input, kind, text) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
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 = {
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. */
@@ -29,18 +29,50 @@ export function interpretAction(
tasks: Array<{ id: string; title: string; steps?: string[] }>,
contacts: Array<{ id: string; name: string }>,
): Interpretation {
const text = input
let text = input
.trim()
.replace(/[。!!]$/, '')
.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 (
/しない|しなく|せず|しないで|やめ|取り消|取消|ではなく|じゃなく|不要|送信|送って|送る/.test(
text,
)
) {
return {
error: 'この内容は自動で変更できません。対象の画面で確認してください。',
error: 'Please review this action in the relevant screen.',
}
}
@@ -61,7 +93,7 @@ export function interpretAction(
if (route)
return {
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)
@@ -71,7 +103,7 @@ export function interpretAction(
if (!body) return unsupported
return {
action: { type: 'journal', kind, text: body },
label: `${kind}を記録する`,
label: `Save ${kind === 'メモ' ? 'note' : 'resume point'}`,
}
}
const weight = text.match(
@@ -80,7 +112,7 @@ export function interpretAction(
if (weight?.[1])
return {
action: { type: 'journal', kind: '体重', text: weight[1] },
label: `体重 ${weight[1]} kg を記録する`,
label: `Log weight: ${weight[1]} kg`,
}
if (
/^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test(
@@ -88,8 +120,8 @@ export function interpretAction(
)
) {
return {
action: { type: 'journal', kind: '服薬', text: '服薬した' },
label: '服薬したことを記録する',
action: { type: 'journal', kind: '服薬', text: 'Took medication' },
label: 'Log medication',
}
}
@@ -102,7 +134,9 @@ export function interpretAction(
(item) =>
item.name === 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 matchingTask = matchingTasks[0]
@@ -118,28 +152,28 @@ export function interpretAction(
id: matchingTask.id,
status: 'later',
},
label: `${name}をあとでにする`,
label: `Defer ${name}`,
}
}
if (matches.length > 0 && matchingTasks.length > 0 && /保留/.test(text)) {
return {
error:
'タスクと連絡先の両方に当てはまります。対象の画面で確認してください。',
'This matches both a task and a contact. Please use the relevant screen.',
}
}
const person = matches[0]
if (matches.length !== 1 || !person)
return { error: '連絡先をひとりに絞れません。氏名を入力してください。' }
return { error: 'Please enter the full name of one contact.' }
if (/相手待ち|保留/.test(text)) {
const status = text.includes('相手待ち') ? 'waiting' : 'later'
return {
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 {
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]
if (matches.length !== 1 || !task)
return {
error:
'タスクをひとつに絞れません。一覧の名前をそのまま指定してください。',
error: 'Please use the exact name of one task from the list.',
}
return {
action: {
@@ -159,7 +192,7 @@ export function interpretAction(
id: task.id,
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)
return {
error:
'タスクをひとつに絞れません。一覧の名前をそのまま指定してください。',
error: 'Please use the exact name of one task from the list.',
}
const status = /未完了|戻して/.test(change[2])
? 'todo'
@@ -187,7 +219,7 @@ export function interpretAction(
const step = steps[0]
if (steps.length === 1 && step) {
if (status === 'later')
return { error: 'ルーティンの手順は完了・未完了に変更できます。' }
return { error: 'Routine steps can be marked done or incomplete.' }
return {
action: {
type: 'routine-step',
@@ -195,14 +227,14 @@ export function interpretAction(
step: step.step,
done: status === 'done',
},
label: `${title}を${status === 'done' ? '完了' : '未完了'}にする`,
label: `Mark ${title} as ${status === 'done' ? 'done' : 'incomplete'}`,
}
}
const task = matches[0]
if (!task) return unsupported
return {
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'
return {
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
+8 -8
View File
@@ -35,22 +35,22 @@ export function InboxPage() {
<WorkspaceNavigation />
<Sidebar collapsible="none" className="inbox-list-panel">
<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">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-2 size-4 text-muted-foreground"
/>
<Input
aria-label="記事を検索"
placeholder="検索"
aria-label="Search articles"
placeholder="Search"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</SidebarHeader>
<SidebarContent className="gap-0" aria-label="記事一覧">
<SidebarContent className="gap-0" aria-label="Articles">
{visible.map((article) => (
<ArticleRow
key={article.id}
@@ -64,9 +64,9 @@ export function InboxPage() {
))}
{visible.length === 0 && (
<div className="inbox-no-results">
<p>記事がありません</p>
<p>No articles found</p>
<Button variant="outline" onClick={() => setQuery('')}>
検索をクリア
Clear search
</Button>
</div>
)}
@@ -79,7 +79,7 @@ export function InboxPage() {
variant="ghost"
size="icon"
className="inbox-back"
aria-label="記事一覧に戻る"
aria-label="Back to articles"
onClick={() => setReadingMobile(false)}
>
<ArrowLeft />
@@ -91,7 +91,7 @@ export function InboxPage() {
<section
className="inbox-reader-scroll"
key={selected?.id}
aria-label="記事本文"
aria-label="Article content"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable.
tabIndex={0}
>
+64 -64
View File
@@ -12,127 +12,127 @@ export type Article = {
export const articles: Article[] = [
{
id: 'agent-browser-workflow',
title: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
title: 'Thinking in the browser: a new workflow with AI agents',
summary:
'コードを書いて終わりにしない。画面を開き、操作し、確かめるところまでをひとつの作業にする。',
'Make implementation and verification one task: open the screen, use it, and check the result.',
source: 'Field Notes',
author: 'Field Notes 編集部',
author: 'Field Notes Editors',
date: '09:40',
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',
title: '毎日使いたくなるCLIは、終了時のひと言まで設計されている',
title: 'A good CLI considers its final line',
summary:
'ヘルプ、エラー、実行結果。小さな道具の使い心地は、出力の細部で変わる。',
'Help, errors, and results: the smallest output details shape a tool’s experience.',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
author: 'Toolbox Letter Editors',
date: '09:15',
paragraphs: [
'コマンドを実行したあと、何が起きたのか分からない。これは処理そのものが成功していても、使う側には不安が残る体験だ。作成したファイルの場所や処理件数を一行で伝えるだけで、道具とのやり取りはずっと明快になる。',
'エラーも、原因と次の行動が近くにあると扱いやすい。「設定が不正です」より、「出力先が見つかりません。存在するディレクトリを指定してください」のほうが、調べ直す手間を減らせる。内部の事情は必要なときにだけ表示できればよい。',
'人が読む出力と、別のプログラムが読む出力の用途も分けて考えたい。通常は簡潔な文章を返し、構造化された結果が必要なら専用の形式を選べるようにする。小さなCLIほど、こうした一貫性が長く使う理由になる。',
'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.”',
'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',
title: '3カラムの余白が、情報を読むリズムをつくる',
summary:
'場所を選ぶ、候補を眺める、ひとつを読む。役割が分かれた画面について。',
title: 'Three columns create a rhythm for reading',
summary: 'Choose a place, scan the options, and settle into an article.',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
author: 'Quiet Interface Editors',
date: '08:50',
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',
title: 'エージェントに渡す文脈は、短くてもよい',
title: 'Context for an agent can be brief',
summary:
'大量の説明よりも、目的・制約・完了条件をそろえる。依頼を整理する小さな習慣。',
'Align on the goal, constraints, and completion criteria before adding background.',
source: 'Field Notes',
author: 'Field Notes 編集部',
author: 'Field Notes Editors',
date: '08:30',
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',
title: 'ローカルで完結する、小さな情報収集ワークスペース',
summary:
'集めることより、あとから見つけられること。ファイルを中心にした道具の組み合わせ。',
title: 'A small, local workspace for collecting information',
summary: 'Finding something again matters more than collecting it.',
source: 'Workbench Journal',
author: 'Workbench Journal 編集部',
date: '昨日',
author: 'Workbench Journal Editors',
date: 'Yesterday',
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',
title: 'デザインシステムを、使う人の言葉で整える',
summary: 'コンポーネントをそろえた先にある、ラベルや空状態の一貫性。',
title: 'Build a design system in the user’s language',
summary:
'Consistency extends beyond components to labels and empty states.',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '昨日',
author: 'Quiet Interface Editors',
date: 'Yesterday',
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',
title: 'おすすめの理由が見える、パーソナルな記事フィード',
summary:
'「なぜこの記事?」に答えられることが、興味に基づく推薦の出発点になる。',
title: 'A personal feed with understandable recommendations',
summary: 'Explain why an article connects to someone’s interests.',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '昨日',
author: 'Field Notes Editors',
date: 'Yesterday',
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',
title: 'テストに残したいのは、実装よりも利用者の約束',
summary: '内部の構造を変えても守りたい動作を、短いシナリオで表す。',
title: 'Test the promises made to users',
summary:
'Describe behavior that should survive changes to the implementation.',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
date: '2日前',
author: 'Toolbox Letter Editors',
date: '2 days ago',
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',
title: '読みやすい本文は、文字サイズだけでは決まらない',
summary: '行の長さ、行間、段落の間隔。読み進めやすい記事画面のためのメモ。',
title: 'Readable text is about more than font size',
summary:
'Line length, line spacing, and paragraph spacing all affect reading.',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '2日前',
author: 'Quiet Interface Editors',
date: '2 days ago',
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 {
type JournalEntry as Entry,
type EntryKind,
entryKindLabels,
useWorkspaceState,
} from '#/features/workspace/workspace-state'
import './journal.css'
@@ -19,8 +20,8 @@ const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: ''
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
function formatTime(value: string | undefined) {
if (!value) return '不明'
return new Date(value).toLocaleString('ja-JP', {
if (!value) return 'Unknown'
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
@@ -58,7 +59,7 @@ export function JournalPage() {
<WorkspaceNavigation />
<SidebarInset className="journal-main">
<header className="journal-header">
<h1 className="text-base font-semibold">記録</h1>
<h1 className="text-base font-semibold">Journal</h1>
<Button
variant="outline"
disabled={current.length === 0}
@@ -67,7 +68,7 @@ export function JournalPage() {
setReviewing(true)
}}
>
記録をまとめる
Summarize entries
</Button>
</header>
<div className="journal-scroll">
@@ -79,28 +80,30 @@ export function JournalPage() {
append(draft)
setDraft(emptyDraft)
}}
submitLabel="記録する"
submitLabel="Save entry"
/>
<section className="journal-entries" aria-label="記録一覧">
<section className="journal-entries" aria-label="Journal entries">
{entries.length === 0 && (
<p className="journal-empty">まだ記録はありません</p>
<p className="journal-empty">No entries yet</p>
)}
{entries.map((entry) => (
<article
key={entry.id}
className="journal-entry"
aria-label={`${entry.kind}の記録`}
aria-label={`${entryKindLabels[entry.kind]} entry`}
>
<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) && (
<span className="text-xs text-muted-foreground">
訂正前
Original
</span>
)}
{entry.correctionOf && (
<span className="text-xs text-muted-foreground">
訂正
Correction
</span>
)}
{!superseded.has(entry.id) && (
@@ -110,7 +113,7 @@ export function JournalPage() {
className="ml-auto"
onClick={() => setCorrecting(entry)}
>
訂正する
Correct
</Button>
)}
</div>
@@ -119,12 +122,12 @@ export function JournalPage() {
{entry.kind === '体重' ? ' kg' : ''}
</p>
<p className="text-xs text-muted-foreground">
発生日時: {formatTime(entry.occurredAt)} · 記録日時:{' '}
Occurred: {formatTime(entry.occurredAt)} · Recorded:{' '}
{formatTime(entry.recordedAt)}
</p>
{(entry.kind === '体重' || entry.source) && (
<p className="mt-2 text-xs text-muted-foreground">
記録元: {entry.source || '不明'}
Source: {entry.source || 'Unknown'}
</p>
)}
</article>
@@ -146,12 +149,12 @@ export function JournalPage() {
)}
{reviewing && (
<Dialog
title="記録をまとめる"
title="Summarize entries"
className="sm:max-w-2xl"
onClose={() => setReviewing(false)}
>
<p className="text-sm text-muted-foreground">
含める記録を選んで確認できます。外部には送信されません。
Select the entries to include. Nothing is sent externally.
</p>
<div className="journal-review-list">
{current.map((entry) => (
@@ -174,7 +177,8 @@ export function JournalPage() {
/>
<span>
<span className="text-xs text-muted-foreground">
{entry.kind} · {formatTime(entry.occurredAt)}
{entryKindLabels[entry.kind]} ·{' '}
{formatTime(entry.occurredAt)}
</span>
<span className="block text-sm">
{entry.text}
@@ -185,7 +189,7 @@ export function JournalPage() {
))}
</div>
<Textarea
aria-label="記録のまとめ"
aria-label="Entry summary"
className="journal-summary"
readOnly
value={buildSummary(
@@ -222,7 +226,7 @@ function EntryEditor({
if (valid) onSubmit()
}}
>
<fieldset className="journal-kind-switch" aria-label="記録の種類">
<fieldset className="journal-kind-switch" aria-label="Entry type">
{kinds.map((kind) => (
<Button
key={kind}
@@ -232,7 +236,7 @@ function EntryEditor({
aria-pressed={draft.kind === kind}
onClick={() => onChange({ ...draft, kind })}
>
{kind}
{entryKindLabels[kind]}
</Button>
))}
</fieldset>
@@ -242,8 +246,8 @@ function EntryEditor({
type="number"
step="any"
min="0.1"
aria-label="体重"
placeholder="体重"
aria-label="Weight"
placeholder="Weight"
value={draft.text}
onChange={(event) =>
onChange({ ...draft, text: event.target.value })
@@ -253,13 +257,13 @@ function EntryEditor({
</div>
) : (
<Textarea
aria-label="記録内容"
aria-label="Entry text"
placeholder={
draft.kind === '服薬'
? '服用した薬の名前'
? 'Name of the medication taken'
: draft.kind === '再開地点'
? '次にすること、開いておく場所'
: '自由なメモ、次にすること'
? 'What to do next and where to pick up'
: 'A thought or your next step'
}
value={draft.text}
onChange={(event) => onChange({ ...draft, text: event.target.value })}
@@ -267,8 +271,8 @@ function EntryEditor({
)}
{draft.kind === '体重' && (
<Input
aria-label="記録元"
placeholder="記録元(体重計など・任意)"
aria-label="Source"
placeholder="Source (e.g. scale, optional)"
value={draft.source}
onChange={(event) =>
onChange({ ...draft, source: event.target.value })
@@ -277,11 +281,11 @@ function EntryEditor({
)}
<div className="journal-editor-footer">
<label className="journal-date-label" htmlFor={dateId}>
発生日時(任意)
When it happened (optional)
<Input
id={dateId}
type="datetime-local"
aria-label="発生日時"
aria-label="When it happened"
value={draft.occurredAt}
onChange={(event) =>
onChange({ ...draft, occurredAt: event.target.value })
@@ -307,38 +311,38 @@ function CorrectionDialog({
}) {
const [draft, setDraft] = useState<Draft>(entry)
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">
元の記録を残して、訂正を追加します。
Add a correction while keeping the original entry.
</p>
<EntryEditor
draft={draft}
onChange={setDraft}
onSubmit={() => onSave(draft)}
submitLabel="訂正を追加"
submitLabel="Add correction"
/>
</Dialog>
)
}
function buildSummary(entries: Entry[]) {
if (!entries.length) return '記録を選択してください。'
if (!entries.length) return 'Select an entry.'
const dates = entries
.map((entry) => entry.occurredAt)
.filter(Boolean)
.sort()
const period = dates.length
? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}`
: '不明'
: 'Unknown'
return [
`発生日時の期間: ${period}`,
`発生日時が不明の記録: ${entries.filter((entry) => !entry.occurredAt).length}件`,
`Period covered: ${period}`,
`Entries with an unknown event time: ${entries.filter((entry) => !entry.occurredAt).length} entries`,
'',
...entries.map(
(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')
}
@@ -26,21 +26,21 @@ async function requireOk(response: Response, search = false) {
if (response.status === 401)
throw new MastodonFeedError(
'account-unavailable',
'アカウントを再接続してください。',
'Reconnect your account.',
)
if (response.status === 429)
throw new MastodonFeedError(
'rate-limit',
'取得回数の制限に達しました。しばらく待ってから再試行してください。',
'Rate limit reached. Wait a moment and try again.',
)
if (search && [400, 422, 501, 503].includes(response.status))
throw new MastodonFeedError(
'search-unavailable',
'検索を実行できません。接続先の対応状況と検索条件を確認してください。',
'Search is unavailable. Check server support and your search query.',
)
throw new MastodonFeedError(
'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')
throw new MastodonFeedError(
'account-unavailable',
'Mastodonアカウントを接続してください。',
'Connect a Mastodon account.',
)
const params = new URLSearchParams({ limit: String(PAGE_SIZE) })
let path: string
@@ -19,7 +19,7 @@ export const loadMastodonPosts = createServerFn({ method: 'GET' })
message:
error instanceof MastodonFeedError
? error.message
: 'Mastodonから取得できませんでした。',
: 'Could not load from Mastodon.',
retryable:
!(error instanceof MastodonFeedError) ||
error.code !== 'account-unavailable',
+14 -12
View File
@@ -33,7 +33,9 @@ export const searchPageInputSchema = z.object({
function requireHandle(value: string): string {
if (!HANDLE.test(value)) {
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
throw new InputError(
'Handles can only contain letters, numbers, and underscores.',
)
}
return value
}
@@ -41,7 +43,7 @@ function requireHandle(value: string): string {
export function normalizeUserTarget(raw: string): string {
const value = raw.trim()
if (!value) {
throw new InputError('ハンドルまたはプロフィール URL を入力してください。')
throw new InputError('Enter a handle or profile URL.')
}
if (value.startsWith('@')) {
return requireHandle(value.slice(1))
@@ -54,21 +56,21 @@ export function normalizeUserTarget(raw: string): string {
try {
url = new URL(value)
} catch {
throw new InputError('プロフィール URL の形式を確認してください。')
throw new InputError('Check the profile URL format.')
}
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)
if (segments.length !== 1) {
throw new InputError('プロフィール URL を入力してください。')
throw new InputError('Enter a profile URL.')
}
return requireHandle(segments[0] ?? '')
}
function requireListId(value: string): string {
if (!LIST_ID.test(value)) {
throw new InputError('リスト ID は数字で入力してください。')
throw new InputError('List IDs must contain only numbers.')
}
return value
}
@@ -76,7 +78,7 @@ function requireListId(value: string): string {
export function normalizeListTarget(raw: string): string {
const value = raw.trim()
if (!value) {
throw new InputError('リストの URL または ID を入力してください。')
throw new InputError('Enter a list URL or ID.')
}
if (!value.includes('://')) {
return requireListId(value)
@@ -86,14 +88,14 @@ export function normalizeListTarget(raw: string): string {
try {
url = new URL(value)
} catch {
throw new InputError('リスト URL の形式を確認してください。')
throw new InputError('Check the list URL format.')
}
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)
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] ?? '')
}
@@ -101,12 +103,12 @@ export function normalizeListTarget(raw: string): string {
export function buildSearchQuery(raw: string, following: boolean): string {
const query = raw.trim()
if (!query) {
throw new InputError('検索語を入力してください。')
throw new InputError('Enter a search query.')
}
const finalQuery =
following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
if (finalQuery.length > 512) {
throw new InputError('検索語は 512 文字以内で入力してください。')
throw new InputError('Search queries must be 512 characters or fewer.')
}
return finalQuery
}
+6 -6
View File
@@ -81,7 +81,7 @@ describe('loadListChoices', () => {
ok: false,
error: {
code: 'upstream',
message: 'X から投稿を取得できませんでした。',
message: 'Could not load posts from X.',
retryable: true,
},
})
@@ -123,7 +123,7 @@ describe('loadUserPage', () => {
ok: false,
error: {
code: 'user-not-found',
message: 'ユーザーが見つかりませんでした。',
message: 'User not found.',
retryable: false,
},
})
@@ -164,7 +164,7 @@ describe('loadListPage', () => {
ok: false,
error: {
code: 'invalid-input',
message: 'リスト ID は数字で入力してください。',
message: 'List IDs must contain only numbers.',
retryable: false,
},
})
@@ -182,7 +182,7 @@ describe('loadListPage', () => {
ok: false,
error: {
code: 'list-not-found',
message: 'リストが見つかりませんでした。',
message: 'List not found.',
retryable: false,
},
})
@@ -199,7 +199,7 @@ describe('loadListPage', () => {
ok: false,
error: {
code: 'list-unavailable',
message: 'このリストは取得できません。',
message: 'This list is unavailable.',
retryable: false,
},
})
@@ -310,7 +310,7 @@ describe('searchPage', () => {
ok: false,
error: {
code: 'upstream',
message: 'X から投稿を取得できませんでした。',
message: 'Could not load posts from X.',
retryable: true,
},
})
+10 -13
View File
@@ -73,17 +73,14 @@ const failure = (
type FailureSubject = 'user' | 'list'
const notFoundFailure: Record<FailureSubject, () => LoadFailure> = {
user: () =>
failure('user-not-found', 'ユーザーが見つかりませんでした。', false),
list: () =>
failure('list-not-found', 'リストが見つかりませんでした。', false),
user: () => failure('user-not-found', 'User not found.', false),
list: () => failure('list-not-found', 'List not found.', false),
}
const unavailableFailure: Record<FailureSubject, () => LoadFailure> = {
user: () =>
failure('user-unavailable', 'このユーザーの投稿は取得できません。', false),
list: () =>
failure('list-unavailable', 'このリストは取得できません。', false),
failure('user-unavailable', 'Posts from this user are unavailable.', false),
list: () => failure('list-unavailable', 'This list is unavailable.', false),
}
function upstreamFailure(
@@ -98,7 +95,7 @@ function upstreamFailure(
: ''
const lower = message.toLowerCase()
if (lower.includes('timeout') || lower.includes('aborted')) {
return failure('timeout', '取得がタイムアウトしました。', true)
return failure('timeout', 'The request timed out.', true)
}
if (lower.includes('not found')) {
return notFoundFailure[subject]()
@@ -107,7 +104,7 @@ function upstreamFailure(
return unavailableFailure[subject]()
}
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 {
@@ -141,7 +138,7 @@ export async function loadListChoices(
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true)
return failure('timeout', 'The request timed out.', true)
}
return upstreamFailure(error, 'list')
}
@@ -181,7 +178,7 @@ export async function loadUserPage(
)
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true)
return failure('timeout', 'The request timed out.', true)
}
if (error instanceof InputError) {
return failure('invalid-input', error.message, false)
@@ -205,7 +202,7 @@ export async function loadListPage(
)
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true)
return failure('timeout', 'The request timed out.', true)
}
if (error instanceof InputError) {
return failure('invalid-input', error.message, false)
@@ -231,7 +228,7 @@ export async function searchPage(
)
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return failure('timeout', '取得がタイムアウトしました。', true)
return failure('timeout', 'The request timed out.', true)
}
if (error instanceof InputError) {
return failure('invalid-input', error.message, false)
+1 -1
View File
@@ -30,7 +30,7 @@ const configFailure = (diagnostic: unknown): LoadFailure => {
ok: false,
error: {
code: 'relay-config',
message: 'TWITTER_RELAY_BASE_URL を設定してください。',
message: 'Set TWITTER_RELAY_BASE_URL.',
retryable: false,
},
}
@@ -29,12 +29,12 @@ export async function fetchProfileNames(
export async function requireProfileName(profileName: string): Promise<void> {
const profiles = await fetchProfileNames().catch(() => {
throw new ProfileUnavailableError(
'プロファイルを確認できませんでした。Relay への接続を確認して再試行してください。',
'Could not verify profiles. Check the relay connection and try again.',
)
})
if (!profiles.includes(profileName)) {
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(
'プロファイルを確認できませんでした。',
'Could not verify profiles.',
)
})
})
+1 -2
View File
@@ -78,8 +78,7 @@ export function createResearchStore(database = getDatabase()): ResearchStore {
},
activate(id) {
database.transaction(() => {
if (id !== null && !load(id))
throw new Error('この会話は見つかりません。')
if (id !== null && !load(id)) throw new Error('Conversation not found.')
setActive(id)
})
},
+1 -3
View File
@@ -141,9 +141,7 @@ describe('research history persistence', () => {
it('rejects a missing conversation without changing the active conversation', () => {
const store = createResearchStore(open())
store.save(run)
expect(() => store.activate('missing')).toThrow(
'この会話は見つかりません。',
)
expect(() => store.activate('missing')).toThrow('Conversation not found.')
expect(store.loadActive()).toEqual(run)
})
@@ -104,7 +104,7 @@ it('propagates failed saves so the user can retry instead of reporting successfu
actions.save.mockResolvedValueOnce(false)
await expect(
syncResearchDeck(plan, false, bindings, actions),
).rejects.toThrow('一時デッキを更新できませんでした')
).rejects.toThrow('Unable to update the temporary deck')
await expect(
syncResearchDeck(plan, true, bindings, actions),
).resolves.toBeUndefined()
+1 -1
View File
@@ -22,7 +22,7 @@ export async function syncResearchDeck(
if (existing && !existing.persisted) {
if (!(await workspace.save({ ...plan, id: existing.id }))) {
throw new Error(
'一時デッキを更新できませんでした。保存処理の完了後に再試行してください。',
'Unable to update the temporary deck. Try again after saving finishes.',
)
}
viewId = existing.id
+3 -3
View File
@@ -45,15 +45,15 @@ export function ResearchMarkdown({
img: ({ src, alt }) =>
typeof src === 'string' && src ? (
<a href={src} target="_blank" rel="noopener noreferrer">
{alt || '画像を開く'}
{alt || 'Open image'}
</a>
) : (
<span>{alt || '画像'}</span>
<span>{alt || 'Image'}</span>
),
table: ({ children }) => (
<section
className="research-markdown-table"
aria-label="回答の表"
aria-label="Response table"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users need to scroll wide tables inside the narrow chat pane.
tabIndex={0}
>
+69 -67
View File
@@ -106,28 +106,34 @@ it('retains input, selected accounts, and the request ID across responsive view
connection,
{ ...connection, id: 'second', displayName: 'second' },
])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 2件' }))
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 2' }),
)
fireEvent.click(screen.getByRole('checkbox', { name: 'second · Twitter' }))
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCPの反応' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'入力内容は残っています',
'Your draft has been kept',
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
expect(screen.getByLabelText('Message')).toHaveValue('WebMCPの反応')
expect(screen.queryByText(/internal credential/)).toBeNull()
view.rerender(view.ui(true, undefined, 'mobile'))
expect(screen.getByRole('alert')).toHaveTextContent('入力内容は残っています')
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 1件' }))
expect(screen.getByRole('alert')).toHaveTextContent(
'Your draft has been kept',
)
expect(screen.getByLabelText('Message')).toHaveValue('WebMCPの反応')
fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 1' }),
)
expect(
screen.getByRole('checkbox', { name: 'second · Twitter' }),
).not.toBeChecked()
expect(MockEventSource.instances).toHaveLength(1)
expect(MockEventSource.instances[0]?.close).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
expect(actions.start.mock.calls[1]?.[0]).toEqual(
actions.start.mock.calls[0]?.[0],
@@ -136,11 +142,11 @@ it('retains input, selected accounts, and the request ID across responsive view
it('opens each generated deck version only once for the run started in this browser', async () => {
const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), {
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCPの反応' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce())
act(() =>
view.client.setQueryData(['research-status'], {
@@ -162,7 +168,9 @@ it('opens each generated deck version only once for the run started in this brow
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledTimes(2), {
timeout: 2500,
})
expect(screen.getByRole('log', { name: '調査の会話' })).toBeVisible()
expect(
screen.getByRole('log', { name: 'Research conversation' }),
).toBeVisible()
})
it('restores the latest plan automatically on reload and permits manual activation', async () => {
@@ -172,7 +180,7 @@ it('restores the latest plan automatically on reload and permits manual activati
expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, true),
)
fireEvent.click(
await screen.findByRole('button', { name: '一時デッキで開く' }),
await screen.findByRole('button', { name: 'Open temporary deck' }),
)
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenLastCalledWith(running.deck, true),
@@ -185,13 +193,13 @@ it('reports cancellation failure and updates the run after a successful retry',
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue({ ...running, status: 'cancelled' })
show()
fireEvent.click(await screen.findByRole('button', { name: '調査を中止' }))
fireEvent.click(await screen.findByRole('button', { name: 'Stop research' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'中止できませんでした',
'Unable to stop research',
)
fireEvent.click(screen.getByRole('button', { name: '調査を中止' }))
fireEvent.click(screen.getByRole('button', { name: 'Stop research' }))
await waitFor(() =>
expect(screen.queryByRole('button', { name: '調査を中止' })).toBeNull(),
expect(screen.queryByRole('button', { name: 'Stop research' })).toBeNull(),
)
expect(actions.cancel).toHaveBeenCalledWith({ data: { id: running.id } })
})
@@ -213,7 +221,7 @@ it('limits account selection to six and shows the host report path on completion
})),
)
await screen.findByText('/home/owner/reports/webmcp.md')
fireEvent.click(screen.getByText('接続アカウント · 6件'))
fireEvent.click(screen.getByText('Connected accounts · 6'))
expect(
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
).toHaveAttribute('aria-disabled', 'true')
@@ -225,7 +233,7 @@ it('limits account selection to six and shows the host report path on completion
it('waits for the workspace before restoring a completed plan from the stream', async () => {
const view = show([connection], false)
await screen.findByLabelText('メッセージ')
await screen.findByLabelText('Message')
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
@@ -253,9 +261,9 @@ it('reconnects the stream without polling or cancelling the host job on unmount'
const stream = MockEventSource.instances[0]
expect(stream?.url).toBe('/api/research/events')
act(() => stream?.onerror?.())
expect(screen.getByText(/配信に再接続/)).toBeVisible()
expect(screen.getByText(/Reconnecting to research updates/)).toBeVisible()
act(() => stream?.onopen?.())
expect(screen.queryByText(/配信に再接続/)).toBeNull()
expect(screen.queryByText(/Reconnecting to research updates/)).toBeNull()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1200))
})
@@ -274,11 +282,11 @@ it('keeps newer stream state when the start response arrives late', async () =>
}),
)
const view = show()
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.change(screen.getByLabelText('メッセージ'), {
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'WebMCP' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ configured: true, run: running }),
@@ -343,13 +351,13 @@ it('shows streamed conversation and sends follow-up input with the current deck
const view = show()
expect(await screen.findByText('検索結果を比較しました。')).toBeVisible()
expect(screen.getByText('検索カラムを追加しました')).toBeVisible()
expect(screen.getByText('あなた')).toBeVisible()
expect(screen.getByText('You')).toBeVisible()
const editedDeck = { id: 'other', title: '切り替えたデッキ', columns: [] }
view.rerender(view.ui(true, editedDeck))
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'このデッキを絞って' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() =>
expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -359,9 +367,7 @@ it('shows streamed conversation and sends follow-up input with the current deck
}),
}),
)
await waitFor(() =>
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
await waitFor(() => expect(screen.getByLabelText('Message')).toHaveValue(''))
const updatedDeck = { id: 'plan', title: '絞り込み結果', columns: [] }
act(() =>
MockEventSource.instances[0]?.onmessage?.({
@@ -390,9 +396,11 @@ it('shows streamed conversation and sends follow-up input with the current deck
it('selects newly loaded connected accounts until the user changes their selection', async () => {
const view = show([])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 0件' }))
await screen.findByText('接続アカウントを先に追加してください。')
await waitFor(() => expect(screen.getByLabelText('Message')).toBeEnabled())
fireEvent.click(
screen.getByRole('button', { name: 'Connected accounts · 0' }),
)
await screen.findByText('Add a connected account first.')
view.rerender(
<QueryClientProvider client={view.client}>
<ResearchPanel
@@ -403,10 +411,10 @@ it('selects newly loaded connected accounts until the user changes their selecti
/>
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '調べて' },
})
expect(screen.getByRole('button', { name: '送信' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled()
})
it('starts a new chat without changing the deck and clears the draft only after success', async () => {
@@ -419,30 +427,26 @@ it('starts a new chat without changing the deck and clears the draft only after
.mockResolvedValue({ configured: true, run: null })
const view = show()
await waitFor(() =>
expect(
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
expect(screen.getByRole('button', { name: 'New chat' })).toBeEnabled(),
)
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '下書き' },
})
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'会話と入力内容は残っています',
'Your conversation and draft have been kept',
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('下書き')
expect(screen.getByLabelText('Message')).toHaveValue('下書き')
const callsBeforeReset = view.onOpenDeck.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
await waitFor(() =>
expect(screen.getByLabelText('メッセージ')).toHaveValue(''),
)
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
await waitFor(() => expect(screen.getByLabelText('Message')).toHaveValue(''))
expect(screen.queryByText('WebMCPの反応')).toBeNull()
expect(view.onOpenDeck).toHaveBeenCalledTimes(callsBeforeReset)
expect(screen.getByText('手動デッキ')).toBeVisible()
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '新しい話題' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledOnce())
expect(actions.start.mock.calls[0]?.[0].data).not.toHaveProperty('runId')
expect(actions.reset).toHaveBeenCalledWith({ data: { id: running.id } })
@@ -452,7 +456,7 @@ it('disables new chat during a running turn and receives another device reset ov
current = { configured: true, run: running }
const view = show()
await screen.findByText('WebMCPの反応')
expect(screen.getByRole('button', { name: '新しいチャット' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'New chat' })).toBeDisabled()
const callsBeforeReset = view.onOpenDeck.mock.calls.length
act(() =>
MockEventSource.instances[0]?.onmessage?.({
@@ -475,11 +479,9 @@ it('does not let a late reset response erase a newer streamed conversation', asy
)
const view = show()
await waitFor(() =>
expect(
screen.getByRole('button', { name: '新しいチャット' }),
).toBeEnabled(),
expect(screen.getByRole('button', { name: 'New chat' })).toBeEnabled(),
)
fireEvent.click(screen.getByRole('button', { name: '新しいチャット' }))
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
const newer = {
configured: true,
run: {
@@ -596,11 +598,11 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
connection,
{ ...connection, id: 'second', displayName: 'account2' },
])
await screen.findByLabelText('チャット履歴')
fireEvent.change(screen.getByLabelText('メッセージ'), {
await screen.findByLabelText('Chat history')
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '別の下書き' },
})
fireEvent.change(screen.getByLabelText('チャット履歴'), {
fireEvent.change(screen.getByLabelText('Chat history'), {
target: { value: saved.id },
})
expect(await screen.findByText('保存した会話')).toBeVisible()
@@ -615,9 +617,9 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true),
)
expect(screen.getByLabelText('メッセージ')).toHaveValue('')
expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible()
fireEvent.click(screen.getByText('接続アカウント · 1件'))
expect(screen.getByLabelText('Message')).toHaveValue('')
expect(screen.getByText('Interrupted · Ready to resume')).toBeVisible()
fireEvent.click(screen.getByText('Connected accounts · 1'))
expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).not.toBeChecked()
@@ -631,24 +633,24 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
configured: true,
run: { ...saved, message: '更新' },
run: { ...saved, message: 'Refresh' },
history,
}),
}),
)
await waitFor(() =>
expect(view.client.getQueryData(['research-status'])).toMatchObject({
run: { message: '更新' },
run: { message: 'Refresh' },
}),
)
expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).toBeChecked()
expect(view.onOpenDeck).toHaveBeenCalledOnce()
fireEvent.change(screen.getByLabelText('メッセージ'), {
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: '続きを調べて' },
})
fireEvent.click(screen.getByRole('button', { name: '送信' }))
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
await waitFor(() =>
expect(actions.start).toHaveBeenCalledWith({
data: expect.objectContaining({
@@ -680,7 +682,7 @@ it('restores a context-only chat on reload and prevents switching during a runni
await waitFor(() =>
expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true),
)
expect(screen.getByLabelText('チャット履歴')).toBeDisabled()
expect(screen.getByLabelText('Chat history')).toBeDisabled()
})
it('keeps newer streamed history when a chat selection response arrives late', async () => {
@@ -702,7 +704,7 @@ it('keeps newer streamed history when a chat selection response arrives late', a
}),
)
const view = show()
fireEvent.change(await screen.findByLabelText('チャット履歴'), {
fireEvent.change(await screen.findByLabelText('Chat history'), {
target: { value: saved.id },
})
const newer = { configured: true, run: { ...running, id: 'remote' }, history }
+47 -40
View File
@@ -52,12 +52,12 @@ import {
import './research-panel.css'
const statusNames: Record<ResearchRun['status'], string> = {
starting: '開始しています',
running: '調査中',
complete: '完了',
failed: '失敗',
cancelled: '中止しました',
interrupted: '中断しました・続きから再開できます',
starting: 'Starting',
running: 'Researching',
complete: 'Complete',
failed: 'Failed',
cancelled: 'Cancelled',
interrupted: 'Interrupted · Ready to resume',
}
type ResearchInput = { topic: string; connectionIds: string[] }
type ResearchDraft = {
@@ -186,7 +186,7 @@ export function ResearchPanel({
pendingTurn.current = null
void syncDeck(deck, activate).catch(() => {
setError(
'調査デッキを同期できませんでした。「一時デッキで開く」から再試行できます。',
'Unable to sync the research deck. Use “Open temporary deck” to try again.',
)
})
}, [run, syncDeck, ready])
@@ -222,7 +222,7 @@ export function ResearchPanel({
} catch {
pendingTurn.current = null
setError(
'送信できませんでした。接続を確認して再試行してください。入力内容は残っています。',
'Unable to send. Check your connection and try again. Your draft has been kept.',
)
return false
} finally {
@@ -247,7 +247,7 @@ export function ResearchPanel({
: existing,
)
} catch {
setError('調査を中止できませんでした。もう一度お試しください。')
setError('Unable to stop research. Please try again.')
} finally {
setBusy(false)
}
@@ -268,7 +268,7 @@ export function ResearchPanel({
updateDraft({ topic: '' })
} catch {
setError(
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
'Unable to start a new chat. Your conversation and draft have been kept. Please try again.',
)
} finally {
setBusy(false)
@@ -287,7 +287,7 @@ export function ResearchPanel({
)
} catch {
setError(
'チャットを開けませんでした。入力内容は残っています。もう一度お試しください。',
'Unable to open the chat. Your draft has been kept. Please try again.',
)
} finally {
setBusy(false)
@@ -301,7 +301,7 @@ export function ResearchPanel({
await syncDeck(deck, true)
} catch {
setError(
'調査デッキを開けませんでした。保存処理の完了後に再試行してください。',
'Unable to open the research deck. Try again after saving finishes.',
)
} finally {
setBusy(false)
@@ -309,9 +309,9 @@ export function ResearchPanel({
}
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
<aside className="research-panel" aria-label="調査チャット">
<aside className="research-panel" aria-label="Research chat">
<header className="research-heading">
<h2>調査チャット</h2>
<h2>Research chat</h2>
<Button
variant="ghost"
size="sm"
@@ -319,16 +319,16 @@ export function ResearchPanel({
disabled={busy || !run || isResearchActive(run)}
title={
isResearchActive(run)
? '調査を中止してから新しいチャットを開始できます'
? 'Stop research before starting a new chat'
: undefined
}
onClick={() => void newChat()}
>
新しいチャット
New chat
</Button>
{(status.data?.history?.length ?? 0) > 0 && (
<label className="research-history" htmlFor={historyId}>
<span>チャット履歴</span>
<span>Chat history</span>
<NativeSelect
id={historyId}
size="sm"
@@ -337,7 +337,7 @@ export function ResearchPanel({
onChange={(event) => void selectChat(event.target.value)}
>
<NativeSelectOption value="" disabled>
新しいチャット
New chat
</NativeSelectOption>
{status.data?.history?.map((entry) => (
<NativeSelectOption key={entry.id} value={entry.id}>
@@ -350,14 +350,15 @@ export function ResearchPanel({
</header>
{reconnecting && (
<p className="deck-save-status" role="status">
調査状況の配信に再接続しています。処理は自宅サーバーで続きます。
Reconnecting to research updates. Processing continues on your home
server.
</p>
)}
{status.isError && (
<p className="deck-error" role="alert">
調査状況を取得できませんでした。
Unable to load research status.
<Button type="button" onClick={() => void status.refetch()}>
再試行
Retry
</Button>
</p>
)}
@@ -367,9 +368,11 @@ export function ResearchPanel({
</p>
)}
{status.isPending ? (
<p role="status">調査の接続を確認しています…</p>
<p role="status">Checking the research connection…</p>
) : !status.data?.configured ? (
<p role="status">自宅のCodexへの接続が設定されていません。</p>
<p role="status">
The connection to Codex on your home server is not configured.
</p>
) : null}
<ResearchConversation
key={`conversation:${run?.id ?? 'new'}`}
@@ -416,16 +419,20 @@ function ResearchConversation({
citations: ResearchCitation[]
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
}) {
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' }
const names = { user: 'You', assistant: 'Codex', tool: 'Tool activity' }
return (
<MessageScrollerProvider autoScroll>
<MessageScroller className="research-conversation">
<MessageScrollerViewport aria-label="調査メッセージ">
<MessageScrollerContent aria-label="調査の会話" className="gap-5 p-4">
<MessageScrollerViewport aria-label="Research messages">
<MessageScrollerContent
aria-label="Research conversation"
className="gap-5 p-4"
>
{messages.length === 0 ? (
<MessageScrollerItem messageId="empty">
<p className="research-empty">
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
Send a topic or question. The assistant adds columns as it
searches and saves a report to your home server.
</p>
</MessageScrollerItem>
) : (
@@ -471,7 +478,7 @@ function ResearchConversation({
)}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton aria-label="最新のメッセージへ" />
<MessageScrollerButton aria-label="Jump to latest message" />
</MessageScroller>
</MessageScrollerProvider>
)
@@ -508,7 +515,7 @@ function ResearchComposer({
return (
<form
className="research-start-form"
aria-label="調査メッセージ"
aria-label="Research messages"
onSubmit={(event) => {
event.preventDefault()
if (!topic.trim() || validSelection.length === 0 || disabled) return
@@ -521,18 +528,18 @@ function ResearchComposer({
}}
>
<p className="research-context">
送信するデッキ: <strong>{contextTitle}</strong>
Deck to send: <strong>{contextTitle}</strong>
</p>
<fieldset disabled={disabled}>
<Collapsible className="research-account-picker" disabled={disabled}>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
接続アカウント · {validSelection.length}件
Connected accounts · {validSelection.length}
</CollapsibleTrigger>
<CollapsibleContent>
<fieldset className="research-accounts">
<legend>調査に使う接続アカウント(最大6件)</legend>
<legend>Accounts for research (up to 6)</legend>
{available.length === 0 ? (
<p>接続アカウントを先に追加してください。</p>
<p>Add a connected account first.</p>
) : (
available.map((connection) => (
<label key={connection.id} htmlFor={`${id}-${connection.id}`}>
@@ -562,7 +569,7 @@ function ResearchComposer({
</fieldset>
</CollapsibleContent>
</Collapsible>
<label htmlFor={id}>メッセージ</label>
<label htmlFor={id}>Message</label>
<InputGroup>
<InputGroupTextarea
id={id}
@@ -570,7 +577,7 @@ function ResearchComposer({
onChange={(event) => onTopicChange(event.target.value)}
maxLength={1000}
rows={3}
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
placeholder="e.g. Compare reactions to WebMCP, then refine this deck."
required
disabled={disabled}
/>
@@ -583,7 +590,7 @@ function ResearchComposer({
disabled || !topic.trim() || validSelection.length === 0
}
>
送信
Send
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
@@ -605,7 +612,7 @@ function ResearchRunStatus({
}) {
const deck = run.deck ?? run.contextDeck
return (
<section className="research-run" aria-label="調査状況">
<section className="research-run" aria-label="Research status">
<Marker role="status">
<MarkerContent>{statusNames[run.status]}</MarkerContent>
</Marker>
@@ -616,7 +623,7 @@ function ResearchRunStatus({
)}
{run.reportPath && (
<p>
Markdownの保存先
Markdown location
<br />
<code className="research-report-path">{run.reportPath}</code>
</p>
@@ -630,7 +637,7 @@ function ResearchRunStatus({
disabled={busy}
onClick={onCancel}
>
調査を中止
Stop research
</Button>
)}
{deck && (
@@ -642,7 +649,7 @@ function ResearchRunStatus({
onOpenDeck(deck)
}}
>
一時デッキで開く
Open temporary deck
</Button>
)}
</div>
+29 -23
View File
@@ -94,7 +94,7 @@ export function createResearchService(
if (isResearchActive(run) && run) {
run.status = 'interrupted'
run.error =
'サーバーの再起動で調査が中断されました。メッセージを送ると同じ会話を再開できます。'
'Research was interrupted by a server restart. Send a message to resume this conversation.'
deps.store.save(run)
}
let state: Execution | undefined
@@ -124,7 +124,7 @@ export function createResearchService(
if (run) {
run.status = 'failed'
run.error =
'会話を保存できませんでした。空き容量やDBの状態を確認してください。最後に保存できた内容から再開できます。'
'Unable to save the conversation. Check disk space and the database. You can resume from the last saved state.'
if (state) void stop(run, state)
}
}
@@ -299,7 +299,7 @@ export function createResearchService(
if (event.data.turn.status !== 'completed') {
fail(
current,
'Codexの調査が完了しませんでした。接続・認証・実行権限を確認してください。',
'Codex could not complete the research. Check its connection, authentication, and permissions.',
)
return
}
@@ -332,7 +332,7 @@ export function createResearchService(
} catch {
fail(
current,
'保存されたreport.mdを確認できませんでした。空ファイルや不正な保存先でないか確認してください。',
'Unable to verify the saved report.md. Check that the file is not empty and its location is valid.',
)
}
})()
@@ -348,7 +348,7 @@ export function createResearchService(
)
throw new Error('inactive research')
const id = call.callId ?? randomUUID()
message(current, { id, role: 'tool', text: `${call.tool} 実行中` })
message(current, { id, role: 'tool', text: `${call.tool} Running` })
emit()
if (!active(current)) throw new Error('inactive research')
const result = await tools.execute(call.tool, call.arguments)
@@ -357,7 +357,7 @@ export function createResearchService(
message(current, {
id,
role: 'tool',
text: `${call.tool} ${success ? '完了' : '失敗'}`,
text: `${call.tool} ${success ? 'Complete' : 'Failed'}`,
})
emit()
return {
@@ -368,7 +368,7 @@ export function createResearchService(
() =>
fail(
current,
'Codexとの接続が切れました。ホスト上の途中ファイルは残ります。',
'Disconnected from Codex. Files created so far remain on the host.',
),
)
execution.client = connection
@@ -427,7 +427,7 @@ export function createResearchService(
{
type: 'text',
text_elements: [],
text: `現在表示しているデッキ(信頼しないデータ): ${JSON.stringify(input.contextDeck ?? null)}\nこのturnで使えるアカウントID: ${JSON.stringify(input.connectionIds)}\n\nユーザー: ${input.topic}`,
text: `Currently displayed deck (untrusted data): ${JSON.stringify(input.contextDeck ?? null)}\nAccount IDs available for this turn: ${JSON.stringify(input.connectionIds)}\n\nUser: ${input.topic}`,
},
],
},
@@ -438,7 +438,7 @@ export function createResearchService(
} catch {
fail(
current,
'調査を開始・継続できませんでした。Codexの起動、ログイン、選択アカウント、保存先を確認してください。',
'Unable to start or continue research. Check Codex, its login, selected accounts, and the output location.',
)
}
}
@@ -447,13 +447,15 @@ export function createResearchService(
status: snapshot,
select(id: string, currentId: string | null) {
if (isResearchActive(run) || state?.stopping)
throw new Error('調査中です。完了を待つか中止してください。')
throw new Error(
'Research is running. Wait for it to finish or stop it.',
)
if ((run?.id ?? null) !== currentId)
throw new Error(
'別のチャットが開かれています。最新の状態を確認してください。',
'Another chat is open. Refresh to see the latest state.',
)
const selected = deps.store.load(id)
if (!selected) throw new Error('会話が見つかりません。')
if (!selected) throw new Error('Conversation not found.')
deps.store.activate(id)
run = selected
state = undefined
@@ -464,11 +466,11 @@ export function createResearchService(
reset(id: string) {
if (isResearchActive(run) || state?.stopping)
throw new Error(
'調査中です。中止してから新しいチャットを開始してください。',
'Research is running. Stop it before starting a new chat.',
)
if (run && run.id !== id)
throw new Error(
'別のチャットが開かれています。最新の状態を確認してください。',
'Another chat is open. Refresh to see the latest state.',
)
deps.store.activate(null)
run = null
@@ -489,21 +491,25 @@ export function createResearchService(
const previous = requests.get(input.requestId)
if (previous) {
if (previous.input !== key)
throw new Error('同じリクエストIDの内容が変わっています。')
throw new Error('The content for this request ID has changed.')
return structuredClone(previous.run)
}
if (isResearchActive(run) || state?.stopping)
throw new Error('調査が実行中です。完了を待つか中断してください。')
throw new Error(
'Research is running. Wait for it to finish or stop it.',
)
if (input.runId && (!run || run.id !== input.runId || !run.threadId))
throw new Error(
'続ける会話が見つかりません。新しい会話を開始してください。',
'The conversation to continue could not be found. Start a new conversation.',
)
const config = deps.config()
if (!config)
throw new Error('Codexへの接続とMarkdown保存先が未設定です。')
throw new Error(
'The Codex connection and Markdown output location are not configured.',
)
if (requests.size >= 100)
throw new Error(
'プロトタイプの実行上限です。アプリを再起動してください。',
'The prototype run limit has been reached. Restart the app.',
)
const current: ResearchRun = {
...(input.runId && run ? structuredClone(run) : {}),
@@ -514,7 +520,7 @@ export function createResearchService(
status: 'starting',
startedAt: Date.now(),
deckVersion: input.runId && run ? run.deckVersion : 0,
message: 'Codexに調査を依頼しています…',
message: 'Asking Codex to research…',
messages: [
...(input.runId && run ? structuredClone(run.messages) : []),
{ id: input.requestId, role: 'user', text: input.topic },
@@ -532,7 +538,7 @@ export function createResearchService(
if (!active(current)) return
current.status = 'failed'
current.error =
'調査の制限時間(15分)に達しました。途中のMarkdownはホストに残ります。'
'Research reached its 15-minute time limit. Markdown files created so far remain on the host.'
void stop(current, execution)
emit()
}, MAX_RUN_MS)
@@ -542,14 +548,14 @@ export function createResearchService(
return structuredClone(current)
},
async cancel(id: string) {
if (!run || run.id !== id) throw new Error('この調査は見つかりません。')
if (!run || run.id !== id) throw new Error('Research run not found.')
if (!state) return structuredClone(run)
const current = run
const execution = state
if (active(current)) {
current.status = 'cancelled'
current.message =
'調査を中断しました。ホストに作成済みのファイルは残ります。'
'Research stopped. Files already created remain on the host.'
const stopping = stop(current, execution)
emit()
await stopping
+19 -14
View File
@@ -154,7 +154,7 @@ it('deduplicates repeated start requests and rejects changed content under the s
const first = service.start(request)
expect(service.start(request).id).toBe(first.id)
expect(() => service.start({ ...request, topic: 'Different' })).toThrow(
'内容が変わっています',
'content for this request ID has changed',
)
const client = await running()
expect(clients).toHaveLength(1)
@@ -166,7 +166,7 @@ it('deduplicates repeated start requests and rejects changed content under the s
it('rejects a second active research run', async () => {
service.start(input())
await running()
expect(() => service.start(input())).toThrow('調査が実行中')
expect(() => service.start(input())).toThrow('Research is running')
expect(clients).toHaveLength(1)
})
@@ -271,7 +271,7 @@ it('waits for an accepted turn-start response before interrupting and ignores la
const cancelled = service.cancel(first.id)
expect(service.status().run?.status).toBe('cancelled')
expect(old.close).not.toHaveBeenCalled()
expect(() => service.start(input())).toThrow('調査が実行中')
expect(() => service.start(input())).toThrow('Research is running')
resolveTurn({ turn: { id: old.turnId } })
await cancelled
expect(old.calls).toHaveBeenCalledWith('turn/interrupt', {
@@ -428,7 +428,10 @@ it('resumes the same conversation with fresh deck context and preserves prior me
expect.objectContaining({ role: 'user', text: 'WebMCP' }),
{ id: 'reply-one', role: 'assistant', text: 'どんな調査をしますか?' },
expect.objectContaining({ role: 'user', text: 'このカラムを読んで' }),
expect.objectContaining({ role: 'tool', text: 'fetch_column_posts 完了' }),
expect.objectContaining({
role: 'tool',
text: 'fetch_column_posts Complete',
}),
{ id: 'reply-two', role: 'assistant', text: '読みました' },
])
old.disconnected()
@@ -437,7 +440,7 @@ it('resumes the same conversation with fresh deck context and preserves prior me
it('rejects unknown continuation IDs', () => {
expect(() => service.start({ ...input(), runId: randomUUID() })).toThrow(
'続ける会話が見つかりません',
'The conversation to continue could not be found',
)
})
@@ -528,7 +531,7 @@ it('resets a completed conversation for every viewer and starts a fresh thread w
expect(service.reset(first.id).run).toBeNull()
expect(await readFile(reportPath, 'utf8')).toBe('# Saved research')
expect(() => service.start({ ...input(), runId: first.id })).toThrow(
'続ける会話が見つかりません',
'The conversation to continue could not be found',
)
const next = service.start(input())
const current = await running()
@@ -546,9 +549,9 @@ it('resets a completed conversation for every viewer and starts a fresh thread w
it('requires an active turn to stop before resetting and rejects a stale reset from another viewer', async () => {
const first = service.start(input())
await running()
expect(() => service.reset(first.id)).toThrow('中止してから')
expect(() => service.reset(first.id)).toThrow('Stop it before')
await service.cancel(first.id)
expect(() => service.reset(randomUUID())).toThrow('別のチャット')
expect(() => service.reset(randomUUID())).toThrow('Another chat')
expect(service.status().run?.id).toBe(first.id)
expect(service.reset(first.id).run).toBeNull()
})
@@ -698,11 +701,13 @@ it('persists a blank new chat and lets all viewers reopen the retained conversat
it('rejects switching conversations while running or from a stale viewer', async () => {
const first = service.start(input())
const client = await running()
expect(() => service.select(first.id, first.id)).toThrow('調査中')
expect(() => service.select(first.id, first.id)).toThrow(
'Research is running',
)
client.completed()
await vi.waitFor(() => expect(service.status().run?.status).toBe('complete'))
expect(() => service.select(first.id, null)).toThrow('別のチャット')
expect(() => service.select(randomUUID(), first.id)).toThrow('見つかりません')
expect(() => service.select(first.id, null)).toThrow('Another chat')
expect(() => service.select(randomUUID(), first.id)).toThrow('not found')
expect(service.status().run?.id).toBe(first.id)
})
@@ -728,7 +733,7 @@ it('stops a running turn when saving streamed messages fails', async () => {
})
expect(service.status().run).toMatchObject({
status: 'failed',
error: expect.stringContaining('保存できません'),
error: expect.stringContaining('Unable to save'),
})
await vi.waitFor(() => expect(client.close).toHaveBeenCalledTimes(1))
expect(client.calls).toHaveBeenCalledWith('turn/interrupt', {
@@ -750,7 +755,7 @@ function orphanedRun(): ResearchRun {
turnId: 'orphan-turn',
connectionIds: [account.id],
deckVersion: 0,
message: '調査中',
message: 'Researching',
citations: [],
messages: [{ id: 'original', role: 'user', text: 'WebMCP' }],
}
@@ -763,7 +768,7 @@ it('marks an unfinished persisted turn interrupted after service restart', () =>
expect(service.status().run).toMatchObject({
...interrupted,
status: 'interrupted',
error: expect.stringContaining('再起動'),
error: expect.stringContaining('server restart'),
})
expect(store.loadActive()?.status).toBe('interrupted')
expect(clients).toHaveLength(0)
@@ -119,7 +119,7 @@ it.each([
act(() => {
expect(result.current.navigate(citation.post.url, [citation])).toBe(true)
})
expect(createTemporary).toHaveBeenCalledWith('引用元の投稿', [column])
expect(createTemporary).toHaveBeenCalledWith('Cited posts', [column])
expect(result.current.target?.deckId).toBe('created')
})
@@ -83,7 +83,7 @@ export function useCitationNavigation({
}))
return true
}
const deck = createTemporary('引用元の投稿', [citation.column])
const deck = createTemporary('Cited posts', [citation.column])
setTarget((previous) => ({
...citation,
deckId: deck.id,
+25 -25
View File
@@ -32,7 +32,7 @@ export function ConversationDetail({
return (
<article
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">
<p className="text-xs text-muted-foreground">
@@ -44,13 +44,13 @@ export function ConversationDetail({
<p className="text-sm text-muted-foreground">{conversation.subject}</p>
</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>
<div className="border-l-2 border-border pl-4">
<p className="text-sm leading-6">{conversation.nextStep}</p>
{conversation.deadline && (
<p className="mt-1 text-xs text-muted-foreground">
相手の指定期限 · {conversation.deadline}
Requested deadline · {conversation.deadline}
</p>
)}
</div>
@@ -58,7 +58,7 @@ export function ConversationDetail({
<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">
元のやりとり{' '}
Original messages{' '}
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-5 pt-5">
@@ -70,7 +70,7 @@ export function ConversationDetail({
tabIndex={-1}
>
<p className="mb-2 text-xs text-muted-foreground">
{message.direction === 'in' ? conversation.name : '自分'} ·{' '}
{message.direction === 'in' ? conversation.name : 'You'} ·{' '}
{message.channel} · {message.time}
</p>
<p className="whitespace-pre-wrap text-sm leading-7">
@@ -81,13 +81,13 @@ export function ConversationDetail({
</div>
</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">
<label
htmlFor={`support-reply-${conversation.id}`}
className="text-sm font-medium"
>
返信
Reply
</label>
<span className="text-xs text-muted-foreground">
{conversation.replyChannel} → {conversation.name}
@@ -122,11 +122,11 @@ export function ConversationDetail({
{conversation.scheduleOptions.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label="日程候補"
aria-label="Suggested times"
value={slot}
onChange={(event) => setSlot(event.target.value)}
>
<NativeSelectOption value="">候補日時を選ぶ</NativeSelectOption>
<NativeSelectOption value="">Choose a time</NativeSelectOption>
{conversation.scheduleOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{option}
@@ -139,18 +139,18 @@ export function ConversationDetail({
disabled={!slot}
onClick={() => {
setDraft(
(current) => `${current}\n\n${slot}はいかがでしょうか。`,
(current) => `${current}\n\nWould ${slot} work for you?`,
)
setTone('')
}}
>
返信に入れる
Add to reply
</Button>
<Button
size="icon-sm"
variant="ghost"
disabled={!slot}
aria-label="予定を仮置きする"
aria-label="Preview calendar hold"
onClick={() => setReview('schedule')}
>
<CalendarPlus />
@@ -163,13 +163,13 @@ export function ConversationDetail({
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<Check className="size-3" />
{scheduled} をこの画面に仮置きしました
{scheduled} held in this preview
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<Button disabled={!draft.trim()} onClick={() => setReview('send')}>
<Send />
送信内容を確認
Review reply
</Button>
<div className="flex flex-wrap gap-1">
<Button
@@ -177,21 +177,21 @@ export function ConversationDetail({
size="sm"
onClick={() => onResolve('later')}
>
今は置いておく
Leave for later
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('waiting')}
>
相手待ち
Waiting
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('dismissed')}
>
返信せず閉じる
Close without replying
</Button>
</div>
</div>
@@ -199,7 +199,7 @@ export function ConversationDetail({
<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">
人物メモ{' '}
About this person{' '}
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
</summary>
<ul className="mt-4 space-y-3 text-sm leading-6">
@@ -221,7 +221,7 @@ export function ConversationDetail({
source?.focus()
}}
>
出典
Source
</a>
</li>
))}
@@ -237,12 +237,12 @@ export function ConversationDetail({
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{review === 'schedule' ? '予定の確認' : '送信内容の確認'}
{review === 'schedule' ? 'Review calendar hold' : 'Review reply'}
</DialogTitle>
<DialogDescription>
{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>
</DialogHeader>
<div className="space-y-3 py-2">
@@ -260,7 +260,7 @@ export function ConversationDetail({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReview(null)}>
戻る
Back
</Button>
<Button
onClick={() => {
@@ -268,12 +268,12 @@ export function ConversationDetail({
else
onResolve(
'sent',
`${conversation.replyChannel} → ${conversation.name}(送信プレビュー)\n${draft}`,
`${conversation.replyChannel} → ${conversation.name} (reply preview)\n${draft}`,
)
setReview(null)
}}
>
{review === 'schedule' ? '仮置きを試す' : '送信を試す'}
{review === 'schedule' ? 'Try calendar hold' : 'Try sending'}
</Button>
</DialogFooter>
</DialogContent>
+89 -63
View File
@@ -27,159 +27,185 @@ export type Conversation = {
export const conversations: Conversation[] = [
{
id: 'design-review',
name: '青木 遥',
role: 'プロダクトデザイナー',
subject: '来週のレビュー、日程を決める',
name: 'Haruka Aoki',
role: 'Product designer',
subject: 'Schedule next week’s design review',
channels: ['Slack', 'Gmail'],
replyChannel: 'Slack',
updatedAt: '10:42',
deadline: '9月29日 17:00',
deadline: 'Sep 29, 17:00',
summary:
'新しい画面のレビュー依頼。資料はメールで届いていて、Slackで日程の返事を待っている。30分あればよい。',
nextStep: '資料を確認して、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: 'Review the materials and suggest a time for a 30-minute call.',
messages: [
{
id: 'aoki-1',
channel: 'Gmail',
time: '9月27日 16:20',
time: 'Sep 27, 16:20',
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',
channel: 'Slack',
time: '9月28日 10:42',
time: 'Sep 28, 10:42',
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: [
{ 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: [
{
label: '短く',
text: '資料ありがとうございます。確認します! 来週の午後で、30分ほどお話しできればと思います。',
label: 'Brief',
text: 'Thanks for the materials! I will take a look. Let’s find 30 minutes one afternoon next week.',
},
{
label: '丁寧に',
text: '資料をご共有いただきありがとうございます。初回の導線を中心に確認いたします。来週の午後に30分ほどお時間をいただけますでしょうか。',
label: 'Polite',
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: 'まず受け取る',
text: 'ご連絡ありがとうございます。資料、受け取りました。日程を確認して9月29日17時までにお返事します。',
label: 'Acknowledge',
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',
name: '森 直人',
role: '友人',
subject: '久しぶりに、お茶でも',
name: 'Naoto Mori',
role: 'Friend',
subject: 'Catch up over coffee',
channels: ['LINE'],
replyChannel: 'LINE',
updatedAt: '昨日',
updatedAt: 'Yesterday',
summary:
'近くに来るのでお茶をしようという誘い。急ぎの返事は求められていない。',
nextStep: '会いたければ都合を返す。今決めなくてもよい。',
'An invitation to meet for coffee while Naoto is nearby. There is no rush to reply.',
nextStep:
'Share your availability if you would like to meet. You do not need to decide right now.',
messages: [
{
id: 'mori-1',
channel: 'LINE',
time: '9月27日 19:15',
time: 'Sep 27, 19:15',
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: [
{
label: '短く',
text: 'いいね、久しぶりに会いたい! 土曜の午後で予定見てみるね。',
label: 'Brief',
text: 'Sounds good, it would be great to catch up! I will check Saturday afternoon.',
},
{
label: '丁寧に',
text: '誘ってくれてありがとう! ぜひお茶したいです。土曜の午後、都合を確認して連絡するね。',
label: 'Polite',
text: 'Thanks for the invitation! I would love to grab coffee. I will check my plans for Saturday afternoon and let you know.',
},
{
label: 'まず受け取る',
text: 'ありがとう! まだ予定が見えないので、わかったら連絡するね。',
label: 'Acknowledge',
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',
name: '藤井 理沙',
role: '制作パートナー',
subject: '見積もりの前提を確認する',
name: 'Risa Fujii',
role: 'Project partner',
subject: 'Confirm the scope for an estimate',
channels: ['Gmail'],
replyChannel: 'Gmail',
updatedAt: '9:10',
deadline: '9月30日 12:00',
deadline: 'Sep 30, 12:00',
summary:
'見積もりを進めるため、対象画面が3画面でよいか確認が届いている。回答期限は9月30日の正午。',
nextStep: '対象画面数を確認して返答する。未確認のまま確約しない。',
'Risa needs confirmation that the project covers three screens before preparing an estimate. Please reply by noon on September 30.',
nextStep:
'Confirm the number of screens before replying. Avoid committing before checking.',
messages: [
{
id: 'fujii-1',
channel: 'Gmail',
time: '9月28日 9:10',
time: 'Sep 28, 9:10',
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: [
{ text: '対象画面数の確認後に概算を作成する予定。', sourceId: 'fujii-1' },
{
text: 'The estimate will be prepared after the screen count is confirmed.',
sourceId: 'fujii-1',
},
],
drafts: [
{
label: '短く',
text: 'ありがとうございます。対象の3画面について確認し、9月30日正午までにお返事します。',
label: 'Brief',
text: 'Thanks. I will check the three screens and reply by noon on September 30.',
},
{
label: '丁寧に',
text: 'ご確認ありがとうございます。一覧・詳細・設定の3画面で相違ないか確認のうえ、9月30日正午までに回答いたします。',
label: 'Polite',
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: 'まず受け取る',
text: 'ご連絡を受け取りました。対象範囲を確認して、改めてご連絡します。',
label: 'Acknowledge',
text: 'Thanks for your message. I will check the scope and get back to you.',
},
],
scheduleOptions: [],
},
{
id: 'reading-note',
name: '田辺 悠',
role: '勉強会の仲間',
subject: '前に話していた記事',
name: 'Yu Tanabe',
role: 'Study group friend',
subject: 'The article we talked about',
channels: ['Slack'],
replyChannel: 'Slack',
updatedAt: '金曜日',
updatedAt: 'Friday',
summary:
'前の勉強会で話題になった記事を共有してくれた。質問や返答の依頼はない。',
nextStep: '読む時間がなければ、返信せず閉じてよい。',
'Yu shared an article discussed at the last study group. There are no questions or requests for a reply.',
nextStep:
'If you do not have time to read it, you can close this without replying.',
messages: [
{
id: 'tanabe-1',
channel: 'Slack',
time: '9月25日 18:30',
time: 'Sep 25, 18:30',
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: [
{ text: 'この共有への返信は不要と明記している。', sourceId: 'tanabe-1' },
{
text: 'This message explicitly says no reply is needed.',
sourceId: 'tanabe-1',
},
],
drafts: [
{ label: '短く', text: '共有ありがとう! あとで読んでみるね。' },
{
label: '丁寧に',
text: '覚えていてくれてありがとう。ちょうど気になっていた話なので、時間を取って読んでみます。',
label: 'Brief',
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: [],
},
+21 -19
View File
@@ -20,11 +20,11 @@ import { conversations } from './mock-conversations'
import './support.css'
const views = [
{ id: 'now', label: '今' },
{ id: 'later', label: 'あとで' },
{ id: 'waiting', label: '相手待ち' },
{ id: 'done', label: '履歴' },
{ id: 'all', label: 'すべて' },
{ id: 'now', label: 'Now' },
{ id: 'later', label: 'Later' },
{ id: 'waiting', label: 'Waiting' },
{ id: 'done', label: 'History' },
{ id: 'all', label: 'All' },
] as const
export function SupportPage() {
const [view, setView] = useState<string>('now')
@@ -50,12 +50,12 @@ export function SupportPage() {
if (note) setOutcomes((current) => ({ ...current, [id]: note }))
setNotice(
status === 'sent'
? '返信の操作を記録しました'
? 'Reply preview recorded'
: status === 'later'
? 'あとでに移しました'
? 'Moved to Later'
: status === 'waiting'
? '相手待ちに移しました'
: '対応不要にしました',
? 'Moved to Waiting'
: 'Marked as no action needed',
)
setReading(false)
}
@@ -66,7 +66,7 @@ export function SupportPage() {
<WorkspaceNavigation />
<Sidebar collapsible="none" className="support-list">
<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">
{views.map((tab) => (
<Button
@@ -88,8 +88,8 @@ export function SupportPage() {
<div className="relative">
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
<Input
aria-label="連絡を検索"
placeholder="検索"
aria-label="Search messages"
placeholder="Search"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -116,12 +116,14 @@ export function SupportPage() {
))}
{!visible.length && (
<p className="p-5 text-sm text-muted-foreground">
{query ? '見つかりませんでした' : 'いま扱う連絡はありません'}
{query
? 'No results found'
: 'No messages to handle right now'}
</p>
)}
{view === 'now' && matches.length > 3 && (
<p className="p-5 text-xs text-muted-foreground">
ほか {matches.length - 3} 件
{matches.length - 3} more
</p>
)}
</SidebarContent>
@@ -133,7 +135,7 @@ export function SupportPage() {
variant="ghost"
size="icon"
className="support-back"
aria-label="連絡一覧に戻る"
aria-label="Back to messages"
onClick={() => setReading(false)}
>
<ArrowLeft />
@@ -148,7 +150,7 @@ export function SupportPage() {
</header>
<section
className="support-detail"
aria-label="連絡の詳細"
aria-label="Message details"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Independent region needs keyboard scrolling.
tabIndex={0}
>
@@ -169,7 +171,7 @@ export function SupportPage() {
<>
{outcomes[selected.id] && (
<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">
{outcomes[selected.id]}
</p>
@@ -187,12 +189,12 @@ export function SupportPage() {
setView('now')
}}
>
今に戻す
Move to Now
</Button>
)}
</>
) : (
<div className="support-empty">連絡を選択</div>
<div className="support-empty">Select a conversation</div>
)}
</section>
</SidebarInset>
+7 -6
View File
@@ -74,7 +74,7 @@ export function VitalsPage() {
<button
className="vital-mobile-close"
type="button"
aria-label="メニューを閉じる"
aria-label="Close menu"
onClick={() => setMobileMenu(false)}
>
<X />
@@ -118,7 +118,7 @@ export function VitalsPage() {
<button
className="vital-mobile-toggle"
type="button"
aria-label="Healthメニューを開く"
aria-label="Open Health menu"
onClick={() => setMobileMenu(true)}
>
<Menu />
@@ -131,7 +131,7 @@ export function VitalsPage() {
</span>
<button
type="button"
aria-label="通知を開く"
aria-label="Open notifications"
onClick={() => setDetail('Notifications')}
>
<Bell />
@@ -308,7 +308,7 @@ export function VitalsPage() {
<div
className="vital-anatomy"
role="img"
aria-label="人体と各系統の位置を示す参照図"
aria-label="Reference illustration of the body and its systems"
>
<img src="/vitals/body-reference.jpg" alt="" />
</div>
@@ -399,7 +399,8 @@ export function VitalsPage() {
onClose={() => setDetail(undefined)}
>
<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>
{systems.find((item) => item.name === detail) && (
<p className="text-3xl">
@@ -410,7 +411,7 @@ export function VitalsPage() {
</p>
)}
<Button variant="outline" onClick={() => setDetail(undefined)}>
閉じる
Close
</Button>
</Dialog>
)}
+11 -4
View File
@@ -18,6 +18,13 @@ export type Task = {
export type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
export type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
export const entryKindLabels: Record<EntryKind, string> = {
メモ: 'Note',
再開地点: 'Resume point',
服薬: 'Medication',
体重: 'Weight',
}
export type JournalEntry = {
id: string
kind: EntryKind
@@ -45,18 +52,18 @@ export function WorkspaceStateProvider({ children }: { children: ReactNode }) {
const [tasks, setTasks] = useState<Task[]>([
{
id: 'review',
title: 'レビュー資料を確認する',
title: 'Review the documents',
section: 'focus',
status: 'todo',
},
{
id: 'morning',
title: '朝の支度',
title: 'Morning routine',
section: 'routine',
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 [outcomes, setOutcomes] = useState<Record<string, string>>({})
+1 -1
View File
@@ -43,7 +43,7 @@ function RootContent() {
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" className="dark">
<html lang="en" className="dark">
<head>
<HeadContent />
</head>