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
+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