Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,7 @@ Sorting and the rest:
- Decisions, with the count of questions that record a decision once sent.
- Settings.
- "A new version is ready, Restart" when health reports `update`. Restart is `POST /api/inbox/restart`: the server stops, the binary on disk starts detached, and the page reloads on the new `serverSession`.
- The frame is Workspaces' sidebar primitive, copied by the owner's word (2026-10-09) into `packages/inbox/shell/` (each file names its Workspaces source and commit): the toggle at the head of each page and ⌘B / Ctrl+B close and open it on Workspaces' one spring (`shell-motion.ts`, MotionValues from `motion`, reduced motion lands without travel); ⌘B does nothing in an input, a textarea or an editor; the state is the Inbox's own cookie `plannotator-inbox-sidebar-open` (open when absent). Closed, a 600 ms rest on the left edge peeks it (1024 px and wider). The edge collapses on a click and resizes on a drag (200 to 520 px, 232 by default, cookie `plannotator-inbox-sidebar-width`), closing past 100 px and reopening if the held pointer comes back. Below 768 px it is a sheet. A file beside a thread closes it on the same spring without changing the stored choice.
- A wide row shows:
- the project label
- the badge ("Stopped", "Holds up N")
Expand Down Expand Up @@ -2099,6 +2100,7 @@ Sorting and the rest:
- `inbox-attachments.spec.ts`
- `inbox-guides.spec.ts`
- `inbox-order.spec.ts` (three agents' questions read newest first in Waiting on you; a reply moves its row to Sent)
- `inbox-sidebar.spec.ts` (the sidebar's close and open sampled every frame, ⌘B and Ctrl+B, ⌘B left to the reply box, the reload, the peek, the edge's click, drag, snap-close and keyboard, reduced motion, the phone sheet; its PNGs, video and curves in `.local/proof/sidebar/`)
- `inbox-phones.spec.ts` (Pair a phone, the device list and Remove; Reach from this Wi-Fi with a pinned phone pairing over the LAN, the no-Bonjour note and `openssl` failing; with `PLANNOTATOR_E2E_TAILNET=1` on a Mac signed in to Tailscale, the door at the MagicDNS name on 8443)

PNGs land in `.local/proof/`.
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 34 additions & 17 deletions packages/inbox/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
import { configurePlannotatorUI } from '@plannotator/ui/configure';
import { storage } from '@plannotator/ui/utils/storage';
import type { InboxDecision, InboxHealth, InboxListRow, InboxListSection, InboxQuestion, InboxThread } from '@plannotator/core/inbox-types';
import {
InboxApiError,
Expand All @@ -28,6 +29,7 @@ import { filterSections, heldNotice, refreshHeldRows, waitingCount } from './hel
import { tildePath } from './format';
import type { ConnectContext } from './harnesses';
import { Sidebar } from './components/Sidebar';
import { SidebarShell } from './shell/SidebarShell';
import { InboxList } from './components/InboxList';
import { ThreadPane } from './components/Thread';
import { EmptyState } from './components/EmptyState';
Expand All @@ -54,6 +56,8 @@ configurePlannotatorUI({
},
});

const SIDEBAR_OPEN_COOKIE = 'plannotator-inbox-sidebar-open';

interface Route {
page: 'inbox' | 'settings' | 'decisions';
project: string | null;
Expand Down Expand Up @@ -122,6 +126,12 @@ function hasRows(sections: readonly InboxListSection[] | null): boolean {

function Inbox() {
const [route, setRoute] = useState<Route>(readRoute);
// Open or closed as the person left it (⌘B or the toggle): a cookie of the Inbox's own, open when absent.
const [sidebarOpen, setSidebarOpen] = useState(() => storage.getItem(SIDEBAR_OPEN_COOKIE) !== 'false');
const changeSidebar = useCallback((next: boolean) => {
setSidebarOpen(next);
storage.setItem(SIDEBAR_OPEN_COOKIE, String(next));
}, []);
const [latest, setLatest] = useState<ListModel | null>(null);
/** The list on screen: held until the person acts (the "N new" notice). */
const [shown, setShown] = useState<InboxListSection[] | null>(null);
Expand Down Expand Up @@ -477,24 +487,31 @@ function Inbox() {

return (
<div className="pn-inbox">
<div className={`ib-shell${fileOpen ? ' ib-fileopen' : ''}`}>
<Sidebar
page={route.page}
projectId={route.project}
inboxCount={waitingCount(latestSections)}
decisionsCount={latest?.decisions_waiting ?? 0}
projects={projects}
projectCounts={projectCounts}
updateReady={update !== null}
restarting={restarting}
onInbox={() => go({ page: 'inbox', project: null, thread: null })}
onProject={(projectId) => go({ page: 'inbox', project: projectId, thread: null })}
onSettings={() => go({ page: 'settings', project: null, thread: null })}
onDecisions={() => go({ page: 'decisions', project: null, thread: null })}
onRestart={() => void restart()}
/>
{/* A file beside the thread takes the sidebar's room (record 2.2): it closes on the same spring, and the person's choice stays as it was. */}
<SidebarShell
className="ib-shell"
open={sidebarOpen && !fileOpen}
onOpenChange={changeSidebar}
navigation={
<Sidebar
page={route.page}
projectId={route.project}
inboxCount={waitingCount(latestSections)}
decisionsCount={latest?.decisions_waiting ?? 0}
projects={projects}
projectCounts={projectCounts}
updateReady={update !== null}
restarting={restarting}
onInbox={() => go({ page: 'inbox', project: null, thread: null })}
onProject={(projectId) => go({ page: 'inbox', project: projectId, thread: null })}
onSettings={() => go({ page: 'settings', project: null, thread: null })}
onDecisions={() => go({ page: 'decisions', project: null, thread: null })}
onRestart={() => void restart()}
/>
}
>
<main className="ib-main">{main}</main>
</div>
</SidebarShell>
{guideMessage && thread && (
<GuidePane
thread={thread}
Expand Down
2 changes: 2 additions & 0 deletions packages/inbox/components/Decisions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { InboxDecision } from '@plannotator/core/inbox-types';
import { InboxApiError, type DecisionsModel, type ProjectFolder, type WaitingDecision } from '../api';
import { agentName, dateTime, dayWords, shortTime } from '../format';
import { DecisionDiamond, Icon } from '../icons';
import { SidebarTrigger } from '../shell/sidebar';

/**
* The project's decisions (the window record's 3.2): a project switch, then
Expand Down Expand Up @@ -280,6 +281,7 @@ export function DecisionsPage(props: DecisionsPageProps) {
<>
<section className={`ib-dpage${narrow ? ' ib-narrow' : ''}`} aria-label="Decisions">
<div className="ib-dh">
<SidebarTrigger />
<h1>Decisions</h1>
<select className="ib-select" aria-label="Project" value={project.id} onChange={(event) => props.onProject(event.target.value)}>
{props.projects.map((p) => (
Expand Down
2 changes: 2 additions & 0 deletions packages/inbox/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from 'react';
import { hostMcpSnippet, OTHER_HARNESSES, type ConnectContext } from '../harnesses';
import { HostMark } from '../icons';
import { CodeBox, ConnectPicker } from './ConnectPicker';
import { SidebarTrigger } from '../shell/sidebar';

type PlannotatorHost = 'claude-code' | 'pi' | 'opencode';

Expand All @@ -22,6 +23,7 @@ export function EmptyState({ context }: { context: ConnectContext | null }) {
return (
<section className="ib-listcol" aria-label="Inbox">
<div className="ib-lhead">
<SidebarTrigger />
<h1>Inbox</h1>
</div>
<div className="ib-lbody">
Expand Down
2 changes: 2 additions & 0 deletions packages/inbox/components/InboxList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react';
import type { InboxListRow, InboxListSection } from '@plannotator/core/inbox-types';
import { agentName, questionCount, shortTime } from '../format';
import { AuthorMark, Icon } from '../icons';
import { SidebarTrigger } from '../shell/sidebar';

export interface InboxListProps {
title: string;
Expand Down Expand Up @@ -104,6 +105,7 @@ export function InboxList(props: InboxListProps) {
aria-label={props.title}
>
<div className="ib-lhead">
<SidebarTrigger />
<h1>{props.title}</h1>
{props.path && <span className="ib-path">{props.path}</span>}
</div>
Expand Down
6 changes: 5 additions & 1 deletion packages/inbox/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HARNESSES, type ConnectContext } from '../harnesses';
import { HostMark, Icon } from '../icons';
import { ConnectPicker } from './ConnectPicker';
import { PhonesBlock } from './Phones';
import { SidebarTrigger } from '../shell/sidebar';

const TOOL_HOSTS: { host: AgentToolHost; name: string }[] = [
{ host: 'claude-code', name: 'Claude Code' },
Expand Down Expand Up @@ -185,7 +186,10 @@ export function SettingsPage({
return (
<div className="ib-spage" aria-label="Settings">
<div className="ib-spage-in">
<h1>Settings</h1>
<div className="ib-shead">
<SidebarTrigger />
<h1>Settings</h1>
</div>
<div className="ib-sblock">
<h2>The Inbox in your agent's tools</h2>
<p>
Expand Down
19 changes: 15 additions & 4 deletions packages/inbox/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ProjectFolder } from '../api';
import { Icon } from '../icons';
import { useSidebar } from '../shell/sidebar';
import { TaterMark } from './TaterMark';

export interface SidebarProps {
Expand All @@ -22,8 +23,18 @@ export interface SidebarProps {
onRestart: () => void;
}

/**
* The Inbox's navigation: the Tater mark, the sections, the projects and
* Settings. Drawn inside Workspaces' sidebar primitive (`../shell`): docked,
* in the hover peek, and as the sheet on a phone width, which a pick closes.
*/
export function Sidebar(props: SidebarProps) {
const firstRun = props.projects.length === 0;
const { setOpenMobile } = useSidebar();
const pick = (go: () => void) => () => {
setOpenMobile(false);
go();
};
return (
<aside className="ib-side" aria-label="Inbox navigation">
<div className="ib-brand">
Expand All @@ -34,7 +45,7 @@ export function Sidebar(props: SidebarProps) {
<button
type="button"
className={`ib-nav${props.page === 'inbox' && props.projectId === null ? ' ib-on' : ''}`}
onClick={props.onInbox}
onClick={pick(props.onInbox)}
aria-current={props.page === 'inbox' && props.projectId === null ? 'page' : undefined}
>
<Icon name="inbox" />
Expand All @@ -44,7 +55,7 @@ export function Sidebar(props: SidebarProps) {
<button
type="button"
className={`ib-nav${props.page === 'decisions' ? ' ib-on' : ''}`}
onClick={props.onDecisions}
onClick={pick(props.onDecisions)}
disabled={firstRun}
aria-current={props.page === 'decisions' ? 'page' : undefined}
>
Expand All @@ -65,7 +76,7 @@ export function Sidebar(props: SidebarProps) {
key={project.id}
type="button"
className={`ib-proj${n === 0 ? ' ib-q' : ''}${on ? ' ib-on' : ''}`}
onClick={() => props.onProject(project.id)}
onClick={pick(() => props.onProject(project.id))}
aria-current={on ? 'page' : undefined}
title={project.root}
>
Expand All @@ -91,7 +102,7 @@ export function Sidebar(props: SidebarProps) {
<button
type="button"
className={`ib-nav${props.page === 'settings' ? ' ib-on' : ''}`}
onClick={props.onSettings}
onClick={pick(props.onSettings)}
aria-current={props.page === 'settings' ? 'page' : undefined}
>
<Icon name="settings" />
Expand Down
60 changes: 55 additions & 5 deletions packages/inbox/inbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
.ib-mk.ib-big > svg, .ib-mk.ib-big > img { width: 17px; height: 17px; }

/* shell */
.ib-shell { display: grid; height: 100%; grid-template-columns: 232px 1fr; }
.ib-shell { display: flex; height: 100%; width: 100%; overflow: hidden; }
.ib-shell > * { min-height: 0; overflow: hidden; }
.ib-side { background: var(--background); border-right: 1px solid var(--ib-line); display: flex; flex-direction: column; padding: 14px 10px 12px; }
.ib-brand { display: flex; align-items: center; gap: 9px; padding: 0 6px 10px; }
Expand Down Expand Up @@ -105,7 +105,7 @@
.ib-restart b { font-weight: 550; color: var(--primary); margin-left: auto; }

/* main list */
.ib-main { background: var(--card); display: flex; min-width: 0; height: 100%; }
.ib-main { background: var(--card); display: flex; flex: 1; min-width: 0; height: 100%; }
.ib-listcol { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.ib-listcol.ib-narrow { flex: none; width: 380px; border-right: 1px solid var(--ib-line); }
.ib-lhead { height: 60px; display: flex; align-items: center; gap: 12px; padding: 0 22px; flex: none; }
Expand Down Expand Up @@ -238,7 +238,8 @@
/* settings */
.ib-spage { padding: 28px 44px; flex: 1; overflow-y: auto; min-width: 0; }
.ib-spage-in { max-width: 812px; }
.ib-spage h1 { font-size: 22px; font-weight: 650; margin: 0 0 22px; letter-spacing: -0.01em; }
.ib-spage h1 { font-size: 22px; font-weight: 650; margin: 0; letter-spacing: -0.01em; }
.ib-shead { display: flex; align-items: center; gap: 12px; margin: 0 0 22px; }
.ib-sblock { padding: 0 0 20px; margin-bottom: 20px; border-bottom: 1px solid color-mix(in oklch, var(--border) 60%, transparent); }
.ib-sblock:last-child { border-bottom: 0; }
.ib-sblock h2 { font-size: 15px; font-weight: 600; margin: 0 0 4px; }
Expand Down Expand Up @@ -337,8 +338,6 @@ button.ib-drow:hover { background: color-mix(in oklch, var(--muted) 35%, transpa

/* attachments (step 2, record 2.1 to 2.8) */
.ib-att-t .ib-ch { font-size: 12px; color: var(--warning); }
.ib-shell.ib-fileopen { grid-template-columns: 1fr; }
.ib-shell.ib-fileopen > .ib-side { display: none; }
.ib-pane.ib-withfile { flex: none; width: 440px; border-right: 1px solid var(--ib-line); z-index: 2; }
.ib-pane.ib-withfile .ib-annpop { width: 400px; }
.ib-pane.ib-withfile .ib-pbody { padding: 16px 20px 0; }
Expand Down Expand Up @@ -453,3 +452,54 @@ pre code.pn-code[data-keep-layout] {
.ib-state h5 { margin: 0 0 6px; font-size: 14px; font-weight: 600; }
.ib-state p { margin: 0 0 10px; font-size: 13px; line-height: 1.55; color: var(--muted-foreground); }
.pn-inbox .ib-menu:hover .ib-mi.ib-on:not(:hover) { background: transparent; }

/* The sidebar: Workspaces' primitive (packages/inbox/shell, the owner 2026-10-09),
its geometry from apps/web/src/components/ui/sidebar.tsx, SidebarPeek.tsx,
CollapsibleEdge.tsx and ResizeHandle.tsx, on the Inbox's skin (.ib-side above).
The open and close ride one spring on MotionValues (shell/shell-motion.ts);
only the peek's slide is CSS, as it is in Workspaces. The tokens sit on :root
because the edge's tooltip portals to the body. */
:root {
--ib-ease-enter: cubic-bezier(0.22, 1, 0.36, 1);
--ib-shadow-sheet: 0 0 0 1px oklch(0.90 0.02 260 / 0.13), 0 9px 22px rgb(0 0 0 / 0.15), 0 28px 72px -12px rgb(0 0 0 / 0.16);
--ib-shadow-tooltip: 0 0 0 1px oklch(0.90 0.02 260 / 0.09), 0 1px 2px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.10);
}
:root.light {
--ib-shadow-sheet: 0 0 0 1px oklch(0.35 0.02 260 / 0.13), 0 8px 22px oklch(0.20 0.02 260 / 0.09), 0 24px 64px -12px oklch(0.20 0.02 260 / 0.15);
--ib-shadow-tooltip: 0 0 0 1px oklch(0.35 0.02 260 / 0.12), 0 1px 2px oklch(0.20 0.02 260 / 0.05), 0 2px 6px oklch(0.20 0.02 260 / 0.04);
}
.ib-sb { flex: none; }
.ib-sb-gap { position: relative; height: 100%; }
.ib-sb-panel { position: fixed; top: 0; bottom: 0; left: 0; z-index: 10; display: flex; overflow: hidden; }
/* Settled closed: inert already, and invisible as a whole, so the window holds one navigation. */
.ib-sb-panel[data-hidden] { visibility: hidden; }
.ib-sb-inner { display: flex; flex-direction: column; flex-shrink: 0; height: 100%; }
.ib-sb-inner > .ib-side, .ib-peek-in > .ib-side, .ib-sb-sheet-in > .ib-side { flex: 1; min-height: 0; }
.pn-inbox .ib-sb-trigger { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 32px; height: 32px; border-radius: 7px; color: var(--muted-foreground); }
.pn-inbox .ib-sb-trigger:hover { background: var(--muted); color: var(--foreground); }
.ib-sb-trigger svg { width: 16px; height: 16px; }
/* Phone widths: the sheet, on the same spring (opacity and x ride it). */
.ib-sb-scrim { position: fixed; inset: 0; z-index: 50; background-color: rgb(0 0 0 / 0.45); backdrop-filter: blur(2px); }
.ib-sb-sheet { position: fixed; top: 0; bottom: 0; left: 0; z-index: 50; display: flex; flex-direction: column; width: var(--sidebar-width); box-shadow: var(--ib-shadow-sheet); outline: none; }
.ib-sb-sheet-in { display: flex; flex-direction: column; width: 100%; height: 100%; }
.ib-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-transparency: reduce) {
.ib-sb-scrim { backdrop-filter: none; background-color: rgb(0 0 0 / 0.65); }
}
/* The peek: a 20 px hover strip on the left edge, then the panel slides in (300 ms, enter easing). */
.ib-peek-strip { position: fixed; top: 0; bottom: 0; left: 0; z-index: 10; width: 20px; }
.ib-peek { position: fixed; top: 0; bottom: 0; left: 0; z-index: 50; width: var(--sidebar-width); box-shadow: var(--ib-shadow-sheet); transform: translateX(-100%); visibility: hidden; will-change: transform; transition: transform 300ms var(--ib-ease-enter), visibility 0s linear 300ms; }
.ib-peek[data-visible] { transform: translateX(0); visibility: visible; transition: transform 300ms var(--ib-ease-enter); }
.ib-peek-in { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.ib-peek, .ib-peek[data-visible] { transition: none; }
}
/* The edge: click to collapse, drag to resize, a separator for the keyboard. */
.ib-shell > .ib-edge-pos { position: fixed; top: 0; bottom: 0; z-index: 30; display: flex; overflow: visible; }
.ib-edge { position: relative; display: flex; width: 0; flex-shrink: 0; outline: none; }
.ib-handle { position: relative; z-index: 10; width: 0; flex-shrink: 0; cursor: col-resize; }
.ib-handle-track { position: absolute; top: 0; bottom: 0; left: -2px; right: -2px; transition: background-color 150ms; }
.ib-handle-hit { position: absolute; top: 0; bottom: 0; left: -8px; right: -8px; }
.ib-edge:focus-visible [data-resize-track] { background-color: var(--ring); }
.ib-edge-tip-pos { z-index: 50; }
.ib-edge-tip { background: var(--popover); color: var(--muted-foreground); box-shadow: var(--ib-shadow-tooltip); border-radius: 6px; padding: 4px 8px; font-family: var(--font-sans); font-size: 11px; font-weight: 500; }
2 changes: 2 additions & 0 deletions packages/inbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
"#surface-guide-reader": "./guide/SurfaceGuideReader.tsx"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@plannotator/core": "workspace:*",
"@plannotator/guide-viewer": "workspace:*",
"@plannotator/review-editor": "workspace:*",
"@plannotator/ui": "workspace:*",
"motion": "^12.38.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"uqr": "^0.1.3"
Expand Down
Loading
Loading