feat: add communication support and journal prototypes

This commit is contained in:
2026-09-28 18:03:06 +09:00
parent 4e8957c94a
commit 0ee5d181a5
14 changed files with 1498 additions and 1 deletions
+8
View File
@@ -1,4 +1,12 @@
[
{
"date": "2026-09-28",
"macrostructure": "communication workbench and append-only journal",
"theme": "shadcn base-nova neutral",
"scope": "support",
"enrichment": "none",
"brief": "Compact execution support integrated with common sidebar, editable reply review and journal corrections"
},
{
"date": "2026-09-28",
"macrostructure": "sidebar-09 Inbox",
+5
View File
@@ -30,6 +30,11 @@ Articles are fictional samples; search and article selection work locally.
On mobile, the article list and reader switch views. The shared sidebar’s bottom
avatar opens connected-account management from either workspace.
The shared sidebar also contains `/support` (communication support) and `/journal`
(notes and health records). These use fictional conversations and local session
state to preview reply review, scheduling, deferral, recording, and corrections.
See [execution support](docs/execution-support.md) for scope and limitations.
The former SNS reader, search,
list, user-profile, and conversation routes have been removed. Original-post
links on post cards open their source site; SNS reply threads are not rendered
+34
View File
@@ -0,0 +1,34 @@
# Execution support prototype
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts
and in-memory state. Reloading or leaving the route resets the prototype state.
No messages, calendar events, medical records, or Rensheng pages are written.
## Reference and scope
The functional reference is the user's life brief
`briefs/2026-09-25-executive-support-design.md`, together with the public
[Beeper Copilot concept](https://beeper-copilot-lp.vercel.app/).
The prototype adopts the execution flows, not private facts from the life repo
or the reference site's deployment and privacy claims.
- Communication: unified conversations, a maximum of three initial actions,
deferral, waiting, dismissal, and a full list; source messages, summaries,
editable reply styles, explicit review, and simulated sending.
- Scheduling: choosing a candidate and reviewing a simulated calendar entry.
- Context: person notes with links back to the sample conversation and a local
record of the simulated outcome. No permanent trait or goal is inferred.
- Journal: free notes, resumption points, user-entered medication and weight
records; occurrence and recording times, append-only corrections, and a
selectable summary preview for a consultation.
The intended memory boundary remains Rensheng: execution events can eventually
be compiled into durable context after source comparison and user correction.
This prototype neither implements that compiler nor creates a second personal
context database. Missing records remain unknown; there are no medication,
dose, weight-target, or treatment recommendations.
The UI uses existing shadcn Base UI components and neutral theme tokens.
Shared account management continues to use the existing connection system;
the new communication and journal prototypes do not consume those accounts.
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from '../fixtures'
const reviewSubject = '来週のレビュー、日程を決める'
const coffeeSubject = '久しぶりに、お茶でも'
test('reviews an edited reply and keeps the simulated result in history', async ({
page,
a11y,
}) => {
await page.goto('/support')
await page.waitForLoadState('networkidle')
const rows = page.locator('.support-row')
await expect(rows).toHaveCount(3)
await rows.filter({ hasText: reviewSubject }).click()
await page.getByRole('button', { name: '丁寧に', exact: true }).click()
const reply = page.getByRole('textbox', { name: '返信', exact: true })
await expect(reply).toHaveValue(/資料をご共有/)
const edited = '資料を確認しました。初回の導線について当日お話しします。'
await reply.fill(edited)
const slot = '10月5日(月)14:00–14:30'
await page.getByRole('combobox', { name: '日程候補' }).selectOption(slot)
await page.getByRole('button', { name: '返信に入れる' }).click()
await expect(reply).toHaveValue(`${edited}\n\n${slot}はいかがでしょうか。`)
await page.getByRole('button', { name: '送信内容を確認' }).click()
const dialog = page.getByRole('dialog', { name: '送信内容の確認' })
await expect(dialog).toContainText(edited)
await expect(dialog).toContainText(slot)
await expect(dialog).toContainText('外部には送信されません')
expect((await a11y().analyze()).violations).toEqual([])
await dialog.getByRole('button', { name: '戻る', exact: true }).click()
await expect(reply).toHaveValue(new RegExp(edited))
await page.getByRole('button', { name: '送信内容を確認' }).click()
await dialog.getByRole('button', { name: '送信を試す' }).click()
await expect(dialog).toHaveCount(0)
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0)
await page.getByRole('button', { name: '履歴', exact: true }).click()
await rows.filter({ hasText: reviewSubject }).click()
await expect(page.locator('.support-outcome')).toContainText(edited)
await expect(page.locator('.support-outcome')).toContainText(slot)
})
test('defers a conversation and restores it to the current queue', async ({
page,
}) => {
await page.goto('/support')
await page.waitForLoadState('networkidle')
const rows = page.locator('.support-row')
await rows.filter({ hasText: coffeeSubject }).click()
await page
.getByRole('textbox', { name: '返信', exact: true })
.fill('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: '今は置いておく' }).click()
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0)
await page.getByRole('button', { name: 'あとで', exact: true }).click()
await rows.filter({ hasText: coffeeSubject }).click()
await expect(
page.getByRole('textbox', { name: '返信', exact: true }),
).toHaveValue('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: '今に戻す' }).click()
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
if (await back.isVisible()) await back.click()
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible()
await expect(
page.getByRole('button', { name: '今', exact: true }),
).toHaveAttribute('aria-pressed', 'true')
})
test('keeps corrections as new records and summarizes only selected current facts', async ({
page,
a11y,
}) => {
await page.goto('/journal')
await page.waitForLoadState('networkidle')
await expect(
page.getByRole('button', { name: '記録する', exact: true }),
).toBeDisabled()
await page
.getByRole('textbox', { name: '記録内容' })
.fill('次はレビュー資料の導線を確認する')
await page.getByRole('button', { name: '記録する', exact: true }).click()
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText(
'発生日時: 不明',
)
await page.getByRole('button', { name: '体重', exact: true }).click()
await page.getByRole('spinbutton', { name: '体重', exact: true }).fill('65.2')
await page.getByRole('textbox', { name: '記録元' }).fill('自宅の体重計')
await page.getByRole('button', { name: '記録する', exact: true }).click()
await page
.getByRole('article', { name: '体重の記録' })
.getByRole('button', { name: '訂正する' })
.click()
const correction = page.getByRole('dialog', { name: '記録を訂正' })
await correction
.getByRole('spinbutton', { name: '体重', exact: true })
.fill('65.8')
await correction.getByRole('button', { name: '訂正を追加' }).click()
const weights = page.getByRole('article', { name: '体重の記録' })
await expect(weights).toHaveCount(2)
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('訂正前')
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText('訂正')
await page
.getByRole('button', { name: '記録をまとめる', exact: true })
.click()
const review = page.getByRole('dialog', { name: '記録をまとめる' })
const summary = review.getByRole('textbox', { name: '記録のまとめ' })
await expect(summary).toHaveValue(/65.8 kg/)
await expect(summary).not.toHaveValue(/65.2 kg/)
await expect(summary).toHaveValue(/記録元: 自宅の体重計/)
await expect(summary).toHaveValue(/発生日時が不明の記録: 2件/)
await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck()
await expect(summary).not.toHaveValue(/次はレビュー資料/)
await expect(summary).toHaveValue(/記録のない期間の服薬・体重は不明/)
expect((await a11y().analyze()).violations).toEqual([])
})
test('fits communication and journal interactions in narrow screens', async ({
page,
a11y,
}) => {
for (const width of [320, 375, 768]) {
await page.setViewportSize({ width, height: 850 })
await page.goto('/support')
await page.waitForLoadState('networkidle')
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.locator('.support-row').first().click()
await expect(
page.getByRole('textbox', { name: '返信', exact: true }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
if (await back.isVisible()) await back.click()
await page.getByRole('link', { name: '記録', exact: true }).click()
await page.getByRole('button', { name: '体重', exact: true }).click()
await expect(
page.getByRole('spinbutton', { name: '体重', exact: true }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
}
})
+19 -1
View File
@@ -1,5 +1,11 @@
import { Link, useLocation } from '@tanstack/react-router'
import { Inbox, Layers3, Search } from 'lucide-react'
import {
Inbox,
Layers3,
MessagesSquare,
NotebookPen,
Search,
} from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
import {
@@ -30,6 +36,18 @@ export function WorkspaceNavigation({
const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [
{
title: '連絡',
to: '/support',
icon: MessagesSquare,
active: pathname === '/support',
},
{
title: '記録',
to: '/journal',
icon: NotebookPen,
active: pathname === '/journal',
},
{
title: 'リーダー',
to: '/inbox',
+349
View File
@@ -0,0 +1,349 @@
import { useId, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { Input } from '#/components/ui/input'
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 './journal.css'
type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
type Entry = {
id: string
kind: EntryKind
text: string
source: string
occurredAt: string
recordedAt: string
correctionOf?: string
}
type Draft = Pick<Entry, 'kind' | 'text' | 'source' | 'occurredAt'>
const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' }
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
function formatTime(value: string | undefined) {
if (!value) return '不明'
return new Date(value).toLocaleString('ja-JP', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function JournalPage() {
const [entries, setEntries] = useState<Entry[]>([])
const [draft, setDraft] = useState<Draft>(emptyDraft)
const [correcting, setCorrecting] = useState<Entry | null>(null)
const [reviewing, setReviewing] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const superseded = new Set(entries.map((entry) => entry.correctionOf))
const current = entries.filter((entry) => !superseded.has(entry.id))
function append(value: Draft, correctionOf?: string) {
setEntries((previous) => [
{
...value,
text: value.text.trim(),
source: value.source.trim(),
id: crypto.randomUUID(),
recordedAt: new Date().toISOString(),
correctionOf,
},
...previous,
])
}
return (
<TooltipProvider delay={500}>
<SidebarProvider className="journal-workspace">
<WorkspaceNavigation />
<SidebarInset className="journal-main">
<header className="journal-header">
<h1 className="text-base font-semibold">記録</h1>
<Button
variant="outline"
disabled={current.length === 0}
onClick={() => {
setSelected(new Set(current.map((entry) => entry.id)))
setReviewing(true)
}}
>
記録をまとめる
</Button>
</header>
<div className="journal-scroll">
<div className="journal-column">
<EntryEditor
draft={draft}
onChange={setDraft}
onSubmit={() => {
append(draft)
setDraft(emptyDraft)
}}
submitLabel="記録する"
/>
<section className="journal-entries" aria-label="記録一覧">
{entries.length === 0 && (
<p className="journal-empty">まだ記録はありません</p>
)}
{entries.map((entry) => (
<article
key={entry.id}
className="journal-entry"
aria-label={`${entry.kind}の記録`}
>
<div className="journal-entry-heading">
<span className="text-sm font-medium">{entry.kind}</span>
{superseded.has(entry.id) && (
<span className="text-xs text-muted-foreground">
訂正前
</span>
)}
{entry.correctionOf && (
<span className="text-xs text-muted-foreground">
訂正
</span>
)}
{!superseded.has(entry.id) && (
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => setCorrecting(entry)}
>
訂正する
</Button>
)}
</div>
<p className="journal-entry-text">
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
</p>
<p className="text-xs text-muted-foreground">
発生日時: {formatTime(entry.occurredAt)} · 記録日時:{' '}
{formatTime(entry.recordedAt)}
</p>
{(entry.kind === '体重' || entry.source) && (
<p className="mt-2 text-xs text-muted-foreground">
記録元: {entry.source || '不明'}
</p>
)}
</article>
))}
</section>
</div>
</div>
</SidebarInset>
</SidebarProvider>
{correcting && (
<CorrectionDialog
entry={correcting}
onClose={() => setCorrecting(null)}
onSave={(value) => {
append(value, correcting.id)
setCorrecting(null)
}}
/>
)}
{reviewing && (
<Dialog
title="記録をまとめる"
className="sm:max-w-2xl"
onClose={() => setReviewing(false)}
>
<p className="text-sm text-muted-foreground">
含める記録を選んで確認できます。外部には送信されません。
</p>
<div className="journal-review-list">
{current.map((entry) => (
<label
key={entry.id}
className="journal-review-row"
htmlFor={`review-${entry.id}`}
>
<Checkbox
id={`review-${entry.id}`}
checked={selected.has(entry.id)}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous)
if (checked) next.add(entry.id)
else next.delete(entry.id)
return next
})
}
/>
<span>
<span className="text-xs text-muted-foreground">
{entry.kind} · {formatTime(entry.occurredAt)}
</span>
<span className="block text-sm">
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
</span>
</span>
</label>
))}
</div>
<Textarea
aria-label="記録のまとめ"
className="journal-summary"
readOnly
value={buildSummary(
current.filter((entry) => selected.has(entry.id)),
)}
/>
</Dialog>
)}
</TooltipProvider>
)
}
function EntryEditor({
draft,
onChange,
onSubmit,
submitLabel,
}: {
draft: Draft
onChange: (draft: Draft) => void
onSubmit: () => void
submitLabel: string
}) {
const dateId = useId()
const valid =
draft.text.trim().length > 0 &&
(draft.kind !== '体重' ||
(Number.isFinite(Number(draft.text)) && Number(draft.text) > 0))
return (
<form
className="journal-editor"
onSubmit={(event) => {
event.preventDefault()
if (valid) onSubmit()
}}
>
<fieldset className="journal-kind-switch" aria-label="記録の種類">
{kinds.map((kind) => (
<Button
key={kind}
type="button"
variant={draft.kind === kind ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={draft.kind === kind}
onClick={() => onChange({ ...draft, kind })}
>
{kind}
</Button>
))}
</fieldset>
{draft.kind === '体重' ? (
<div className="flex items-center gap-2">
<Input
type="number"
step="any"
min="0.1"
aria-label="体重"
placeholder="体重"
value={draft.text}
onChange={(event) =>
onChange({ ...draft, text: event.target.value })
}
/>
<span className="text-sm text-muted-foreground">kg</span>
</div>
) : (
<Textarea
aria-label="記録内容"
placeholder={
draft.kind === '服薬'
? '服用した薬の名前'
: draft.kind === '再開地点'
? '次にすること、開いておく場所'
: '自由なメモ、次にすること'
}
value={draft.text}
onChange={(event) => onChange({ ...draft, text: event.target.value })}
/>
)}
{draft.kind === '体重' && (
<Input
aria-label="記録元"
placeholder="記録元(体重計など・任意)"
value={draft.source}
onChange={(event) =>
onChange({ ...draft, source: event.target.value })
}
/>
)}
<div className="journal-editor-footer">
<label className="journal-date-label" htmlFor={dateId}>
発生日時(任意)
<Input
id={dateId}
type="datetime-local"
aria-label="発生日時"
value={draft.occurredAt}
onChange={(event) =>
onChange({ ...draft, occurredAt: event.target.value })
}
/>
</label>
<Button type="submit" disabled={!valid}>
{submitLabel}
</Button>
</div>
</form>
)
}
function CorrectionDialog({
entry,
onClose,
onSave,
}: {
entry: Entry
onClose: () => void
onSave: (draft: Draft) => void
}) {
const [draft, setDraft] = useState<Draft>(entry)
return (
<Dialog title="記録を訂正" onClose={onClose} className="sm:max-w-xl">
<p className="text-sm text-muted-foreground">
元の記録を残して、訂正を追加します。
</p>
<EntryEditor
draft={draft}
onChange={setDraft}
onSubmit={() => onSave(draft)}
submitLabel="訂正を追加"
/>
</Dialog>
)
}
function buildSummary(entries: Entry[]) {
if (!entries.length) return '記録を選択してください。'
const dates = entries
.map((entry) => entry.occurredAt)
.filter(Boolean)
.sort()
const period = dates.length
? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}`
: '不明'
return [
`発生日時の期間: ${period}`,
`発生日時が不明の記録: ${entries.filter((entry) => !entry.occurredAt).length}件`,
'',
...entries.map(
(entry) =>
`[${entry.kind}] ${entry.text}${entry.kind === '体重' ? ' kg' : ''}\n発生日時: ${formatTime(entry.occurredAt)} / 記録日時: ${formatTime(entry.recordedAt)}${entry.kind === '体重' ? ` / 記録元: ${entry.source || '不明'}` : ''}`,
),
'',
'記録のない期間の服薬・体重は不明です。',
].join('\n')
}
+129
View File
@@ -0,0 +1,129 @@
.journal-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.journal-main {
min-width: 0;
overflow: hidden;
}
.journal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 68px;
padding: 16px 28px;
border-bottom: 1px solid var(--border);
}
.journal-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.journal-column {
width: min(100%, 760px);
margin: 0 auto;
padding: 40px 28px;
}
.journal-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.journal-kind-switch {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.journal-editor textarea {
min-height: 112px;
}
.journal-editor-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.journal-date-label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: var(--muted-foreground);
min-width: 0;
}
.journal-date-label input {
max-width: 100%;
}
.journal-entries {
margin-top: 36px;
}
.journal-empty {
padding: 40px 0;
color: var(--muted-foreground);
font-size: 14px;
text-align: center;
}
.journal-entry {
border-top: 1px solid var(--border);
padding: 20px 0;
}
.journal-entry-heading {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
}
.journal-entry-text {
margin: 12px 0;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.journal-review-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 220px;
overflow: auto;
padding: 4px;
}
.journal-review-row {
display: flex;
align-items: flex-start;
gap: 12px;
overflow-wrap: anywhere;
}
.journal-review-row [data-slot="checkbox"] {
margin-top: 4px;
}
.journal-summary {
min-height: 180px;
max-height: 280px;
overflow-y: auto;
font-size: 13px;
}
@media (max-width: 600px) {
.journal-header {
padding: 14px;
flex-wrap: wrap;
}
.journal-column {
padding: 24px 14px;
}
.journal-editor-footer {
align-items: stretch;
flex-direction: column;
}
.journal-editor-footer button {
align-self: flex-end;
}
.journal-kind-switch {
gap: 0;
}
.journal-kind-switch button {
padding-inline: 8px;
}
}
@@ -0,0 +1,283 @@
import { CalendarPlus, Check, ChevronDown, Send } from 'lucide-react'
import { useState } from 'react'
import { Button } from '#/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '#/components/ui/dialog'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { Textarea } from '#/components/ui/textarea'
import type { Conversation } from './mock-conversations'
export function ConversationDetail({
conversation,
onResolve,
}: {
conversation: Conversation
onResolve: (
result: 'sent' | 'waiting' | 'later' | 'dismissed',
note?: string,
) => void
}) {
const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? '')
const [tone, setTone] = useState(conversation.drafts[0]?.label ?? '')
const [review, setReview] = useState<'send' | 'schedule' | null>(null)
const [slot, setSlot] = useState('')
const [scheduled, setScheduled] = useState('')
return (
<article
className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10"
aria-label={`${conversation.name}との会話`}
>
<header className="mb-8 space-y-2">
<p className="text-xs text-muted-foreground">
{conversation.channels.join(' / ')} · {conversation.role}
</p>
<h1 className="text-2xl font-medium tracking-tight">
{conversation.name}
</h1>
<p className="text-sm text-muted-foreground">{conversation.subject}</p>
</header>
<section className="mb-8 space-y-4" aria-label="会話の要点">
<p className="text-sm leading-7">{conversation.summary}</p>
<div className="border-l-2 border-border pl-4">
<p className="text-sm leading-6">{conversation.nextStep}</p>
{conversation.deadline && (
<p className="mt-1 text-xs text-muted-foreground">
相手の指定期限 · {conversation.deadline}
</p>
)}
</div>
</section>
<details className="group mb-8 border-y border-border py-3">
<summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
元のやりとり{' '}
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-5 pt-5">
{conversation.messages.map((message) => (
<div
key={message.id}
id={`source-${message.id}`}
className="scroll-mt-5 rounded-lg bg-muted/40 p-4 focus:outline-none"
tabIndex={-1}
>
<p className="mb-2 text-xs text-muted-foreground">
{message.direction === 'in' ? conversation.name : '自分'} ·{' '}
{message.channel} · {message.time}
</p>
<p className="whitespace-pre-wrap text-sm leading-7">
{message.text}
</p>
</div>
))}
</div>
</details>
<section aria-label="返信を作る" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label
htmlFor={`support-reply-${conversation.id}`}
className="text-sm font-medium"
>
返信
</label>
<span className="text-xs text-muted-foreground">
{conversation.replyChannel} → {conversation.name}
</span>
</div>
<div className="flex flex-wrap gap-1">
{conversation.drafts.map((option) => (
<Button
key={option.label}
variant={tone === option.label ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={tone === option.label}
onClick={() => {
setDraft(option.text)
setTone(option.label)
}}
>
{option.label}
</Button>
))}
</div>
<Textarea
id={`support-reply-${conversation.id}`}
value={draft}
className="min-h-36 resize-y text-sm leading-7"
onChange={(event) => {
setDraft(event.target.value)
setTone('')
}}
/>
{conversation.scheduleOptions.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label="日程候補"
value={slot}
onChange={(event) => setSlot(event.target.value)}
>
<NativeSelectOption value="">候補日時を選ぶ</NativeSelectOption>
{conversation.scheduleOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{option}
</NativeSelectOption>
))}
</NativeSelect>
<Button
size="sm"
variant="ghost"
disabled={!slot}
onClick={() => {
setDraft(
(current) => `${current}\n\n${slot}はいかがでしょうか。`,
)
setTone('')
}}
>
返信に入れる
</Button>
<Button
size="icon-sm"
variant="ghost"
disabled={!slot}
aria-label="予定を仮置きする"
onClick={() => setReview('schedule')}
>
<CalendarPlus />
</Button>
</div>
)}
{scheduled && (
<p
role="status"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<Check className="size-3" />
{scheduled} をこの画面に仮置きしました
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<Button disabled={!draft.trim()} onClick={() => setReview('send')}>
<Send />
送信内容を確認
</Button>
<div className="flex flex-wrap gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('later')}
>
今は置いておく
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('waiting')}
>
相手待ち
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('dismissed')}
>
返信せず閉じる
</Button>
</div>
</div>
</section>
<details className="group mt-10 border-t border-border pt-4">
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
人物メモ{' '}
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
</summary>
<ul className="mt-4 space-y-3 text-sm leading-6">
{conversation.facts.map((fact) => (
<li
key={fact.sourceId}
className="flex items-start justify-between gap-3"
>
<span>{fact.text}</span>
<a
className="shrink-0 text-xs text-muted-foreground underline underline-offset-4"
href={`#source-${fact.sourceId}`}
onClick={() => {
const source = document.getElementById(
`source-${fact.sourceId}`,
)
const details = source?.closest('details')
if (details) details.open = true
source?.focus()
}}
>
出典
</a>
</li>
))}
</ul>
</details>
<Dialog
open={review !== null}
onOpenChange={(open) => {
if (!open) setReview(null)
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{review === 'schedule' ? '予定の確認' : '送信内容の確認'}
</DialogTitle>
<DialogDescription>
{review === 'schedule'
? '外部カレンダーには登録せず、この画面だけに仮置きします。'
: '外部には送信されません。完了後の表示を試せます。'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm font-medium">
{conversation.name}
{review === 'send' && (
<span className="ml-2 font-normal text-muted-foreground">
{conversation.replyChannel}
</span>
)}
</p>
<p className="max-h-[45dvh] overflow-y-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-4 text-sm leading-7">
{review === 'schedule' ? slot : draft}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReview(null)}>
戻る
</Button>
<Button
onClick={() => {
if (review === 'schedule') setScheduled(slot)
else
onResolve(
'sent',
`${conversation.replyChannel} → ${conversation.name}(送信プレビュー)\n${draft}`,
)
setReview(null)
}}
>
{review === 'schedule' ? '仮置きを試す' : '送信を試す'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</article>
)
}
+186
View File
@@ -0,0 +1,186 @@
type Channel = 'Slack' | 'LINE' | 'Gmail'
export type Conversation = {
id: string
name: string
role: string
subject: string
channels: Channel[]
replyChannel: Channel
updatedAt: string
deadline?: string
summary: string
nextStep: string
messages: {
id: string
channel: Channel
text: string
time: string
direction: 'in' | 'out'
}[]
facts: { text: string; sourceId: string }[]
drafts: { label: string; text: string }[]
scheduleOptions: string[]
}
// All contacts, conversations and proposed appointments are fictional UI samples.
export const conversations: Conversation[] = [
{
id: 'design-review',
name: '青木 遥',
role: 'プロダクトデザイナー',
subject: '来週のレビュー、日程を決める',
channels: ['Slack', 'Gmail'],
replyChannel: 'Slack',
updatedAt: '10:42',
deadline: '9月29日 17:00',
summary:
'新しい画面のレビュー依頼。資料はメールで届いていて、Slackで日程の返事を待っている。30分あればよい。',
nextStep: '資料を確認して、30分の候補日時を返す。',
messages: [
{
id: 'aoki-1',
channel: 'Gmail',
time: '9月27日 16:20',
direction: 'in',
text: 'レビュー用の画面案を共有します。特に初回の導線について意見を聞きたいです。確認のお時間は30分ほどを想定しています。',
},
{
id: 'aoki-2',
channel: 'Slack',
time: '9月28日 10:42',
direction: 'in',
text: '昨日メールしたレビューの件、来週どこかでお時間いただけますか? 9月29日17時までに候補をいただけると助かります。午後だとうれしいです。',
},
],
facts: [
{ text: 'レビューは30分を想定している。', sourceId: 'aoki-1' },
{ text: '今回の日程は午後を希望している。', sourceId: 'aoki-2' },
],
drafts: [
{
label: '短く',
text: '資料ありがとうございます。確認します! 来週の午後で、30分ほどお話しできればと思います。',
},
{
label: '丁寧に',
text: '資料をご共有いただきありがとうございます。初回の導線を中心に確認いたします。来週の午後に30分ほどお時間をいただけますでしょうか。',
},
{
label: 'まず受け取る',
text: 'ご連絡ありがとうございます。資料、受け取りました。日程を確認して9月29日17時までにお返事します。',
},
],
scheduleOptions: ['10月5日(月)14:00–14:30', '10月6日(火)15:00–15:30'],
},
{
id: 'weekend-coffee',
name: '森 直人',
role: '友人',
subject: '久しぶりに、お茶でも',
channels: ['LINE'],
replyChannel: 'LINE',
updatedAt: '昨日',
summary:
'近くに来るのでお茶をしようという誘い。急ぎの返事は求められていない。',
nextStep: '会いたければ都合を返す。今決めなくてもよい。',
messages: [
{
id: 'mori-1',
channel: 'LINE',
time: '9月27日 19:15',
direction: 'in',
text: '来週そっちの方に行くかも。時間合ったらお茶しない? 土曜の午後なら空いてる。急ぎじゃないので、予定わかったら!',
},
],
facts: [{ text: '今回は土曜の午後が空いている。', sourceId: 'mori-1' }],
drafts: [
{
label: '短く',
text: 'いいね、久しぶりに会いたい! 土曜の午後で予定見てみるね。',
},
{
label: '丁寧に',
text: '誘ってくれてありがとう! ぜひお茶したいです。土曜の午後、都合を確認して連絡するね。',
},
{
label: 'まず受け取る',
text: 'ありがとう! まだ予定が見えないので、わかったら連絡するね。',
},
],
scheduleOptions: ['10月3日(土)14:00–15:00', '10月3日(土)16:00–17:00'],
},
{
id: 'estimate-check',
name: '藤井 理沙',
role: '制作パートナー',
subject: '見積もりの前提を確認する',
channels: ['Gmail'],
replyChannel: 'Gmail',
updatedAt: '9:10',
deadline: '9月30日 12:00',
summary:
'見積もりを進めるため、対象画面が3画面でよいか確認が届いている。回答期限は9月30日の正午。',
nextStep: '対象画面数を確認して返答する。未確認のまま確約しない。',
messages: [
{
id: 'fujii-1',
channel: 'Gmail',
time: '9月28日 9:10',
direction: 'in',
text: '概算の作成にあたり、対象は一覧・詳細・設定の3画面という理解でよろしいでしょうか。9月30日正午までにご確認いただければ、同日中にお見積もりをお送りします。',
},
],
facts: [
{ text: '対象画面数の確認後に概算を作成する予定。', sourceId: 'fujii-1' },
],
drafts: [
{
label: '短く',
text: 'ありがとうございます。対象の3画面について確認し、9月30日正午までにお返事します。',
},
{
label: '丁寧に',
text: 'ご確認ありがとうございます。一覧・詳細・設定の3画面で相違ないか確認のうえ、9月30日正午までに回答いたします。',
},
{
label: 'まず受け取る',
text: 'ご連絡を受け取りました。対象範囲を確認して、改めてご連絡します。',
},
],
scheduleOptions: [],
},
{
id: 'reading-note',
name: '田辺 悠',
role: '勉強会の仲間',
subject: '前に話していた記事',
channels: ['Slack'],
replyChannel: 'Slack',
updatedAt: '金曜日',
summary:
'前の勉強会で話題になった記事を共有してくれた。質問や返答の依頼はない。',
nextStep: '読む時間がなければ、返信せず閉じてよい。',
messages: [
{
id: 'tanabe-1',
channel: 'Slack',
time: '9月25日 18:30',
direction: 'in',
text: '前に話していた、道具を増やさず仕事を進める話の記事を見つけたので共有! 時間あるときにどうぞ。返信不要です。',
},
],
facts: [
{ text: 'この共有への返信は不要と明記している。', sourceId: 'tanabe-1' },
],
drafts: [
{ label: '短く', text: '共有ありがとう! あとで読んでみるね。' },
{
label: '丁寧に',
text: '覚えていてくれてありがとう。ちょうど気になっていた話なので、時間を取って読んでみます。',
},
{ label: 'まず受け取る', text: 'ありがとう、受け取りました!' },
],
scheduleOptions: [],
},
]
+200
View File
@@ -0,0 +1,200 @@
import { ArrowLeft, Search } from 'lucide-react'
import { useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarProvider,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { ConversationDetail } from './conversation-detail'
import { conversations } from './mock-conversations'
import './support.css'
type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
const views = [
{ id: 'now', label: '今' },
{ id: 'later', label: 'あとで' },
{ id: 'waiting', label: '相手待ち' },
{ id: 'done', label: '履歴' },
{ id: 'all', label: 'すべて' },
] as const
export function SupportPage() {
const [view, setView] = useState<string>('now')
const [query, setQuery] = useState('')
const [statuses, setStatuses] = useState<Record<string, Status>>({})
const [selectedId, setSelectedId] = useState<string>()
const [reading, setReading] = useState(false)
const [outcomes, setOutcomes] = useState<Record<string, string>>({})
const [notice, setNotice] = useState('')
const matches = conversations.filter((item) => {
const status = statuses[item.id] ?? 'now'
return (
(view === 'all'
? true
: view === 'done'
? status === 'sent' || status === 'dismissed'
: status === view) && `${item.name} ${item.subject}`.includes(query)
)
})
const visible = view === 'now' ? matches.slice(0, 3) : matches
const selected = visible.find((item) => item.id === selectedId) ?? visible[0]
function resolve(id: string, status: Exclude<Status, 'now'>, note?: string) {
setStatuses((current) => ({ ...current, [id]: status }))
if (note) setOutcomes((current) => ({ ...current, [id]: note }))
setNotice(
status === 'sent'
? '返信の操作を記録しました'
: status === 'later'
? 'あとでに移しました'
: status === 'waiting'
? '相手待ちに移しました'
: '対応不要にしました',
)
setReading(false)
}
return (
<TooltipProvider>
<SidebarProvider className="support-workspace" data-reading={reading}>
<Sidebar collapsible="none" className="support-sidebar">
<WorkspaceNavigation />
<Sidebar collapsible="none" className="support-list">
<SidebarHeader className="border-b p-5 gap-4">
<h1 className="text-base font-semibold">連絡</h1>
<div className="flex gap-1 flex-wrap">
{views.map((tab) => (
<Button
key={tab.id}
size="sm"
variant={view === tab.id ? 'secondary' : 'ghost'}
aria-pressed={view === tab.id}
onClick={() => {
setView(tab.id)
setSelectedId(undefined)
setReading(false)
setNotice('')
}}
>
{tab.label}
</Button>
))}
</div>
<div className="relative">
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
<Input
aria-label="連絡を検索"
placeholder="検索"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</SidebarHeader>
<SidebarContent className="gap-0">
{visible.map((item) => (
<button
type="button"
className="support-row"
aria-current={selected?.id === item.id ? 'true' : undefined}
key={item.id}
onClick={() => {
setSelectedId(item.id)
setReading(true)
}}
>
<span className="text-xs text-muted-foreground">
{item.name}
</span>
<span className="font-medium text-sm">{item.subject}</span>
</button>
))}
{!visible.length && (
<p className="p-5 text-sm text-muted-foreground">
{query ? '見つかりませんでした' : 'いま扱う連絡はありません'}
</p>
)}
{view === 'now' && matches.length > 3 && (
<p className="p-5 text-xs text-muted-foreground">
ほか {matches.length - 3} 件
</p>
)}
</SidebarContent>
</Sidebar>
</Sidebar>
<SidebarInset className="support-main">
<header className="support-toolbar">
<Button
variant="ghost"
size="icon"
className="support-back"
aria-label="連絡一覧に戻る"
onClick={() => setReading(false)}
>
<ArrowLeft />
</Button>
<span className="text-sm">{selected?.name}</span>
<span
role="status"
className="ml-auto text-xs text-muted-foreground"
>
{notice}
</span>
</header>
<section
className="support-detail"
aria-label="連絡の詳細"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Independent region needs keyboard scrolling.
tabIndex={0}
>
{conversations.map((conversation) => (
<div
key={conversation.id}
hidden={selected?.id !== conversation.id}
>
<ConversationDetail
conversation={conversation}
onResolve={(status, note) =>
resolve(conversation.id, status, note)
}
/>
</div>
))}
{selected ? (
<>
{outcomes[selected.id] && (
<div className="support-outcome">
<h2 className="font-medium text-sm">活動の記録</h2>
<p className="whitespace-pre-wrap mt-2 text-sm">
{outcomes[selected.id]}
</p>
</div>
)}
{view !== 'now' && (
<Button
variant="outline"
className="m-6"
onClick={() => {
setStatuses((current) => ({
...current,
[selected.id]: 'now',
}))
setView('now')
}}
>
今に戻す
</Button>
)}
</>
) : (
<div className="support-empty">連絡を選択</div>
)}
</section>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
+89
View File
@@ -0,0 +1,89 @@
/* Hallmark · sidebar workbench · neutral · P5 H5 E4 S5 R5 V4 */
.support-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.support-sidebar {
width: 390px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.support-list {
flex: 1;
min-width: 0;
width: auto;
}
.support-row {
display: flex;
flex-direction: column;
gap: 10px;
padding: 22px 20px;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
line-height: 1.6;
}
.support-row:hover {
background: var(--sidebar-accent);
}
.support-row[aria-current="true"] {
background: var(--muted);
box-shadow: inset 2px 0 var(--primary);
}
.support-main {
min-width: 0;
height: 100%;
overflow: hidden;
}
.support-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
}
.support-detail {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-wrap: anywhere;
}
.support-detail > * {
max-width: 800px;
margin-inline: auto;
}
.support-outcome {
padding: 24px;
border-top: 1px solid var(--border);
}
.support-empty {
padding: 64px 24px;
color: var(--muted-foreground);
text-align: center;
}
.support-back {
display: none;
}
@media (max-width: 767px) {
.support-sidebar {
width: 100%;
}
.support-main {
display: none;
}
.support-back {
display: inline-flex;
}
.support-workspace[data-reading="true"] .support-sidebar {
display: none;
}
.support-workspace[data-reading="true"] .support-main {
display: flex;
}
.support-toolbar {
padding: 12px;
}
}
+42
View File
@@ -9,12 +9,24 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SupportRouteImport } from './routes/support'
import { Route as JournalRouteImport } from './routes/journal'
import { Route as InboxRouteImport } from './routes/inbox'
import { Route as DeckRouteImport } from './routes/deck'
import { Route as IndexRouteImport } from './routes/index'
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
const SupportRoute = SupportRouteImport.update({
id: '/support',
path: '/support',
getParentRoute: () => rootRouteImport,
} as any)
const JournalRoute = JournalRouteImport.update({
id: '/journal',
path: '/journal',
getParentRoute: () => rootRouteImport,
} as any)
const InboxRoute = InboxRouteImport.update({
id: '/inbox',
path: '/inbox',
@@ -45,6 +57,8 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/support': typeof SupportRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
@@ -52,6 +66,8 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/support': typeof SupportRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
@@ -60,6 +76,8 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/support': typeof SupportRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
@@ -69,6 +87,8 @@ export interface FileRouteTypes {
| '/'
| '/deck'
| '/inbox'
| '/journal'
| '/support'
| '/api/research/events'
| '/oauth/mastodon/callback'
fileRoutesByTo: FileRoutesByTo
@@ -76,6 +96,8 @@ export interface FileRouteTypes {
| '/'
| '/deck'
| '/inbox'
| '/journal'
| '/support'
| '/api/research/events'
| '/oauth/mastodon/callback'
id:
@@ -83,6 +105,8 @@ export interface FileRouteTypes {
| '/'
| '/deck'
| '/inbox'
| '/journal'
| '/support'
| '/api/research/events'
| '/oauth/mastodon/callback'
fileRoutesById: FileRoutesById
@@ -91,12 +115,28 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DeckRoute: typeof DeckRoute
InboxRoute: typeof InboxRoute
JournalRoute: typeof JournalRoute
SupportRoute: typeof SupportRoute
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/support': {
id: '/support'
path: '/support'
fullPath: '/support'
preLoaderRoute: typeof SupportRouteImport
parentRoute: typeof rootRouteImport
}
'/journal': {
id: '/journal'
path: '/journal'
fullPath: '/journal'
preLoaderRoute: typeof JournalRouteImport
parentRoute: typeof rootRouteImport
}
'/inbox': {
id: '/inbox'
path: '/inbox'
@@ -139,6 +179,8 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DeckRoute: DeckRoute,
InboxRoute: InboxRoute,
JournalRoute: JournalRoute,
SupportRoute: SupportRoute,
ApiResearchEventsRoute: ApiResearchEventsRoute,
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
}
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { JournalPage } from '#/features/journal/journal-page'
export const Route = createFileRoute('/journal')({ component: JournalPage })
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { SupportPage } from '#/features/support/support-page'
export const Route = createFileRoute('/support')({ component: SupportPage })