From c0f3caab882a258775cc6461c1fd203ad645f2ca Mon Sep 17 00:00:00 2001 From: itelo Date: Tue, 1 Sep 2026 12:25:39 -0300 Subject: [PATCH 1/2] feat(wizard): expand the tips deck to 10 cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tips column rotates a card every 8 seconds while the wizard works, but there were only three, so a run of any length repeated them after 24 seconds. Add seven cards and rework one. The new ones cover what a developer most often has to ask next: that one API spans every brand, that grants are preferred over raw access codes, what a Connect Webview does, that a sandbox has virtual devices to build against, that events beat polling, and that writes return an action attempt because a lock may be asleep. The old "While this runs" card becomes "Where to go next" and picks up a pointer to the Console assistant. The column is 30 wide and prints each line after a leading space, so an over-wide line wraps and shears the two-column layout — visible only in a real terminal. LEARN_CARDS_MAX_LINE_LENGTH and its test make that a CI failure instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/screens/tips.test.tsx | 50 ++++++++++++++++++ src/lib/screens/tips.tsx | 98 +++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/lib/screens/tips.test.tsx diff --git a/src/lib/screens/tips.test.tsx b/src/lib/screens/tips.test.tsx new file mode 100644 index 0000000..4efc7cb --- /dev/null +++ b/src/lib/screens/tips.test.tsx @@ -0,0 +1,50 @@ +import { render } from 'ink-testing-library' +import { expect, test } from 'vitest' + +import { + LEARN_CARD_SECONDS, + LEARN_CARDS, + LEARN_CARDS_MAX_LINE_LENGTH, + Tips, +} from './tips.js' + +// Every rendered line is printed after a leading space inside a 30-wide column. +// A line over the limit wraps and shears the two-column layout, which is only +// visible in a real terminal — so it is asserted here instead. +test('LEARN_CARDS: every title and line fits the Tips column', () => { + const tooWide = LEARN_CARDS.flatMap((card) => + [card.title, ...card.lines] + .map((line) => ({ line, width: 1 + [...line].length })) + .filter(({ width }) => width > LEARN_CARDS_MAX_LINE_LENGTH), + ) + + expect(tooWide).toEqual([]) +}) + +test('LEARN_CARDS: every card has a title and a body', () => { + for (const card of LEARN_CARDS) { + expect(card.title).not.toBe('') + expect(card.lines.some((line) => line !== '')).toBe(true) + } +}) + +// The rotation in IntegrateProgress indexes by elapsed time modulo the card +// count, so an empty deck would render nothing at all. +test('LEARN_CARDS: the deck covers a long run', () => { + expect(LEARN_CARDS).toHaveLength(10) + expect(LEARN_CARDS.length * LEARN_CARD_SECONDS).toBeGreaterThanOrEqual(60) +}) + +test('Tips: renders the card title and its lines', () => { + const card = { title: 'How Seam works', lines: ['An Access Grant', 'PIN'] } + + const { lastFrame, unmount } = render() + try { + const frame = lastFrame() ?? '' + expect(frame).toContain('How Seam works') + expect(frame).toContain('An Access Grant') + expect(frame).toContain('PIN') + } finally { + unmount() + } +}) diff --git a/src/lib/screens/tips.tsx b/src/lib/screens/tips.tsx index 6413660..07ccd0f 100644 --- a/src/lib/screens/tips.tsx +++ b/src/lib/screens/tips.tsx @@ -8,6 +8,10 @@ export interface LearnCard { // Educational cards shown in the Tips column while the wizard works. Shared by // the running two-column frame and the integration Tasks screen. +// +// The column is 30 wide and every line is printed after a leading space, so +// lines must stay under ~27 characters or they wrap and break the layout. +// LEARN_CARDS_MAX_LINE_LENGTH guards this. export const LEARN_CARDS: LearnCard[] = [ { title: 'How Seam works', @@ -32,16 +36,100 @@ export const LEARN_CARDS: LearnCard[] = [ ], }, { - title: 'While this runs', + title: 'One API, every brand', lines: [ - 'Docs docs.seam.co', - 'MCP seam-docs, in your', - ' AI editor', - 'API connect.getseam.com', + 'August, Yale, Schlage,', + 'Salto, Latch, Brivo, and', + '100+ more speak one', + 'Seam API.', + '', + 'Swap brands without', + 'rewriting your code.', + ], + }, + { + title: 'Grants, not codes', + lines: [ + 'Say who gets in, where,', + 'and when — Seam picks', + 'the method each device', + 'supports.', + '', + 'Reach for access codes', + 'only when you must.', + ], + }, + { + title: 'Connect an account', + lines: [ + 'A Connect Webview is a', + 'hosted page where your', + 'user signs in to their', + 'lock account.', + '', + 'You never touch their', + 'password.', + ], + }, + { + title: 'Test without hardware', + lines: [ + 'A sandbox workspace ships', + 'with virtual devices.', + '', + 'Build and test the whole', + 'flow before any real lock', + 'arrives.', + ], + }, + { + title: 'Events over polling', + lines: [ + 'Locks go offline, guests', + 'arrive early, batteries', + 'die.', + '', + 'Subscribe to webhooks and', + 'let Seam tell you.', + ], + }, + { + title: 'Work is asynchronous', + lines: [ + 'A lock may be asleep or', + 'offline, so writes return', + 'an action attempt.', + '', + 'Poll it — or await the', + 'event — for the result.', + ], + }, + { + title: 'Ask your editor', + lines: [ + 'The seam-docs MCP is now', + 'in your AI assistant.', + '', + 'Ask it for the exact API', + 'call instead of guessing', + 'at parameter names.', + ], + }, + { + title: 'Where to go next', + lines: [ + 'Docs docs.seam.co', + 'API connect.getseam.com', + 'Ask AI Seam Assistant, in', + ' the Console', ], }, ] +// The widest line the 30-wide Tips column renders without wrapping, counting +// the leading space each line is printed with. +export const LEARN_CARDS_MAX_LINE_LENGTH = 27 + export const LEARN_CARD_SECONDS = 8 // One tips card: a bold title over gray body lines. The leading space keeps it From ea2fc7e3b6f6b47e899d4079ec1f2bc111e94c61 Mon Sep 17 00:00:00 2001 From: itelo Date: Tue, 1 Sep 2026 12:25:51 -0300 Subject: [PATCH 2/2] feat(wizard): point developers at the workspace assistant when a run finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once setup is done the developer has a workspace with real devices and grants in it, and no obvious next place to ask about them. Link the Console assistant from the done screen. Lift the console host out of connect-web.ts into getConsoleUrl(), so the link honors SEAM_CONSOLE_URL the same way the browser handoff does instead of hardcoding production, and read it at call time so a test can stub it. getAssistantUrl() builds the workspace URL on top. The link sits under the celebration card rather than inside it: a workspace id is a 36-character UUID, so the URL runs to ~79 columns and the card's border and padding would push it past an 80-column terminal. Doing that exposed a latent rendering bug. The screen is vertically centered, and when that offset lands on a half row Ink overlaps the rows it paints; the blank ` ` spacer paints a real space, which landed at column 49 — mid-UUID — and silently corrupted the URL into something uncopyable that reads as a typo. Nothing on this screen was long enough to be hit before. The spacer is now a margin, which paints nothing, and the container uses minHeight so a short terminal grows instead of overlapping. The test asserts the whole URL to keep it that way. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/api.ts | 14 +++++++ src/lib/app.tsx | 1 + src/lib/screens/debug-screen.tsx | 1 + src/lib/screens/done.test.tsx | 69 ++++++++++++++++++++++++++++++++ src/lib/screens/done.tsx | 44 +++++++++++++++++++- src/lib/steps/connect-web.ts | 13 +++--- 6 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 src/lib/screens/done.test.tsx diff --git a/src/lib/api.ts b/src/lib/api.ts index eec6e49..6e3e2e4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -51,6 +51,20 @@ export function looksLikeSeamApiKey(value: string): boolean { return /^seam_[A-Za-z0-9]/.test(value.trim()) } +// The Seam Console. Override the host with SEAM_CONSOLE_URL for dev. Read at +// call time, not module load, so a test can stub the environment. +export function getConsoleUrl(): string { + const url = process.env['SEAM_CONSOLE_URL'] ?? 'https://console.seam.co' + return url.replace(/\/+$/, '') +} + +// The workspace's Seam Assistant: a chat that already knows the workspace's +// devices, grants and events. Where the wizard points developers once setup is +// done and there is something real to ask about. +export function getAssistantUrl(workspaceId: string): string { + return `${getConsoleUrl()}/dashboard/${workspaceId}/assistant` +} + // Base URL for Seam-hosted inference. The embedded agent's SDK appends // /v1/messages; the exchange endpoint below lives at /v1/session. export function getInferenceBaseUrl(): string { diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 375607a..2bdeeaf 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -993,6 +993,7 @@ export function App({ return ( diff --git a/src/lib/screens/debug-screen.tsx b/src/lib/screens/debug-screen.tsx index 5e27330..29c5096 100644 --- a/src/lib/screens/debug-screen.tsx +++ b/src/lib/screens/debug-screen.tsx @@ -56,6 +56,7 @@ const SCREENS: Record ReactElement> = { done: () => ( { + vi.unstubAllEnvs() +}) + +test('DoneScreen: points at the workspace assistant', () => { + const { lastFrame, unmount } = render( + , + ) + try { + const frame = lastFrame() ?? '' + expect(frame).toContain( + `https://console.seam.co/dashboard/${WORKSPACE_ID}/assistant`, + ) + expect(frame).toContain('Seam AI') + } finally { + unmount() + } +}) + +test('DoneScreen: honors SEAM_CONSOLE_URL for the assistant link', () => { + vi.stubEnv('SEAM_CONSOLE_URL', 'https://console.example.com/') + + const { lastFrame, unmount } = render( + , + ) + try { + expect(lastFrame() ?? '').toContain( + `https://console.example.com/dashboard/${WORKSPACE_ID}/assistant`, + ) + } finally { + unmount() + } +}) + +// A run that never settled on a workspace has no assistant to link to. +test('DoneScreen: omits the assistant link without a workspace', () => { + const { lastFrame, unmount } = render( + , + ) + try { + const frame = lastFrame() ?? '' + expect(frame).not.toContain('/assistant') + expect(frame).toContain('Press any key to exit') + } finally { + unmount() + } +}) diff --git a/src/lib/screens/done.tsx b/src/lib/screens/done.tsx index a651848..b18121b 100644 --- a/src/lib/screens/done.tsx +++ b/src/lib/screens/done.tsx @@ -1,6 +1,8 @@ import { Box, Text, useStdout } from 'ink' import type { ReactElement } from 'react' +import { getAssistantUrl } from 'lib/api.js' + export interface IntegrationOutcome { ok: boolean // Null when the model did not report a cost. Only shown when --show-cost is on. @@ -15,12 +17,17 @@ export interface IntegrationOutcome { // `outcome` is null when the wizard finished without running the agent (e.g. // "Continue on my own"), so it just confirms setup. Self-centers like the // welcome splash rather than sitting under the header. +// +// `workspaceId` is null only when the run never settled on a workspace, in +// which case there is no assistant to link to and the pointer is dropped. export function DoneScreen({ workspaceName, + workspaceId, outcome, showCost, }: { workspaceName: string + workspaceId: string | null outcome: IntegrationOutcome | null showCost: boolean }): ReactElement { @@ -44,10 +51,19 @@ export function DoneScreen({ ? ` · $${outcome.costUsd.toFixed(2)}` : '') + // The workspace id is a UUID, so this URL runs to ~79 columns — too wide to + // sit inside the bordered card, whose border and padding would push it past + // an 80-column terminal. It goes underneath, where it gets the full width. + const assistantUrl = workspaceId == null ? null : getAssistantUrl(workspaceId) + return ( + // minHeight, not height: the card centers in a tall terminal but the box + // grows on a short one. A fixed height overflows instead, and Ink then + // overlaps the overflowing rows — which silently corrupts the URL below. @@ -83,7 +99,31 @@ export function DoneScreen({ )} docs.seam.co · seam docs MCP in your editor - + + {assistantUrl != null && ( + + + Ask{' '} + + Seam AI + {' '} + about your workspace — devices, grants, events + + + {assistantUrl} + + + )} + {/* A margin, not a blank : this column is vertically + centered, and when that offset lands on a half row Ink overlaps the + rows it paints. A blank Text paints a real space, which lands mid-URL + above and silently corrupts it. A margin paints nothing. */} + Press any key to exit diff --git a/src/lib/steps/connect-web.ts b/src/lib/steps/connect-web.ts index d2307f7..6201946 100644 --- a/src/lib/steps/connect-web.ts +++ b/src/lib/steps/connect-web.ts @@ -3,12 +3,15 @@ import { createServer, type ServerResponse } from 'node:http' import open from 'open' -import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/api.js' +import { + getConsoleUrl, + getWorkspaceForApiKey, + type SeamWorkspace, +} from 'lib/api.js' import { saveProjectApiKey } from 'lib/env-file.js' // The dashboard "wizard" page mints a key and posts it back to the local -// callback. Override the console host with SEAM_CONSOLE_URL for dev. -const CONSOLE_URL = process.env['SEAM_CONSOLE_URL'] ?? 'https://console.seam.co' +// callback. The console host itself comes from getConsoleUrl(). const CONSOLE_WIZARD_PATH = '/dashboard/wizard' const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 @@ -35,7 +38,7 @@ interface CallbackPayload { // created key back. Throws on error/timeout; resolves with the workspace. // // Wire protocol (must match the dashboard page): the page is opened at -// {CONSOLE_URL}/dashboard/wizard?cli_connect=1&cli_port=&cli_state= +// {console}/dashboard/wizard?cli_connect=1&cli_port=&cli_state= // and POSTs JSON { state, api_key } to http://127.0.0.1:/ (CORS *). export async function connectViaWeb( root: string, @@ -94,7 +97,7 @@ export async function connectViaWeb( reject(new Error('Could not start the local callback server.')) return } - const url = `${CONSOLE_URL}${CONSOLE_WIZARD_PATH}?cli_connect=1&cli_port=${address.port}&cli_state=${state}` + const url = `${getConsoleUrl()}${CONSOLE_WIZARD_PATH}?cli_connect=1&cli_port=${address.port}&cli_state=${state}` events.onUrl?.(url) open(url).catch(() => { // Browser may not open (headless/SSH) — the UI shows the URL to visit.