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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ jobs:
packages/inbox/actionChecklist.test.tsx
packages/inbox/wordsOnlyTwoAsks.test.tsx
packages/inbox/firstRunNotifications.test.tsx
packages/inbox/tour.test.tsx
packages/inbox/sentReply.test.tsx
packages/inbox/skillHighlightSent.test.ts
packages/inbox/threadFoot.test.tsx
Expand Down
14 changes: 14 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions packages/core/inbox-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,35 @@ export function isInboxSoundVolume(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= INBOX_SOUND_MIN_VOLUME && value <= INBOX_SOUND_MAX_VOLUME;
}

/**
* The onboarding tour (approval "inbox-onboarding-tour"; config.json
* `inboxTour`, through GET and POST /api/inbox/settings `tour`), kept per
* machine and written by the server only. `seen_at`: the tour was started;
* `finished_at`: it reached the end; `skipped_at`: the person left it, or
* chose "Connect my agents" on the entry screen. Once finished or skipped the
* first-run screen no longer opens on the entry screen; a quiet "Take the
* tour" link stays. `notify_again`: the tour ended with notifications not on,
* so the person's first real thread offers them once more, inline.
*/
export interface InboxTourState {
version: number;
seen_at: string | null;
finished_at: string | null;
skipped_at: string | null;
notify_again: boolean;
}

/** What POST /api/inbox/settings `tour` takes: each `true` stamps now; `notify_again` sets the flag. */
export interface InboxTourChange {
seen?: boolean;
finished?: boolean;
skipped?: boolean;
notify_again?: boolean;
}

/** The tour's content version: the fixture's `v`, recorded with each stamp. */
export const INBOX_TOUR_VERSION = 1;

/**
* The Inbox window's text size (config.json `inboxTextSize`, through GET and
* POST /api/inbox/settings `text_size`): the body size in px the whole type
Expand Down
429 changes: 384 additions & 45 deletions packages/inbox/App.tsx

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions packages/inbox/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type {
InboxQuestion,
InboxSoundSettings,
InboxTextSize,
InboxTourChange,
InboxTourState,
InboxThread,
InboxComposeProject,
InboxDraft,
Expand Down Expand Up @@ -226,6 +228,8 @@ export interface SettingsModel {
diff_style?: InboxDiffStyle | null;
/** Experimental features, each off unless switched on (`env`: PLANNOTATOR_EXPERIMENTAL decides). Absent from an older Inbox. */
experimental?: InboxExperimentalSettings;
/** The onboarding tour (config.json `inboxTour`): started, finished, skipped. Absent from an Inbox that predates it. */
tour?: InboxTourState;
store: {
dir: string;
bytes: number;
Expand Down Expand Up @@ -726,6 +730,10 @@ export const inboxApi = {
claimSoundDialog: () => post<{ show: boolean; sound: InboxSoundSettings }>('/api/inbox/sound/dialog', {}),
/** Plannotator's appearance, written to config.json `theme` (shared with plan review). */
saveTheme: (theme: InboxThemeConfig) => post<{ theme: InboxThemeConfig | null }>('/api/inbox/settings', { theme }),
/** The onboarding tour: each `true` stamps now (started, finished, skipped); `notify_again` sets the first thread's ask. */
saveTour: (change: InboxTourChange) => post<{ tour: InboxTourState }>('/api/inbox/settings', { tour: change }),
/** The tour's curated fixture (packages/inbox/tour/content/fixture.json), served from the binary on demand. */
tourFixture: () => get<unknown>('/api/inbox/tour/fixture.json'),
/** The window's text size, written to config.json `inboxTextSize`. */
saveTextSize: (textSize: InboxTextSize) => post<{ text_size: InboxTextSize }>('/api/inbox/settings', { text_size: textSize }),
/** The diff layout, written to config.json `diffOptions.diffStyle` (shared with code review). */
Expand Down
35 changes: 31 additions & 4 deletions packages/inbox/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ export interface EmptyStateProps {
* when (never while a one-time dialog is open).
*/
ask?: ReactNode;
/** The screen's heading in place of "No agent has written yet" (the tour's step 6: "Now, let's connect your agents"). */
heading?: string;
/** A line above everything (the tour's step 6: back in the person's own Inbox). */
lead?: string;
/**
* "Want to see it work first? Take the 2-minute tour" at the screen's foot
* (approval "inbox-onboarding-tour"): once the tour was finished or left,
* the entry screen no longer opens and this quiet link stays. Absent: none.
*/
onTakeTour?: () => void;
/** Try it's prompt to copy in place of the test message (the tour's step 6: one real question, with pictures). */
tryPrompt?: string;
}

/**
Expand All @@ -209,7 +221,7 @@ export interface EmptyStateProps {
* plugin is a command to copy, and the screen moves on by itself once the
* agents are read again (App re-reads them while this screen shows).
*/
export function EmptyState({ context, agents, agentsUnavailable, onRetry, sessions, onAgents, onMessage, ask }: EmptyStateProps) {
export function EmptyState({ context, agents, agentsUnavailable, onRetry, sessions, onAgents, onMessage, ask, heading, lead, onTakeTour, tryPrompt }: EmptyStateProps) {
const [skipped, setSkipped] = useState<string[]>([]);
const [busy, setBusy] = useState<InboxAgentId | null>(null);
const [error, setError] = useState<{ id: InboxAgentId; text: string } | null>(null);
Expand Down Expand Up @@ -469,7 +481,13 @@ export function EmptyState({ context, agents, agentsUnavailable, onRetry, sessio
</div>
<div className="ib-lbody">
<div className="ib-empty" data-inbox-empty="" aria-busy={loading || undefined}>
<h2>No agent has written yet</h2>
{lead && (
<p className="ib-tour-lead" role="status">
<Icon name="check" size={14} />
{lead}
</p>
)}
<h2 tabIndex={-1}>{heading ?? 'No agent has written yet'}</h2>
{hero}
{lists}
{!loading && (
Expand Down Expand Up @@ -499,8 +517,8 @@ export function EmptyState({ context, agents, agentsUnavailable, onRetry, sessio
<p>{who}</p>
)}
<div className="ib-try-quote">
<span>{TRY_PROMPT}</span>
<CopyButton text={TRY_PROMPT} />
<span>{tryPrompt ?? TRY_PROMPT}</span>
<CopyButton text={tryPrompt ?? TRY_PROMPT} />
</div>
{ask && <div data-first-run-ask="">{ask}</div>}
</div>
Expand All @@ -510,6 +528,15 @@ export function EmptyState({ context, agents, agentsUnavailable, onRetry, sessio
<ConnectPicker harnesses={OTHER_HARNESSES} initial="vscode" context={context} icons />
</Fold>
)}
{onTakeTour && (
<p className="ib-tour-again" data-tour-again="">
Want to see it work first?{' '}
<button type="button" className="ib-lnk" onClick={onTakeTour}>
Take the 2-minute tour
</button>
: how we built the Inbox with the Inbox.
</p>
)}
<div className="ib-sr" aria-live="polite">
{said}
</div>
Expand Down
27 changes: 27 additions & 0 deletions packages/inbox/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const SHOWN_PROJECTS = 2;

export interface SettingsPageProps {
settings: SettingsModel | null;
/**
* Settings → General's first group, "Getting started" (approval
* "inbox-onboarding-tour", returning users): Take the tour. Absent: none.
*/
onTakeTour?: () => void;
context: ConnectContext | null;
error: string | null;
onToggleTool: (host: AgentToolHost, next: boolean) => void;
Expand Down Expand Up @@ -180,8 +185,10 @@ function GeneralSection({
onTestNotification,
sound,
onToggleExperimental,
onTakeTour,
}: {
onToggleExperimental?: (next: boolean) => void;
onTakeTour?: () => void;
settings: SettingsModel;
permission: NotificationPermission | 'unsupported';
onToggleNotifications: (next: boolean) => void;
Expand All @@ -203,6 +210,25 @@ function GeneralSection({
: reachText({ here: notificationsOn, enabled: settings.notifications.enabled, reach, origin: typeof window !== 'undefined' ? window.location.origin : '' });
return (
<>
{onTakeTour && (
<section className="ib-sgroup" data-settings-tour="" aria-labelledby="ib-g-start">
<h2 id="ib-g-start">Getting started</h2>
<div className="ib-card">
<div className="ib-prow">
<span className="ib-txt">
<span className="ib-l">Tour</span>
<span className="ib-desc">See how the Inbox works through real threads from building Plannotator · 2 min</span>
</span>
<span className="ib-ctl">
<button type="button" className="ib-btn" onClick={onTakeTour} data-settings-take-tour="">
<Icon name="play" size={14} />
Take the tour
</button>
</span>
</div>
</div>
</section>
)}
<section className="ib-sgroup" data-settings-theme={mode} aria-labelledby="ib-g-appearance">
<h2 id="ib-g-appearance">Appearance</h2>
<div className="ib-card">
Expand Down Expand Up @@ -657,6 +683,7 @@ export function SettingsPage(props: SettingsPageProps) {
onTestNotification={onTestNotification}
sound={sound}
onToggleExperimental={props.onToggleExperimental}
onTakeTour={props.onTakeTour}
/>
);
break;
Expand Down
4 changes: 3 additions & 1 deletion packages/inbox/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export interface SidebarProps {
* the connect prompt names; null until the settings load.
*/
mcpCommand?: readonly string[] | null;
/** The foot's Copy prompt pill (default true). The onboarding tour's sidebar leaves it out: it is for the person's own agents. */
connectPrompt?: boolean;
}

/**
Expand Down Expand Up @@ -362,7 +364,7 @@ export function Sidebar(props: SidebarProps) {
</div>
<div className="ib-side-foot">
{/* Copy prompt first, then the Update button or "What's new in X", then the Settings row. */}
<CopyConnectPrompt mcpCommand={props.mcpCommand ?? null} />
{props.connectPrompt !== false && <CopyConnectPrompt mcpCommand={props.mcpCommand ?? null} />}
<UpdateButton
update={props.update}
restarting={props.restarting}
Expand Down
27 changes: 23 additions & 4 deletions packages/inbox/components/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ export function MessageBody({
comments,
sentAnswers,
onJumpQuestion,
imageBase,
onImage,
pictureBadge,
}: {
message: InboxMessageWire;
/** The thread's project root: relative links start there or at the message's own folder. */
Expand Down Expand Up @@ -313,6 +316,16 @@ export function MessageBody({
sentAnswers?: readonly SentAnswerGroup[];
/** A row of `sentAnswers` was pressed: show its question. */
onJumpQuestion?: (target: JumpTarget) => void;
/**
* Where the body's images load from, in place of the message's own image
* route (`inboxMessageImageBase`): the onboarding tour's assets
* (`inbox-tour:`, images.ts). Absent: the message's route.
*/
imageBase?: string;
/** An image the body shows (a picture choice's zoom included) was opened: the host shows it. Absent: the thread's viewer. */
onImage?: (src: string, alt: string) => void;
/** A label over each picture choice's picture (ui QuestionBlock `pictureBadge`); absent: none. */
pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
}) {
// A person's message ends with the skills block the window pasted when it
// was sent: folded behind one quiet line (SkillsLine). An agent's message
Expand Down Expand Up @@ -368,7 +381,9 @@ export function MessageBody({
},
[imageOf, message.id],
);
const onImageClick = useCallback((src: string, alt: string) => void openImage(src, alt), [openImage]);
const onImageRef = useRef(onImage);
onImageRef.current = onImage;
const onImageClick = useCallback((src: string, alt: string) => (onImageRef.current ? onImageRef.current(src, alt) : void openImage(src, alt)), [openImage]);
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
Expand Down Expand Up @@ -607,7 +622,7 @@ export function MessageBody({
maxWidth={null}
stickyActions={false}
disableCodePathValidation
imageBaseDir={inboxMessageImageBase(message.id)}
imageBaseDir={imageBase ?? inboxMessageImageBase(message.id)}
onImageClick={onImageClick}
resolveLocalLink={resolveLocalLink}
refRepo={refRepo}
Expand All @@ -623,6 +638,7 @@ export function MessageBody({
questionContractLines
questionReadOnly={answerLocked}
questionStatusLabel={statusLabel}
questionPictureBadge={pictureBadge}
renderQuestionCard={renderReplacedCard ? questionCard : undefined}
renderQuestionBelowPrompt={renderReplacesLine ? belowPrompt : undefined}
renderDirectiveBlock={renderActions ? directiveBlock : undefined}
Expand All @@ -644,7 +660,7 @@ export function MessageBody({
maxWidth={null}
stickyActions={false}
disableCodePathValidation
imageBaseDir={inboxMessageImageBase(message.id)}
imageBaseDir={imageBase ?? inboxMessageImageBase(message.id)}
onImageClick={onImageClick}
resolveLocalLink={resolveLocalLink}
refRepo={refRepo}
Expand All @@ -670,6 +686,8 @@ export function MessageBody({
}

export interface ThreadPaneProps {
/** A line under the subject, from the window (the tour's notifications ask, once more, in the first real thread). */
notice?: React.ReactNode;
thread: InboxThread;
/**
* The thread's list row's `sent`: the person's reply is the last message
Expand Down Expand Up @@ -949,7 +967,7 @@ async function replyLanded(threadId: string, key: string): Promise<'yes' | 'no'

const THREAD_GONE_TEXT = 'This thread was deleted, so nothing was sent.';

export function ThreadPane({ thread, sent, onQuestions, onChanged, draftChanged = 0, onClose, file, onOpenFile, onOpenGuide, lookSeq, seenThrough = null, onSeen, covered = false, onScreenChange, row, onMarkRead, onAck, onAction, onArchive, pinned = false, onPin, replyRequest, onReplyOpened, backLabel = 'Inbox', phone = false, stacked = false, fileStacked = false, remote = false, onBackToList, onToast, landing = null, onLanded, artifacts: artifactsRoute = false, onArtifacts }: ThreadPaneProps) {
export function ThreadPane({ notice, thread, sent, onQuestions, onChanged, draftChanged = 0, onClose, file, onOpenFile, onOpenGuide, lookSeq, seenThrough = null, onSeen, covered = false, onScreenChange, row, onMarkRead, onAck, onAction, onArchive, pinned = false, onPin, replyRequest, onReplyOpened, backLabel = 'Inbox', phone = false, stacked = false, fileStacked = false, remote = false, onBackToList, onToast, landing = null, onLanded, artifacts: artifactsRoute = false, onArtifacts }: ThreadPaneProps) {
// "Open in" (OpenIn.tsx): apps on this computer; a new tab only through the tailnet or on a phone.
const openIn: OpenInSurface | undefined = phone || remote === true ? 'tab' : remote === null ? undefined : 'apps';
const root = thread.messages[0]!;
Expand Down Expand Up @@ -3084,6 +3102,7 @@ export function ThreadPane({ thread, sent, onQuestions, onChanged, draftChanged
<div className={`ib-pbody${one ? '' : ' ib-folding'}`} ref={bodyRef} hidden={artifactsOn}>
{/* A focus target (the way back's fallback, a stacked thread), never a tab stop. */}
<h2 className="ib-subject" dir="auto" tabIndex={-1}>{thread.subject ?? '(no subject)'}</h2>
{notice}
{startedTo && (
<div className="ib-tmeta" data-started-by="">
<span>You started this thread</span>
Expand Down
15 changes: 15 additions & 0 deletions packages/inbox/components/Updates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,12 +512,19 @@ export function WhatsNewPage({
update,
onBack,
onUpdate,
onTakeTour,
}: {
model: WhatsNewModel | null;
error: string | null;
update: InboxHealth['update'];
onBack: () => void;
onUpdate: () => void;
/**
* The quiet line under the notes (approval "inbox-onboarding-tour",
* returning users: no dialog): "New: a 2-minute tour…". The window passes
* it while the tour was never taken. Absent: no line.
*/
onTakeTour?: () => void;
}) {
const blocks = useMemo(() => parseMarkdownToBlocks(model?.notes ?? '', { frontmatter: false }), [model?.notes]);
// The notes' images load from the copies the Inbox saved (never from GitHub:
Expand Down Expand Up @@ -606,6 +613,14 @@ export function WhatsNewPage({
) : (
model && <p className="ib-sum">The release has no notes. They are on GitHub.</p>
)}
{model && onTakeTour && (
<p className="ib-wn-tour" data-whats-new-tour="">
New: a 2-minute tour of how we built the Inbox with the Inbox.{' '}
<button type="button" className="ib-lnk" onClick={onTakeTour}>
Take the tour
</button>
</p>
)}
{/* The skipped releases come after this release's notes, folded when there are more than a few (decision 7). */}
{model && model.skipped.length > 0 && <SkippedReleases since={model.since ?? model.current} skipped={model.skipped} />}
</div>
Expand Down
4 changes: 4 additions & 0 deletions packages/inbox/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const PATHS = {
arrowUp: 'M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18',
// The question sound (Heroicons outline): play, and cursor-arrow-rays for "click once in this tab".
film: 'M6 5.25h12a2.25 2.25 0 0 1 2.25 2.25v9a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25v-9A2.25 2.25 0 0 1 6 5.25ZM7.5 5.25v13.5M16.5 5.25v13.5M3.75 9.75H7.5M3.75 14.25H7.5M16.5 9.75h3.75M16.5 14.25h3.75',
/** The onboarding tour's tag (Heroicons outline "map"). */
map: 'M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z',
/** A replay of a real thread (Heroicons outline "arrow-path"). */
replay: 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99',
play: 'M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z',
clickHere:
'M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59',
Expand Down
7 changes: 7 additions & 0 deletions packages/inbox/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
import type { ImageSrcResolver } from '@plannotator/ui/configure';

const MESSAGE_BASE_PREFIX = 'inbox-message:';
/** The onboarding tour's messages (tour/): their images are the tour's own assets, by file name only. */
export const TOUR_IMAGE_BASE = 'inbox-tour:';
const TOUR_ASSET_RE = /^[a-z0-9][a-z0-9.-]*\.webp$/;
/** `<data dir>/inbox/markups/<sha256>.<ext>`, the path a marked-up image comment carries. */
const INBOX_MARKUP_PATH_RE = /[\\/]inbox[\\/]markups[\\/]([0-9a-f]{64}\.(?:png|jpg|webp))$/;
const MESSAGE_ID_RE = /^msg_[0-9A-Za-z]+$/;
Expand Down Expand Up @@ -57,6 +60,10 @@ export const inboxImageSrcResolver: ImageSrcResolver = (path, base) => {
// Inbox's data dir, shown back by its file name only.
const markup = INBOX_MARKUP_PATH_RE.exec(path);
if (markup) return `/api/inbox/markups/${markup[1]}`;
if (base === TOUR_IMAGE_BASE) {
// A tour picture loads from the tour's read-only asset route; anything else in a tour message loads nothing.
return TOUR_ASSET_RE.test(path) ? `/api/inbox/tour/assets/${encodeURIComponent(path)}` : `/api/inbox/tour/assets/none`;
}
if (base?.startsWith(MESSAGE_BASE_PREFIX)) {
const id = base.slice(MESSAGE_BASE_PREFIX.length);
if (MESSAGE_ID_RE.test(id)) return inboxMessageImageUrl(id, path);
Expand Down
Loading
Loading