feat: add URL-driven workspace state and streamed Codex research

This commit is contained in:
2026-09-28 20:08:28 +09:00
parent 85d23a328b
commit 8085ad2f90
207 changed files with 15287 additions and 16026 deletions
+18 -39
View File
@@ -1,64 +1,43 @@
import { conversations } from '#/features/support/mock-conversations'
import { useWorkspaceState } from '#/features/workspace/workspace-state'
import { conversations } from "#/features/support/mock-conversations";
import { useWorkspaceState } from "#/features/workspace/workspace-state";
export function DailyBrief({
onOpenContact,
}: {
onOpenContact: (id: string) => void
}) {
const { tasks, statuses, entries } = useWorkspaceState()
const focus = tasks.find(
(task) => task.section === 'focus' && task.status === 'todo',
)
const optional = tasks.find(
(task) => task.section === 'optional' && task.status === 'todo',
)
const contacts = conversations.filter(
(person) => (statuses[person.id] ?? 'now') === 'now',
)
const firstContact = contacts[0]
export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => void }) {
const { tasks, statuses, entries } = useWorkspaceState();
const focus = tasks.find((task) => task.section === "focus" && task.status === "todo");
const optional = tasks.find((task) => task.section === "optional" && task.status === "todo");
const contacts = conversations.filter((person) => (statuses[person.id] ?? "now") === "now");
const firstContact = contacts[0];
const resume = entries.find(
(entry) =>
entry.kind === '再開地点' &&
!entries.some((other) => other.correctionOf === entry.id),
)
entry.kind === "再開地点" && !entries.some((other) => other.correctionOf === entry.id),
);
return (
<section className="home-brief" aria-label="Daily brief">
<p>
{focus
? `Start with “${focus.title}” today.`
: 'There are no priority tasks right now.'}
{focus ? `Start with “${focus.title}” today.` : "There are no priority tasks right now."}
{firstContact ? (
<>
{' '}
<button
type="button"
onClick={() => onOpenContact(firstContact.id)}
>
{" "}
<button type="button" onClick={() => onOpenContact(firstContact.id)}>
Reply to {firstContact.name}
</button>
{contacts.length > 1
? ` — one of ${contacts.length} conversations to review.`
: ' when you’re ready.'}
: " when you’re ready."}
</>
) : (
' There are no conversations awaiting your attention.'
" 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}
”
{resume.text.length > 100 ? `${resume.text.slice(0, 100)}…` : resume.text}”
</p>
) : optional ? (
<p className="home-brief-secondary">
“{optional.title}” can wait until you have room.
</p>
<p className="home-brief-secondary">“{optional.title}” can wait until you have room.</p>
) : null}
</section>
)
);
}
+143 -207
View File
@@ -1,4 +1,4 @@
import { Link, useNavigate, useRouteContext } from '@tanstack/react-router'
import { Link, useNavigate, useRouteContext } from "@tanstack/react-router";
import {
ArrowDown,
ArrowUp,
@@ -11,161 +11,146 @@ import {
Plus,
RotateCcw,
X,
} from 'lucide-react'
import { useRef, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar'
import { Textarea } from '#/components/ui/textarea'
import { TooltipProvider } from '#/components/ui/tooltip'
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 {
entryKindLabels,
useWorkspaceState,
} from '#/features/workspace/workspace-state'
import { DailyBrief } from './daily-brief'
import { type HomeAction, interpretAction } from './interpret-action'
import './home.css'
} from "lucide-react";
import { useRef, useState } from "react";
import { Dialog } from "#/components/dialog";
import { Button } from "#/components/ui/button";
import { Checkbox } from "#/components/ui/checkbox";
import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar";
import { Textarea } from "#/components/ui/textarea";
import { TooltipProvider } from "#/components/ui/tooltip";
import { WorkspaceNavigation } from "#/components/workspace-navigation";
import { articles } from "#/features/inbox/mock-articles";
import { useUrlState } from "#/features/navigation/use-url-state";
import { ConversationDetail } from "#/features/support/conversation-detail";
import { conversations } from "#/features/support/mock-conversations";
import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state";
import { DailyBrief } from "./daily-brief";
import { type HomeAction, interpretAction } from "./interpret-action";
export function HomePage() {
const { auth } = useRouteContext({ from: '__root__' })
const {
tasks,
setTasks,
statuses,
setStatuses,
outcomes,
setOutcomes,
entries,
setEntries,
} = useWorkspaceState()
const navigate = useNavigate()
const input = useRef<HTMLTextAreaElement>(null)
const [prompt, setPrompt] = useState('')
const { auth } = useRouteContext({ from: "__root__" });
const { tasks, setTasks, statuses, setStatuses, outcomes, setOutcomes, entries, setEntries } =
useWorkspaceState();
const navigate = useNavigate();
const input = useRef<HTMLTextAreaElement>(null);
const [prompt, setPrompt] = useState("");
const [proposal, setProposal] = useState<{
action: HomeAction
label: string
} | null>(null)
const [notice, setNotice] = useState('')
const [contactId, setContactId] = useState<string>()
const [articleId, setArticleId] = useState<string>()
const contact = conversations.find((item) => item.id === contactId)
const article = articles.find((item) => item.id === articleId)
const waiting = conversations.filter(
(item) => (statuses[item.id] ?? 'now') === 'now',
)
action: HomeAction;
label: string;
} | null>(null);
const [notice, setNotice] = useState("");
const [search, updateSearch] = useUrlState();
const contactId = search.conversation;
const articleId = search.article;
const setContactId = (id: string | undefined) =>
updateSearch({ conversation: id, article: undefined });
const setArticleId = (id: string | undefined) =>
updateSearch({ article: id, conversation: undefined });
const contact = conversations.find((item) => item.id === contactId);
const article = articles.find((item) => item.id === articleId);
const waiting = conversations.filter((item) => (statuses[item.id] ?? "now") === "now");
const currentEntries = entries.filter(
(entry) => !entries.some((other) => other.correctionOf === entry.id),
)
const rest = tasks.filter((task) => task.status !== 'todo')
);
const rest = tasks.filter((task) => task.status !== "todo");
function suggest(text: string) {
setPrompt(text)
setProposal(null)
input.current?.focus()
setPrompt(text);
setProposal(null);
input.current?.focus();
}
function interpret() {
const result = interpretAction(prompt, tasks, conversations)
if ('error' in result) {
setNotice(result.error)
setProposal(null)
const result = interpretAction(prompt, tasks, conversations);
if ("error" in result) {
setNotice(result.error);
setProposal(null);
} else {
setProposal(result)
setNotice('')
setProposal(result);
setNotice("");
}
}
function apply(action: HomeAction) {
switch (action.type) {
case 'add-task':
case "add-task":
setTasks((current) => [
...current,
{
id: crypto.randomUUID(),
title: action.title,
section: action.section,
status: 'todo',
status: "todo",
},
])
break
case 'task-status':
]);
break;
case "task-status":
setTasks((current) =>
current.map((task) =>
task.id === action.id ? { ...task, status: action.status } : task,
),
)
break
case 'task-move':
moveTask(action.id, action.direction)
break
case 'routine-step':
);
break;
case "task-move":
moveTask(action.id, action.direction);
break;
case "routine-step":
setTasks((current) =>
current.map((task) =>
task.id === action.id
? {
...task,
completedSteps: action.done
? Array.from(
new Set([...(task.completedSteps ?? []), action.step]),
)
: (task.completedSteps ?? []).filter(
(step) => step !== action.step,
),
? Array.from(new Set([...(task.completedSteps ?? []), action.step]))
: (task.completedSteps ?? []).filter((step) => step !== action.step),
}
: task,
),
)
break
case 'contact':
setContactId(action.id)
break
case 'contact-status':
setStatuses((current) => ({ ...current, [action.id]: action.status }))
break
case 'journal':
);
break;
case "contact":
setContactId(action.id);
break;
case "contact-status":
setStatuses((current) => ({ ...current, [action.id]: action.status }));
break;
case "journal":
setEntries((current) => [
{
id: crypto.randomUUID(),
kind: action.kind,
text: action.text,
source: '',
occurredAt: '',
source: "",
occurredAt: "",
recordedAt: new Date().toISOString(),
},
...current,
])
break
case 'navigate':
void navigate({ to: action.to })
break
]);
break;
case "navigate":
void navigate({ to: action.to });
break;
}
setNotice(proposal?.label ?? 'Updated')
setProposal(null)
setPrompt('')
setNotice(proposal?.label ?? "Updated");
setProposal(null);
setPrompt("");
}
function moveTask(id: string, direction: -1 | 1) {
setTasks((current) => {
const index = current.findIndex((task) => task.id === id)
const index = current.findIndex((task) => task.id === id);
const siblings = current
.map((task, i) => ({ task, i }))
.filter(
({ task }) =>
task.section === current[index]?.section && task.status === 'todo',
)
const position = siblings.findIndex(({ task }) => task.id === id)
const target = siblings[position + direction]?.i
if (target === undefined) return current
const sourceTask = current[index]
const targetTask = current[target]
if (!sourceTask || !targetTask) return current
const updated = [...current]
updated[index] = targetTask
updated[target] = sourceTask
return updated
})
.filter(({ task }) => task.section === current[index]?.section && task.status === "todo");
const position = siblings.findIndex(({ task }) => task.id === id);
const target = siblings[position + direction]?.i;
if (target === undefined) return current;
const sourceTask = current[index];
const targetTask = current[target];
if (!sourceTask || !targetTask) return current;
const updated = [...current];
updated[index] = targetTask;
updated[target] = sourceTask;
return updated;
});
}
return (
<TooltipProvider delay={500}>
@@ -182,8 +167,8 @@ export function HomePage() {
<form
className="home-composer"
onSubmit={(event) => {
event.preventDefault()
interpret()
event.preventDefault();
interpret();
}}
>
<Textarea
@@ -192,25 +177,23 @@ export function HomePage() {
placeholder="What’s on your mind?"
value={prompt}
onChange={(event) => {
setPrompt(event.target.value)
setProposal(null)
setNotice('')
setPrompt(event.target.value);
setProposal(null);
setNotice("");
}}
onKeyDown={(event) => {
if (
(event.metaKey || event.ctrlKey) &&
event.key === 'Enter' &&
event.key === "Enter" &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
interpret()
event.preventDefault();
interpret();
}
}}
/>
<div className="home-composer-footer">
<span className="text-xs text-muted-foreground">
Tasks, replies, notes.
</span>
<span className="text-xs text-muted-foreground">Tasks, replies, notes.</span>
<Button
type="submit"
size="icon"
@@ -222,16 +205,8 @@ export function HomePage() {
</div>
</form>
<div className="home-suggestions">
{[
'Add grocery shopping',
'Reply to Aoki',
'Note: Resume on page 2',
].map((text) => (
<button
type="button"
key={text}
onClick={() => suggest(text)}
>
{["Add grocery shopping", "Reply to Aoki", "Note: Resume on page 2"].map((text) => (
<button type="button" key={text} onClick={() => suggest(text)}>
{text}
<ArrowUpRight className="size-3" />
</button>
@@ -255,12 +230,12 @@ export function HomePage() {
{notice}
</p>
<div className="home-columns">
<section className="home-plan" aria-label="Today’s plan">
<section aria-label="Today’s plan">
{(
[
{ id: 'focus', label: 'Start here' },
{ id: 'routine', label: 'Routines' },
{ id: 'optional', label: 'If there’s room' },
{ 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">
@@ -272,11 +247,11 @@ export function HomePage() {
aria-label={`Add to ${section.label}`}
onClick={() =>
suggest(
section.id === 'routine'
? 'Add routine:'
: section.id === 'optional'
? 'Optional: '
: 'Add:',
section.id === "routine"
? "Add routine:"
: section.id === "optional"
? "Optional: "
: "Add:",
)
}
>
@@ -284,11 +259,7 @@ export function HomePage() {
</Button>
</header>
{tasks
.filter(
(task) =>
task.section === section.id &&
task.status === 'todo',
)
.filter((task) => task.section === section.id && task.status === "todo")
.map((task) => (
<div className="home-task" key={task.id}>
<button
@@ -298,9 +269,7 @@ export function HomePage() {
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'done' }
: item,
item.id === task.id ? { ...item, status: "done" } : item,
),
)
}
@@ -321,11 +290,7 @@ export function HomePage() {
>
<Checkbox
id={`step-${task.id}-${step}`}
checked={
task.completedSteps?.includes(
step,
) ?? false
}
checked={task.completedSteps?.includes(step) ?? false}
onCheckedChange={(checked) =>
setTasks((current) =>
current.map((item) =>
@@ -333,17 +298,9 @@ export function HomePage() {
? {
...item,
completedSteps: checked
? [
...(item.completedSteps ??
[]),
step,
]
: (
item.completedSteps ??
[]
).filter(
(value) =>
value !== step,
? [...(item.completedSteps ?? []), step]
: (item.completedSteps ?? []).filter(
(value) => value !== step,
),
}
: item,
@@ -382,9 +339,7 @@ export function HomePage() {
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'later' }
: item,
item.id === task.id ? { ...item, status: "later" } : item,
),
)
}
@@ -394,7 +349,7 @@ export function HomePage() {
</div>
</div>
))}
{section.id === 'focus' &&
{section.id === "focus" &&
waiting.slice(0, 2).map((item) => (
<div className="home-task" key={item.id}>
<MessagesSquare className="size-5 text-muted-foreground mt-1" />
@@ -403,12 +358,8 @@ export function HomePage() {
className="home-task-body text-left"
onClick={() => setContactId(item.id)}
>
<span className="block">
Reply to {item.name}
</span>
<span className="text-xs text-muted-foreground">
{item.subject}
</span>
<span className="block">Reply to {item.name}</span>
<span className="text-xs text-muted-foreground">{item.subject}</span>
</button>
<Button
variant="ghost"
@@ -421,13 +372,10 @@ export function HomePage() {
</div>
))}
{!tasks.some(
(task) =>
task.section === section.id && task.status === 'todo',
(task) => task.section === section.id && task.status === "todo",
) &&
(section.id !== 'focus' || !waiting.length) && (
<p className="home-empty">
It’s okay to leave some room.
</p>
(section.id !== "focus" || !waiting.length) && (
<p className="home-empty">It’s okay to leave some room.</p>
)}
</section>
))}
@@ -438,7 +386,7 @@ export function HomePage() {
</summary>
{rest.map((task) => (
<div className="home-task" key={task.id}>
{task.status === 'done' ? (
{task.status === "done" ? (
<Check className="size-4" />
) : (
<Coffee className="size-4" />
@@ -451,9 +399,7 @@ export function HomePage() {
onClick={() =>
setTasks((current) =>
current.map((item) =>
item.id === task.id
? { ...item, status: 'todo' }
: item,
item.id === task.id ? { ...item, status: "todo" } : item,
),
)
}
@@ -465,7 +411,7 @@ export function HomePage() {
</details>
)}
</section>
<aside className="home-context" aria-label="Today’s context">
<aside aria-label="Today’s context">
<section className="home-section">
<header className="home-section-heading">
<h2>Messages</h2>
@@ -474,12 +420,7 @@ export function HomePage() {
</Link>
</header>
{conversations
.filter(
(item) =>
!['sent', 'dismissed'].includes(
statuses[item.id] ?? 'now',
),
)
.filter((item) => !["sent", "dismissed"].includes(statuses[item.id] ?? "now"))
.map((item) => (
<button
className="home-context-row"
@@ -489,11 +430,11 @@ 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(' / ')}
{statuses[item.id] === "waiting"
? "Waiting"
: statuses[item.id] === "later"
? "Later"
: item.channels.join(" / ")}
</span>
</button>
))}
@@ -512,7 +453,7 @@ export function HomePage() {
</span>
<span>
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
{entry.kind === "体重" ? " kg" : ""}
</span>
</p>
))}
@@ -520,7 +461,7 @@ export function HomePage() {
<button
type="button"
className="home-context-row"
onClick={() => suggest('Note:')}
onClick={() => suggest("Note:")}
>
<span>Leave a note</span>
<Plus className="size-4" />
@@ -541,9 +482,7 @@ export function HomePage() {
key={item.id}
onClick={() => setArticleId(item.id)}
>
<span className="text-xs text-muted-foreground">
{item.source}
</span>
<span className="text-xs text-muted-foreground">{item.source}</span>
<span>{item.title}</span>
</button>
))}
@@ -566,16 +505,13 @@ export function HomePage() {
<ConversationDetail
conversation={contact}
onResolve={(status, note) => {
setStatuses((current) => ({ ...current, [contact.id]: status }))
if (note)
setOutcomes((current) => ({ ...current, [contact.id]: note }))
setContactId(undefined)
setStatuses((current) => ({ ...current, [contact.id]: status }));
if (note) setOutcomes((current) => ({ ...current, [contact.id]: note }));
setContactId(undefined);
}}
/>
{outcomes[contact.id] && (
<p className="text-sm whitespace-pre-wrap">
{outcomes[contact.id]}
</p>
<p className="text-sm whitespace-pre-wrap">{outcomes[contact.id]}</p>
)}
</Dialog>
)}
@@ -595,5 +531,5 @@ export function HomePage() {
)}
</SidebarProvider>
</TooltipProvider>
)
);
}
+135 -179
View File
@@ -1,210 +1,166 @@
import { describe, expect, it } from 'vitest'
import { interpretAction } from './interpret-action'
import { describe, expect, it } from "vitest";
import { interpretAction } from "./interpret-action";
const tasks = [
{ id: 'stretch', title: 'ストレッチ' },
{ id: 'shop', title: '買い物' },
]
{ id: "stretch", title: "ストレッチ" },
{ id: "shop", title: "買い物" },
];
const contacts = [
{ id: 'aoki', name: '青木 遥' },
{ id: 'mori', name: '森 直人' },
]
{ id: "aoki", name: "青木 遥" },
{ id: "mori", name: "森 直人" },
];
describe('home natural-language proposals', () => {
describe("home natural-language proposals", () => {
it.each([
['買い物を上に移動', -1],
['買い物を下に動かして', 1],
])('moves the named task by one position: %s', (input, direction) => {
["買い物を上に移動", -1],
["買い物を下に動かして", 1],
])("moves the named task by one position: %s", (input, direction) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'task-move', id: 'shop', direction },
})
})
action: { type: "task-move", id: "shop", direction },
});
});
it('does not reorder a partial task match', () => {
expect(interpretAction('買いを上に移動', tasks, contacts)).toHaveProperty(
'error',
)
})
it("does not reorder a partial task match", () => {
expect(interpretAction("買いを上に移動", tasks, contacts)).toHaveProperty("error");
});
it.each([
['朝の支度の水を飲むを完了', true],
['朝の支度の水を飲むを未完了にして', false],
])('targets a named routine step: %s', (input, done) => {
["朝の支度の水を飲むを完了", true],
["朝の支度の水を飲むを未完了にして", false],
])("targets a named routine step: %s", (input, done) => {
expect(
interpretAction(
input,
[{ id: 'morning', title: '朝の支度', steps: ['水を飲む', '着替える'] }],
[{ id: "morning", title: "朝の支度", steps: ["水を飲む", "着替える"] }],
contacts,
),
).toMatchObject({
action: { type: 'routine-step', id: 'morning', step: '水を飲む', done },
})
})
action: { type: "routine-step", id: "morning", step: "水を飲む", done },
});
});
it('rejects unknown and ambiguous routine steps', () => {
const routines = [
{ id: 'morning', title: '朝の支度', steps: ['水を飲む', '水を飲む'] },
]
it("rejects unknown and ambiguous routine steps", () => {
const routines = [{ id: "morning", title: "朝の支度", steps: ["水を飲む", "水を飲む"] }];
expect(interpretAction("朝の支度の水を飲むを完了", routines, contacts)).toHaveProperty("error");
expect(interpretAction("朝の支度の着替えるを完了", routines, contacts)).toHaveProperty("error");
});
it.each([
["買い物を追加", "買い物", "focus"],
["追加:買い物", "買い物", "focus"],
["ルーティンに追加:夜のストレッチ", "夜のストレッチ", "routine"],
["夜のストレッチをルーティンに追加", "夜のストレッチ", "routine"],
["余裕があれば本を読む", "本を読む", "optional"],
])("proposes %s without mutating existing tasks", (input, title, section) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: "add-task", title, section },
});
expect(tasks).toHaveLength(2);
});
it.each([
["ストレッチを完了", "done"],
["ストレッチは終わった", "done"],
["ストレッチをあとで", "later"],
["ストレッチを保留", "later"],
["ストレッチを未完了にして", "todo"],
])("matches the whole task title for %s", (input, status) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: "task-status", id: "stretch", status },
});
});
it("rejects partial and duplicate task names", () => {
expect(interpretAction("買いを完了", tasks, contacts)).toHaveProperty("error");
expect(
interpretAction('朝の支度の水を飲むを完了', routines, contacts),
).toHaveProperty('error')
interpretAction("買い物を完了", [...tasks, { id: "duplicate", title: "買い物" }], contacts),
).toHaveProperty("error");
});
it("opens the uniquely named contact without sending", () => {
expect(interpretAction("青木さんに返信", tasks, contacts)).toMatchObject({
action: { type: "contact", id: "aoki" },
});
expect(interpretAction("青木遥に返信", tasks, contacts)).toMatchObject({
action: { type: "contact", id: "aoki" },
});
});
it("rejects ambiguous surnames", () => {
expect(
interpretAction('朝の支度の着替えるを完了', routines, contacts),
).toHaveProperty('error')
})
interpretAction("青木さんに返信", tasks, [...contacts, { id: "other", name: "青木 太郎" }]),
).toHaveProperty("error");
});
it.each([
['買い物を追加', '買い物', 'focus'],
['追加:買い物', '買い物', 'focus'],
['ルーティンに追加:夜のストレッチ', '夜のストレッチ', 'routine'],
['夜のストレッチをルーティンに追加', '夜のストレッチ', 'routine'],
['余裕があれば本を読む', '本を読む', 'optional'],
])('proposes %s without mutating existing tasks', (input, title, section) => {
["森さんを相手待ちにして", "waiting"],
["森さんを保留", "later"],
])("changes only the named contact for %s", (input, status) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'add-task', title, section },
})
expect(tasks).toHaveLength(2)
})
action: { type: "contact-status", id: "mori", status },
});
});
it.each([
['ストレッチを完了', 'done'],
['ストレッチは終わった', 'done'],
['ストレッチをあとで', 'later'],
['ストレッチを保留', 'later'],
['ストレッチを未完了にして', 'todo'],
])('matches the whole task title for %s', (input, status) => {
["メモ:散歩でアイデアを思いついた", "メモ", "散歩でアイデアを思いついた"],
["再開地点:レビューの続き", "再開地点", "レビューの続き"],
["体重は65.2kg", "体重", "65.2"],
["薬を飲んだ", "服薬", "Took medication"],
])("records explicit user statements: %s", (input, kind, text) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'task-status', id: 'stretch', status },
})
})
it('rejects partial and duplicate task names', () => {
expect(interpretAction('買いを完了', tasks, contacts)).toHaveProperty(
'error',
)
expect(
interpretAction(
'買い物を完了',
[...tasks, { id: 'duplicate', title: '買い物' }],
contacts,
),
).toHaveProperty('error')
})
it('opens the uniquely named contact without sending', () => {
expect(interpretAction('青木さんに返信', tasks, contacts)).toMatchObject({
action: { type: 'contact', id: 'aoki' },
})
expect(interpretAction('青木遥に返信', tasks, contacts)).toMatchObject({
action: { type: 'contact', id: 'aoki' },
})
})
it('rejects ambiguous surnames', () => {
expect(
interpretAction('青木さんに返信', tasks, [
...contacts,
{ id: 'other', name: '青木 太郎' },
]),
).toHaveProperty('error')
})
action: { type: "journal", kind, text },
});
});
it.each([
['森さんを相手待ちにして', 'waiting'],
['森さんを保留', 'later'],
])('changes only the named contact for %s', (input, status) => {
"ストレッチを完了にしないで",
"買い物を追加しない",
"青木さんに送信",
"青木さんに返信を送って",
"薬を何錠飲めばいい",
"体重を記録",
"適当に片付けて",
"toString",
"買い物を完了、ストレッチを追加",
"買い物を完了して、ストレッチを追加",
])("leaves unsupported, negative, and compound requests unchanged: %s", (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty("error");
});
it.each([
["連絡を開いて", "/support"],
["記録", "/journal"],
["リーダーを開く", "/inbox"],
["Researchに移動", "/deck"],
])("recognizes navigation: %s", (input, to) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'contact-status', id: 'mori', status },
})
})
action: { type: "navigate", to },
});
});
});
describe("English home commands", () => {
const tasks = [{ id: "shop", title: "grocery shopping" }];
const contacts = [{ id: "aoki", name: "Haruka Aoki" }];
it.each([
['メモ:散歩でアイデアを思いついた', 'メモ', '散歩でアイデアを思いついた'],
['再開地点:レビューの続き', '再開地点', 'レビューの続き'],
['体重は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 },
})
})
it.each([
'ストレッチを完了にしないで',
'買い物を追加しない',
'青木さんに送信',
'青木さんに返信を送って',
'薬を何錠飲めばいい',
'体重を記録',
'適当に片付けて',
'toString',
'買い物を完了、ストレッチを追加',
'買い物を完了して、ストレッチを追加',
])('leaves unsupported, negative, and compound requests unchanged: %s', (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty('error')
})
it.each([
['連絡を開いて', '/support'],
['記録', '/journal'],
['リーダーを開く', '/inbox'],
['Researchに移動', '/deck'],
])('recognizes navigation: %s', (input, to) => {
expect(interpretAction(input, tasks, contacts)).toMatchObject({
action: { type: 'navigate', to },
})
})
})
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) => {
["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')
})
})
});
});
it.each(["Don't complete grocery shopping", "Send a reply to Aoki"])("rejects %s", (input) => {
expect(interpretAction(input, tasks, contacts)).toHaveProperty("error");
});
});
+125 -145
View File
@@ -1,27 +1,27 @@
export type HomeAction =
| {
type: 'add-task'
title: string
section: 'focus' | 'routine' | 'optional'
type: "add-task";
title: string;
section: "focus" | "routine" | "optional";
}
| { type: 'task-status'; id: string; status: 'todo' | 'done' | 'later' }
| { type: 'task-move'; id: string; direction: -1 | 1 }
| { type: 'routine-step'; id: string; step: string; done: boolean }
| { type: 'contact'; id: string }
| { type: 'contact-status'; id: string; status: 'later' | 'waiting' }
| { type: "task-status"; id: string; status: "todo" | "done" | "later" }
| { type: "task-move"; id: string; direction: -1 | 1 }
| { type: "routine-step"; id: string; step: string; done: boolean }
| { type: "contact"; id: string }
| { type: "contact-status"; id: string; status: "later" | "waiting" }
| {
type: 'journal'
kind: 'メモ' | '再開地点' | '服薬' | '体重'
text: string
type: "journal";
kind: "メモ" | "再開地点" | "服薬" | "体重";
text: string;
}
| { type: 'navigate'; to: '/support' | '/journal' | '/inbox' | '/deck' }
| { type: "navigate"; to: "/support" | "/journal" | "/inbox" | "/deck" };
type Interpretation = { action: HomeAction; label: string } | { error: string }
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”.',
}
"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. */
export function interpretAction(
@@ -31,227 +31,207 @@ export function interpretAction(
): Interpretation {
let text = input
.trim()
.replace(/[。!!]$/, '')
.trim()
.replace(/[。!!]$/, "")
.trim();
// 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.' }
return { error: "Please review this action in the relevant screen." };
text = text
.replace(
/^open (messages|journal|reader|research|deck)$/i,
(_, name: string) =>
({
messages: '連絡',
journal: '記録',
reader: 'リーダー',
research: 'Research',
deck: 'デッキ',
messages: "連絡",
journal: "記録",
reader: "リーダー",
research: "Research",
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(/^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' ? '上' : '下'}に移動`,
`${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.' }
.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: 'Please review this action in the relevant screen.',
}
error: "Please review this action in the relevant screen.",
};
}
const navigation: Record<
string,
'/support' | '/journal' | '/inbox' | '/deck'
> = {
連絡: '/support',
記録: '/journal',
リーダー: '/inbox',
Research: '/deck',
デッキ: '/deck',
}
const destination = text.replace(/(?:を開いて|を開く|に移動)$/, '')
const route = Object.hasOwn(navigation, destination)
? navigation[destination]
: undefined
const navigation: Record<string, "/support" | "/journal" | "/inbox" | "/deck"> = {
連絡: "/support",
記録: "/journal",
リーダー: "/inbox",
Research: "/deck",
デッキ: "/deck",
};
const destination = text.replace(/(?:を開いて|を開く|に移動)$/, "");
const route = Object.hasOwn(navigation, destination) ? navigation[destination] : undefined;
if (route)
return {
action: { type: 'navigate', to: route },
label: `Open ${{ '/support': 'Messages', '/journal': 'Journal', '/inbox': 'Reader', '/deck': 'Research' }[route]}`,
}
action: { type: "navigate", to: route },
label: `Open ${{ "/support": "Messages", "/journal": "Journal", "/inbox": "Reader", "/deck": "Research" }[route]}`,
};
const note = text.match(/^(メモ|再開地点)[::\s]+(.+)$/s)
const note = text.match(/^(メモ|再開地点)[::\s]+(.+)$/s);
if (note?.[2]) {
const kind = note[1] === 'メモ' ? 'メモ' : '再開地点'
const body = note[2].trim()
if (!body) return unsupported
const kind = note[1] === "メモ" ? "メモ" : "再開地点";
const body = note[2].trim();
if (!body) return unsupported;
return {
action: { type: 'journal', kind, text: body },
label: `Save ${kind === 'メモ' ? 'note' : 'resume point'}`,
}
action: { type: "journal", kind, text: body },
label: `Save ${kind === "メモ" ? "note" : "resume point"}`,
};
}
const weight = text.match(
/^体重(?:は|を記録[::]?|[::]|\s)*\s*(\d{2,3}(?:\.\d{1,2})?)\s*(?:kg|キロ)(?:を記録|だった|です)?$/i,
)
);
if (weight?.[1])
return {
action: { type: 'journal', kind: '体重', text: weight[1] },
action: { type: "journal", kind: "体重", text: weight[1] },
label: `Log weight: ${weight[1]} kg`,
}
if (
/^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test(
text,
)
) {
};
if (/^(?:薬を飲んだ|服薬した|服薬を記録)(?:ので記録して|と記録して)?$/.test(text)) {
return {
action: { type: 'journal', kind: '服薬', text: 'Took medication' },
label: 'Log medication',
}
action: { type: "journal", kind: "服薬", text: "Took medication" },
label: "Log medication",
};
}
const contact = text.match(
/^(.+?)(?:さん)?(?:に返信(?:したい|する)?|の返信(?:を開いて|を開く)?|を相手待ち(?:にして|にする)?|を保留(?:にして|にする)?)$/,
)
);
if (contact?.[1]) {
const name = contact[1].trim()
const name = contact[1].trim();
const matches = contacts.filter(
(item) =>
item.name === name ||
item.name.replace(/\s/g, '') === name ||
item.name
.split(/\s+/)
.some((part) => part.toLowerCase() === name.toLowerCase()),
)
const matchingTasks = tasks.filter((task) => task.title === name)
const matchingTask = matchingTasks[0]
if (
matches.length === 0 &&
matchingTasks.length === 1 &&
matchingTask &&
/保留/.test(text)
) {
item.name.replace(/\s/g, "") === name ||
item.name.split(/\s+/).some((part) => part.toLowerCase() === name.toLowerCase()),
);
const matchingTasks = tasks.filter((task) => task.title === name);
const matchingTask = matchingTasks[0];
if (matches.length === 0 && matchingTasks.length === 1 && matchingTask && /保留/.test(text)) {
return {
action: {
type: 'task-status',
type: "task-status",
id: matchingTask.id,
status: 'later',
status: "later",
},
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.',
}
error: "This matches both a task and a contact. Please use the relevant screen.",
};
}
const person = matches[0]
const person = matches[0];
if (matches.length !== 1 || !person)
return { error: 'Please enter the full name of one contact.' }
return { error: "Please enter the full name of one contact." };
if (/相手待ち|保留/.test(text)) {
const status = text.includes('相手待ち') ? 'waiting' : 'later'
const status = text.includes("相手待ち") ? "waiting" : "later";
return {
action: { type: 'contact-status', id: person.id, status },
label: `Mark the conversation with ${person.name} as ${status === 'waiting' ? 'waiting' : 'later'}`,
}
action: { type: "contact-status", id: person.id, status },
label: `Mark the conversation with ${person.name} as ${status === "waiting" ? "waiting" : "later"}`,
};
}
return {
action: { type: 'contact', id: person.id },
action: { type: "contact", id: person.id },
label: `Review reply to ${person.name}`,
}
};
}
const move = text.match(/^(.+?)を(上|下)(?:に移動|に動かして|へ|にして)$/)
const move = text.match(/^(.+?)を(上|下)(?:に移動|に動かして|へ|にして)$/);
if (move?.[1]) {
const title = move[1].trim()
const matches = tasks.filter((task) => task.title === title)
const task = matches[0]
const title = move[1].trim();
const matches = tasks.filter((task) => task.title === title);
const task = matches[0];
if (matches.length !== 1 || !task)
return {
error: 'Please use the exact name of one task from the list.',
}
error: "Please use the exact name of one task from the list.",
};
return {
action: {
type: 'task-move',
type: "task-move",
id: task.id,
direction: move[2] === '上' ? -1 : 1,
direction: move[2] === "上" ? -1 : 1,
},
label: `Move ${task.title} ${move[2] === '上' ? 'up' : 'down'}`,
}
label: `Move ${task.title} ${move[2] === "上" ? "up" : "down"}`,
};
}
const change = text.match(
/^(.+?)(?:を|は)(完了(?:にして|にする)?|終わった|あとで(?:にして|にする)?|後で(?:にして|にする)?|保留(?:にして|にする)?|未完了(?:にして|にする)?|戻して)$/,
)
);
if (change?.[1] && change[2]) {
const title = change[1].trim()
const matches = tasks.filter((task) => task.title === title)
const title = change[1].trim();
const matches = tasks.filter((task) => task.title === title);
const steps = tasks.flatMap((task) =>
(task.steps ?? [])
.filter((step) => `${task.title}の${step}` === title)
.map((step) => ({ id: task.id, step })),
)
);
if (matches.length + steps.length !== 1)
return {
error: 'Please use the exact name of one task from the list.',
}
error: "Please use the exact name of one task from the list.",
};
const status = /未完了|戻して/.test(change[2])
? 'todo'
? "todo"
: /完了|終わった/.test(change[2])
? 'done'
: 'later'
const step = steps[0]
? "done"
: "later";
const step = steps[0];
if (steps.length === 1 && step) {
if (status === 'later')
return { error: 'Routine steps can be marked done or incomplete.' }
if (status === "later") return { error: "Routine steps can be marked done or incomplete." };
return {
action: {
type: 'routine-step',
type: "routine-step",
id: step.id,
step: step.step,
done: status === 'done',
done: status === "done",
},
label: `Mark ${title} as ${status === 'done' ? 'done' : 'incomplete'}`,
}
label: `Mark ${title} as ${status === "done" ? "done" : "incomplete"}`,
};
}
const task = matches[0]
if (!task) return unsupported
const task = matches[0];
if (!task) return unsupported;
return {
action: { type: 'task-status', id: task.id, status },
label: `Mark ${title} as ${status === 'done' ? 'done' : status === 'later' ? 'later' : 'incomplete'}`,
}
action: { type: "task-status", id: task.id, status },
label: `Mark ${title} as ${status === "done" ? "done" : status === "later" ? "later" : "incomplete"}`,
};
}
const routine =
text.match(/^(.+?)をルーティンに追加(?:して)?$/) ??
text.match(/^ルーティンに追加[::]\s*(.+)$/)
const optional = text.match(/^余裕があれば[、\s]*(.+)$/)
const add =
text.match(/^(.+?)を(?:タスクに)?追加(?:して)?$/) ??
text.match(/^追加[::]\s*(.+)$/)
const title = (routine?.[1] ?? optional?.[1] ?? add?.[1])?.trim()
text.match(/^ルーティンに追加[::]\s*(.+)$/);
const optional = text.match(/^余裕があれば[、\s]*(.+)$/);
const add = text.match(/^(.+?)を(?:タスクに)?追加(?:して)?$/) ?? text.match(/^追加[::]\s*(.+)$/);
const title = (routine?.[1] ?? optional?.[1] ?? add?.[1])?.trim();
if (title && !/[。、\n]|(?:してから|それから|と一緒に)/.test(title)) {
const section = routine ? 'routine' : optional ? 'optional' : 'focus'
const section = routine ? "routine" : optional ? "optional" : "focus";
return {
action: { type: 'add-task', title, section },
label: `Add “${title}” to ${section === 'routine' ? 'Routines' : section === 'optional' ? 'If there’s room' : 'Start here'}`,
}
action: { type: "add-task", title, section },
label: `Add “${title}” to ${section === "routine" ? "Routines" : section === "optional" ? "If there’s room" : "Start here"}`,
};
}
return unsupported
return unsupported;
}