feat: add minimal reader and shared account navigation

This commit is contained in:
2026-09-28 17:53:38 +09:00
parent 96e545401e
commit 4e8957c94a
15 changed files with 844 additions and 96 deletions
@@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { ConnectionManager } from './connection-manager'
import { loadConnections } from './server-functions'
export function ConnectionManagerDialog({
onClose,
hasTemporaryDecks,
}: {
onClose: () => void
hasTemporaryDecks?: boolean
}) {
const fetchConnections = useServerFn(loadConnections)
const connections = useQuery({
queryKey: ['connections'],
queryFn: () => fetchConnections(),
retry: false,
})
return (
<Dialog title="接続アカウント" onClose={onClose} className="sm:max-w-2xl">
{connections.isPending ? (
<p role="status">接続アカウントを読み込んでいます…</p>
) : connections.isError ? (
<div className="grid gap-3">
<p role="alert">接続アカウントを取得できませんでした。</p>
<Button variant="outline" onClick={() => void connections.refetch()}>
再試行
</Button>
</div>
) : (
<>
{connections.data.relayError && (
<p role="alert">{connections.data.relayError}</p>
)}
<ConnectionManager
connections={connections.data.connections}
onChanged={() => connections.refetch()}
hasTemporaryDecks={hasTemporaryDecks}
/>
</>
)}
</Dialog>
)
}
+1 -24
View File
@@ -15,7 +15,6 @@ import {
EmptyTitle,
} from '#/components/ui/empty'
import { Input } from '#/components/ui/input'
import { ConnectionManager } from '#/features/connections/connection-manager'
import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync'
import { ResearchPanel } from '#/features/research/research-panel'
@@ -41,7 +40,6 @@ export function DeckPage() {
const researchDeckBindings = useRef(new Map<string, string>()).current
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
const [managingConnections, setManagingConnections] = useState(false)
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
const [newDeckTitle, setNewDeckTitle] = useState('')
const switcherEditing = deckDialog !== null
@@ -155,6 +153,7 @@ export function DeckPage() {
>
{(renderResearchPanel) => (
<AppShell
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
researchChat={renderResearchPanel}
sidebar={
<>
@@ -204,14 +203,6 @@ export function DeckPage() {
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
<Button
variant="outline"
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</Button>
</>
}
>
@@ -308,20 +299,6 @@ export function DeckPage() {
{profiles.data.relayError}
</p>
) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some(
(deck) => !deck.persisted,
)}
/>
</Dialog>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
+146
View File
@@ -0,0 +1,146 @@
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 { type Article, articles } from './mock-articles'
import './inbox.css'
export function InboxPage() {
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>(
articles[0]?.id,
)
const [readingMobile, setReadingMobile] = useState(false)
const visible = articles.filter((article) =>
`${article.title} ${article.summary} ${article.source}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
)
const selected =
visible.find((article) => article.id === selectedId) ?? visible[0]
return (
<TooltipProvider delay={500}>
<SidebarProvider className="inbox-workspace" data-reading={readingMobile}>
<Sidebar collapsible="none" className="inbox-sidebar">
<WorkspaceNavigation />
<Sidebar collapsible="none" className="inbox-list-panel">
<SidebarHeader className="gap-4 border-b p-5">
<h1 className="text-base font-semibold">リーダー</h1>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-2 size-4 text-muted-foreground"
/>
<Input
aria-label="記事を検索"
placeholder="検索"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</SidebarHeader>
<SidebarContent className="gap-0" aria-label="記事一覧">
{visible.map((article) => (
<ArticleRow
key={article.id}
article={article}
selected={selected?.id === article.id}
onSelect={() => {
setSelectedId(article.id)
setReadingMobile(true)
}}
/>
))}
{visible.length === 0 && (
<div className="inbox-no-results">
<p>記事がありません</p>
<Button variant="outline" onClick={() => setQuery('')}>
検索をクリア
</Button>
</div>
)}
</SidebarContent>
</Sidebar>
</Sidebar>
<SidebarInset className="inbox-reader">
<header className="inbox-reader-toolbar">
<Button
variant="ghost"
size="icon"
className="inbox-back"
aria-label="記事一覧に戻る"
onClick={() => setReadingMobile(false)}
>
<ArrowLeft />
</Button>
<span className="text-xs text-muted-foreground">
{selected?.source}
</span>
</header>
<section
className="inbox-reader-scroll"
key={selected?.id}
aria-label="記事本文"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable.
tabIndex={0}
>
{selected && <ArticleReader article={selected} />}
</section>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
function ArticleRow({
article,
selected,
onSelect,
}: {
article: Article
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
className="inbox-article-row"
aria-current={selected ? 'true' : undefined}
onClick={onSelect}
>
<span className="inbox-row-meta">
<span className="truncate">{article.source}</span>
<time className="ml-auto shrink-0">{article.date}</time>
</span>
<span className="inbox-row-title">{article.title}</span>
<span className="inbox-row-summary">{article.summary}</span>
</button>
)
}
function ArticleReader({ article }: { article: Article }) {
return (
<article className="inbox-article" aria-label={article.title}>
<h2 className="inbox-article-title">{article.title}</h2>
<p className="inbox-byline text-xs text-muted-foreground">
{article.author} · {article.date}
</p>
<div className="inbox-article-body">
{article.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</article>
)
}
+148
View File
@@ -0,0 +1,148 @@
/* Hallmark · macrostructure: sidebar-09 Inbox · tone: reading-first · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.inbox-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.inbox-sidebar {
width: 420px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.inbox-list-panel {
flex: 1;
min-width: 0;
width: auto;
}
.inbox-article-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 11px;
width: 100%;
padding: 22px 20px;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
}
.inbox-article-row:hover {
background: var(--sidebar-accent);
}
.inbox-article-row[aria-current="true"] {
background: var(--muted);
box-shadow: inset 2px 0 var(--primary);
}
.inbox-row-meta {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--muted-foreground);
font-size: 11px;
}
.inbox-row-title {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.65;
text-wrap: pretty;
}
.inbox-row-summary {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.8;
}
.inbox-reader {
min-width: 0;
height: 100%;
overflow: hidden;
}
.inbox-reader-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 10px 26px;
border-bottom: 1px solid var(--border);
}
.inbox-reader-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.inbox-article {
max-width: 800px;
margin: 0 auto;
padding: 54px 52px 40px;
}
.inbox-article-title {
margin: 0;
font-size: clamp(24px, 2.4vw, 34px);
line-height: 1.55;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.inbox-byline {
margin-block: 30px;
}
.inbox-article-body {
font-size: 15px;
line-height: 2.15;
letter-spacing: 0.015em;
}
.inbox-article-body p + p {
margin-top: 24px;
}
.inbox-no-results {
display: grid;
justify-items: start;
gap: 12px;
padding: 32px 20px;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.8;
}
.inbox-back {
display: none;
}
@media (min-width: 768px) and (max-width: 1100px) {
.inbox-sidebar {
width: 340px;
}
.inbox-article {
padding: 36px 28px;
}
}
@media (max-width: 767px) {
.inbox-sidebar {
width: 100%;
border-right: 0;
}
.inbox-reader {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-sidebar {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-reader {
display: flex;
}
.inbox-back {
display: inline-flex;
}
.inbox-reader-toolbar {
padding-inline: 12px;
}
.inbox-article {
padding: 30px 24px;
}
}
+138
View File
@@ -0,0 +1,138 @@
export type Article = {
id: string
title: string
summary: string
source: string
author: string
date: string
paragraphs: string[]
}
// All articles are original mock content. Editorial names are fictional.
export const articles: Article[] = [
{
id: 'agent-browser-workflow',
title: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
summary:
'コードを書いて終わりにしない。画面を開き、操作し、確かめるところまでをひとつの作業にする。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '09:40',
paragraphs: [
'UIをつくるとき、コードが正しいことと、画面が使いやすいことの間には距離がある。ボタンが画面の外に出ていないか。入力した文章は画面を切り替えても残るか。こうした問いへの答えは、実際にブラウザを動かして初めて見えてくる。',
'そこで試したいのが、実装と確認をひとつの短い循環にする進め方だ。エージェントに変更を依頼するとき、「記事を選び、詳細を読み、一覧に戻る」といった利用者の動作も添える。作業の終点がファイルの変更から、動作の確認へと具体的になる。',
'確認には、ふだんの画面幅と狭い画面幅の両方を使う。うまく動いた結果だけでなく、何を操作し、何を確認できなかったかも残しておくと、次の変更で同じ箇所を確かめやすい。最初から網羅するより、重要な操作をひとつ確実に通すことから始めたい。',
'人が担うのは、その体験が目的に合っているかを判断すること。操作の繰り返しをエージェントに任せると、余白や言葉、情報の順序といった、使い心地に向き合う時間を取りやすくなる。',
],
},
{
id: 'small-cli-tools',
title: '毎日使いたくなるCLIは、終了時のひと言まで設計されている',
summary:
'ヘルプ、エラー、実行結果。小さな道具の使い心地は、出力の細部で変わる。',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
date: '09:15',
paragraphs: [
'コマンドを実行したあと、何が起きたのか分からない。これは処理そのものが成功していても、使う側には不安が残る体験だ。作成したファイルの場所や処理件数を一行で伝えるだけで、道具とのやり取りはずっと明快になる。',
'エラーも、原因と次の行動が近くにあると扱いやすい。「設定が不正です」より、「出力先が見つかりません。存在するディレクトリを指定してください」のほうが、調べ直す手間を減らせる。内部の事情は必要なときにだけ表示できればよい。',
'人が読む出力と、別のプログラムが読む出力の用途も分けて考えたい。通常は簡潔な文章を返し、構造化された結果が必要なら専用の形式を選べるようにする。小さなCLIほど、こうした一貫性が長く使う理由になる。',
],
},
{
id: 'three-column-reading',
title: '3カラムの余白が、情報を読むリズムをつくる',
summary:
'場所を選ぶ、候補を眺める、ひとつを読む。役割が分かれた画面について。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '08:50',
paragraphs: [
'記事を探すとき、私たちはすべての文章を同じ深さで読んでいるわけではない。関心のある分野を選び、見出しを流し読みし、気になるものにだけ腰を据える。3カラムの画面は、この異なる読み方にそれぞれの場所を用意できる。',
'左は現在地、中央は候補、右は本文。役割が明確なら、記事を開いても一覧の中での位置を見失いにくい。選択中の項目を穏やかに示すことや、一覧のスクロール位置を保つことが、この安心感を支えている。',
'すべてのカラムを等しい幅にする必要はない。見出しを判断できる一覧の幅を確保し、残りを本文に渡す。本文にも適切な最大幅を設ければ、大きな画面で一行が長くなりすぎることを防げる。',
'狭い画面では、同じ役割を順番に見せればよい。一覧と本文を切り替えても、戻る場所が明確であること。カラムの数よりも、読む途中の文脈が続いていることが大切だ。',
],
},
{
id: 'agent-context',
title: 'エージェントに渡す文脈は、短くてもよい',
summary:
'大量の説明よりも、目的・制約・完了条件をそろえる。依頼を整理する小さな習慣。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '08:30',
paragraphs: [
'依頼を詳しく書こうとするほど、背景と希望と必須条件が混ざってしまうことがある。受け手がエージェントでも人でも、最初に知りたいのは何を実現する作業なのかという一点だ。',
'まず目的を一文で書き、次に変えてはいけない条件を添える。最後に、何を確かめたら作業が終わるのかを決める。参考ファイルやスクリーンショットは、その三つを補うために渡すと役割がはっきりする。',
'途中で判断が変わったときは、説明を足し続けるより、現在の方針を短く書き直すほうが役立つ場合がある。文脈の量ではなく、いま有効な判断を見つけやすいことを意識したい。',
],
},
{
id: 'local-first-notes',
title: 'ローカルで完結する、小さな情報収集ワークスペース',
summary:
'集めることより、あとから見つけられること。ファイルを中心にした道具の組み合わせ。',
source: 'Workbench Journal',
author: 'Workbench Journal 編集部',
date: '昨日',
paragraphs: [
'気になった記事を保存する場所は増えがちだ。ブラウザのブックマーク、メモアプリ、チャットの自分宛てメッセージ。集める入口が便利でも、読み返す入口が分かれていると、保存したこと自体を忘れてしまう。',
'小さく始めるなら、タイトル、出典、保存した理由だけをテキストで残す方法がある。本文をすべて保管する前に、なぜ自分が気になったのかを一行添えておく。その一行が、検索したときの判断材料になる。',
'ローカルのファイルを中心にすると、使い慣れた検索やエディタと組み合わせやすい。一方、複数の端末で扱うなら同期や競合の扱いを決める必要がある。道具を増やす前に、どこで読み、いつ振り返るのかを決めておきたい。',
],
},
{
id: 'quiet-design-system',
title: 'デザインシステムを、使う人の言葉で整える',
summary: 'コンポーネントをそろえた先にある、ラベルや空状態の一貫性。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '昨日',
paragraphs: [
'ボタンの角丸や色がそろっていても、同じ操作を画面ごとに違う言葉で呼んでいると迷いが生まれる。「保存」と「追加」が何を意味するのか。見た目と同じように、言葉にも共通のルールが必要だ。',
'最初に整理したいのは、よく使う操作と状態の名前だ。未読、保存済み、読み込み中などの基本的な表現を決めておくと、新しい画面をつくるときの判断が少なくなる。専門用語を画面に出す前に、使う人がその違いを知る必要があるかも考えたい。',
'空の一覧にも役割がある。何もない理由と、次にできることが簡潔に分かれば、空状態は行き止まりにならない。派手なイラストがなくても、適切な一文と操作があれば十分なことは多い。',
],
},
{
id: 'personal-feed',
title: 'おすすめの理由が見える、パーソナルな記事フィード',
summary:
'「なぜこの記事?」に答えられることが、興味に基づく推薦の出発点になる。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '昨日',
paragraphs: [
'興味に合う記事が届くと便利だが、その理由が分からないと、偶然なのか自分の行動が反映されたのか判断しづらい。推薦の根拠を短く添えるだけで、読む側は自分との接点を見つけやすくなる。',
'理由は複雑なスコアである必要はない。「選んだトピックに関連」「保存した記事と近いテーマ」など、実際に使っている情報と対応していることが大切だ。まだ推薦処理がない試作なら、その理由もサンプルであることを明示しておく。',
'関心は変化するので、利用者が調整できる入口も必要になる。トピックの追加や解除、読みたくない記事の非表示など、小さな操作から始められる。よいフィードは、当て続ける仕組みだけでなく、外れたときに直せる仕組みでもある。',
],
},
{
id: 'behavior-tests',
title: 'テストに残したいのは、実装よりも利用者の約束',
summary: '内部の構造を変えても守りたい動作を、短いシナリオで表す。',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
date: '2日前',
paragraphs: [
'コンポーネントを分割しただけなのに、多くのテストが壊れる。そんなときは、テストが利用者の操作よりも内部の構造に寄り添っていないかを見直したい。守りたいのは、どの関数が呼ばれたかではなく、期待する結果が得られたかという約束だ。',
'記事一覧なら、「トピックを選ぶと該当する記事が並ぶ」「記事を保存すると保存済みで見つけられる」といったシナリオを考えられる。画面に表示される名前で操作して、結果も画面から確かめると、何を保証するテストかが読み取りやすい。',
'もちろん、すべてをブラウザで確かめる必要はない。複雑な絞り込みの計算は小さな単位で検証し、代表的な操作の流れを画面で確認する。変更の影響に合わせて確認の範囲を選ぶことが、続けやすいテストにつながる。',
],
},
{
id: 'reading-typography',
title: '読みやすい本文は、文字サイズだけでは決まらない',
summary: '行の長さ、行間、段落の間隔。読み進めやすい記事画面のためのメモ。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '2日前',
paragraphs: [
'文字が十分に大きくても、一行が長すぎると次の行を見つけるのが難しくなる。逆に幅が狭すぎると、短い単語のまとまりまで細かく折り返され、読むリズムが途切れる。本文の幅は、文字サイズと一緒に考えたい。',
'行間は一行ごとの読みやすさを、段落の間隔は話のまとまりを支えている。同じ余白として扱わず、それぞれの役割が見えるように調整すると、装飾を増やさなくても文章の構造が伝わる。',
'調整するときは、短いダミーテキストだけで判断しない。長い見出しや複数段落の記事を置き、実際に数分読んでみる。画面の見栄えだけでは気づけない、読み疲れや視線の迷いを見つけやすくなる。',
],
},
]