feat: add URL-driven workspace state and streamed Codex research
This commit is contained in:
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user