From 7806330ca4fdaeb759c34278de5f3cbe64b203bd Mon Sep 17 00:00:00 2001 From: egeozin Date: Sun, 11 Oct 2026 22:37:23 -0700 Subject: [PATCH 1/2] Follow the coding agent on the locked dashboard (Paper LOCK-N) A browser sign-up whose project has nothing yet used to land on a page that said "Browser setup is not available yet" next to one install line. Now the page shows three steps and ticks them as Tin sees them: run the line, click Allow in the tab the agent opens, and Tin reads the project. When the plan waits for picks, the tab moves to the project the agent made, so nobody is left watching the empty personal one. The install line also starts the agent with "Set up Tin" (Codex, and Claude Code with -s user), kept in one place in app.js. It stays one line with an ellipsis on a desktop and wraps on a phone. GET /api/projects/{id}/agent-setup is membership checked and reads Postgres only: the person's project calls (project_mcp_usage) and MCP-started runs for the agent, and their latest growth.onboarding run that was not superseded for setup. No migration. The page polls it every 5 s while visible and records the steps it watched as lock page events. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/feature-status.md | 2 +- src/tin_lite/api.py | 33 ++++- src/tin_lite/db.py | 70 +++++++++ src/tin_lite/static/app.css | 230 ++++++++++++++++++++++++++--- src/tin_lite/static/app.js | 264 +++++++++++++++++++++++++++++---- tests/test_agent_setup.py | 269 ++++++++++++++++++++++++++++++++++ tests/test_lock_page_event.py | 20 +++ tests/test_product_ui.py | 4 +- web/lock-page.browser.test.js | 136 +++++++++++++++-- 9 files changed, 961 insertions(+), 67 deletions(-) create mode 100644 tests/test_agent_setup.py diff --git a/docs/feature-status.md b/docs/feature-status.md index 9d386ad5..0a6a3460 100644 --- a/docs/feature-status.md +++ b/docs/feature-status.md @@ -28,7 +28,7 @@ inputs, prerequisites and supported schedule modes. | Website changes | `website.change` puts an approved article, answer page or public article on the site at the founder's chosen route, fixes what the latest audit found (`source: audit`, site-fix-v5's rules), makes the redirects and noindex changes page decisions made (`planned`), and applies the copy fix `qa.feedback_to_fix` planned from public feedback once approved (`feedback`). Each audit fix, planned change or plan is a change row approved or declined once, in Decisions or over MCP. A page approved with commit to main, or approved rows, publish (Tin merges the PR once the repository's required checks pass, then checks the live site for fixes and redirects); anything else opens a PR. `preflight_website_change` previews each source and its judgment calls. See [website.change](website-change.md). | organic.technical_fix is hidden and kept for pinned runs; the traffic system's recipe still starts it. Deleting a page stays with the founder. organic.site_architecture, content.blog_index and the `blog_index` source refuse new starts and are kept for pinned runs. Agent-only. Protected paths are the shared auth pages, the project's protected pages (a setting with its history) and a per-run input. Fixture-tested; no live merge yet. | | Content review | Read the draft, request changes in text, review a new revision and approve through dashboard or MCP. Generation notes stay separate from publishable copy. | Approval applies to the reviewed revision. PR delivery neither merges the PR nor publishes the website. | | Saved schedules | Eligible definitions support daily or selected-weekday execution at a local time in an IANA timezone, with skip-overlap and bounded catch-up. | Not every workflow is schedulable. Paid scheduled occurrences need standing spending authority, not just a positive balance; hosted default policies include $50 per scheduled run ($10 for projects created before October 2, 2026). | -| Locked dashboard for browser sign-ups | A project with no workflow or run yet shows a locked dashboard: rail dimmed, one page that sends the person to their coding agent with the install line. Agent connection links and callbacks can finish the requested account and resource setup while the dashboard stays locked. Unlocks on the next reload once a workflow or run exists. `TIN_LITE_BROWSER_LOCK_ENABLED=false` turns it off. | Browser onboarding does not exist yet; the lock stands in for it. | +| Locked dashboard for browser sign-ups | A project with no workflow or run yet shows a locked dashboard: rail dimmed, one page that sends the person to their coding agent. Three steps follow the agent while the page is open (`GET /api/projects/{id}/agent-setup`, polled every 5 s while visible): the install line, which also starts the agent with "Set up Tin"; the agent's sign-in, ticked by its first project call or MCP-started run; and setup, ticked by the person's latest `growth.onboarding` run in any project they belong to. When that plan waits for picks or has finished, the tab moves to its project. Agent connection links and callbacks can finish the requested account and resource setup while the dashboard stays locked. `TIN_LITE_BROWSER_LOCK_ENABLED=false` turns it off. | Browser onboarding does not exist yet; the lock stands in for it. | | One-off tasks | `project.task` has its own conversation, questions, pause/resume and exact-diff review. | It is not a reusable private code workflow or a replacement for the shared workflow engine. | See [architecture](architecture.md), [content delivery](repository-aware-content-delivery.md), and the diff --git a/src/tin_lite/api.py b/src/tin_lite/api.py index 4e124aae..9d9c16b6 100644 --- a/src/tin_lite/api.py +++ b/src/tin_lite/api.py @@ -169,18 +169,23 @@ async def safe_handler(request: Request): WEEK_START = Query(description="The first of seven local days, usually a Monday.") +LOCK_PAGE_ACTIONS = frozenset( + {"viewed", "install_copied", "agent_connected", "setup_started", "plan_ready"} +) + + @router.post("/api/events/lock-page", status_code=204) async def record_lock_page_event(request: Request, user: AuthContext = AUTHENTICATED_USER): - """A browser sign-up met the locked dashboard: opened it, or copied the install line. + """A browser sign-up met the locked dashboard and how far it watched the agent get. - The lock stands in for browser onboarding until it exists; these two events show how - many people it sends to their coding agent. The body is a fixed vocabulary, never - free text. + The lock stands in for browser onboarding until it exists. Opened and copied show how + many people it sends to their coding agent; the other three are its steps ticking while + the page was open. The body is a fixed vocabulary, never free text. """ body = await request.json() if await request.body() else {} body = body if isinstance(body, dict) else {} action = body.get("action") - if action not in {"viewed", "install_copied"}: + if action not in LOCK_PAGE_ACTIONS: raise HTTPException(status_code=400, detail="Unknown lock page action.") agent = body.get("agent") project_id = body.get("project_id") @@ -196,6 +201,24 @@ async def record_lock_page_event(request: Request, user: AuthContext = AUTHENTIC return Response(status_code=204) +@router.get("/api/projects/{project_id}/agent-setup") +async def get_agent_setup( + project_id: UUID, request: Request, user: AuthContext = AUTHENTICATED_USER +) -> dict[str, Any]: + """What the locked dashboard shows while the coding agent sets Tin up. + + Asked from the project the browser has open, usually the empty personal one, and + answered for the person: the agent often creates and onboards a separate project for + the business. Every project named in the answer is one they belong to. + """ + database = request.app.state.runtime.database + if not await database.has_project_access( + project_id=project_id, clerk_user_id=user.clerk_user_id + ): + raise HTTPException(status_code=404, detail="project not found") + return await database.agent_setup_status(user.clerk_user_id) + + BILLING_QUOTE_HEADER = Header(default=None, alias="Tin-Billing-Quote") diff --git a/src/tin_lite/db.py b/src/tin_lite/db.py index 7f0021fd..768d3aa7 100644 --- a/src/tin_lite/db.py +++ b/src/tin_lite/db.py @@ -1942,6 +1942,76 @@ async def record_mcp_usage( tool_name[:100], ) + async def agent_setup_status(self, clerk_user_id: str) -> dict[str, Any]: + """How far this person's coding agent has come, across the projects they belong to. + + The locked dashboard follows along with it. The agent counts as connected once a + project call (project_mcp_usage) or an MCP-started run carries this person's identity. + project_mcp_usage keeps the latest call per project, so the time can be later than + the very first call. Setup is this person's latest growth.onboarding run that was not + superseded; its plan is ready once it waits for picks or has finished. + """ + row = await self.pool.fetchrow( + """ + WITH mine AS ( + SELECT projects.id, projects.name + FROM project_memberships + JOIN projects ON projects.id = project_memberships.project_id + WHERE project_memberships.clerk_user_id = $1 AND projects.deleted_at IS NULL + ), + setup AS ( + SELECT runs.id, runs.project_id, mine.name AS project_name, runs.status, + runs.created_at, runs.trigger_client + FROM workflow_runs AS runs + JOIN mine ON mine.id = runs.project_id + WHERE runs.executor = 'growth.onboarding' + AND runs.started_by_clerk_user_id = $1 + AND runs.status <> 'superseded' + ORDER BY runs.created_at DESC, runs.id DESC + LIMIT 1 + ), + agent_runs AS ( + SELECT runs.created_at, runs.trigger_client + FROM workflow_runs AS runs + JOIN mine ON mine.id = runs.project_id + WHERE runs.started_by_clerk_user_id = $1 AND runs.trigger_source = 'mcp' + ) + SELECT + (SELECT min(usage.used_at) FROM project_mcp_usage AS usage + JOIN mine ON mine.id = usage.project_id + WHERE usage.clerk_user_id = $1) AS usage_at, + (SELECT min(created_at) FROM agent_runs) AS agent_run_at, + (SELECT trigger_client FROM agent_runs + WHERE trigger_client IS NOT NULL + ORDER BY created_at DESC LIMIT 1) AS client, + setup.id AS setup_run_id, + setup.project_id AS setup_project_id, + setup.project_name AS setup_project_name, + setup.status AS setup_status, + setup.created_at AS setup_started_at + FROM (SELECT 1) AS one + LEFT JOIN setup ON true + """, + clerk_user_id, + ) + assert row is not None + connected = [value for value in (row["usage_at"], row["agent_run_at"]) if value] + status = row["setup_status"] + return { + "agent": { + "connected_at": min(connected) if connected else None, + "client": row["client"], + }, + "setup": { + "run_id": row["setup_run_id"], + "project_id": row["setup_project_id"], + "project_name": row["setup_project_name"], + "started_at": row["setup_started_at"], + "status": status, + "plan_ready": status in {"needs_input", "succeeded"}, + }, + } + async def create_project_workflow( self, *, diff --git a/src/tin_lite/static/app.css b/src/tin_lite/static/app.css index 6edebbc3..dc3a1ba8 100644 --- a/src/tin_lite/static/app.css +++ b/src/tin_lite/static/app.css @@ -9913,55 +9913,227 @@ html[data-theme="dark"] .md-embed-frame { } /* Locked dashboard: a browser sign-up whose project has no workflow yet. The rail is - dimmed by .nav-item:disabled; the main pane makes the case for the coding agent. - Source of truth: Paper board "LOCK-A · Browser sign-up · The hero, indoors". */ + dimmed by .nav-item:disabled; the main pane follows the coding agent in three steps. + Source of truth: Paper boards "LOCK-N · A" and "LOCK-N · B and C". */ .lock-page { display: flex; flex-direction: column; align-items: center; - justify-content: center; min-height: 100svh; - padding: 40px 40px 80px; + padding: 136px 40px 80px; } .lock-page-column { display: flex; flex-direction: column; - align-items: center; + gap: 40px; width: 100%; - max-width: 760px; + max-width: 632px; +} + +.lock-page-head { + display: flex; + flex-direction: column; + gap: 12px; } .lock-page h1 { - max-width: 560px; margin: 0; color: var(--ink); - font-size: 26px; + font-size: 32px; font-weight: 400; - letter-spacing: -0.01em; - line-height: 32px; - text-align: center; + letter-spacing: -0.02em; + line-height: 40px; } -.lock-page p { - max-width: 600px; - margin: 10px 0 0; +.lock-page-head p { + margin: 0; color: var(--ink-secondary); + font-size: 17px; + line-height: 26px; +} + +.lock-steps { + margin: 0; + padding: 0; + border-top: 1px solid var(--card-hairline); + list-style: none; +} + +.lock-step { + display: flex; + gap: 16px; + padding: 20px 0; + border-bottom: 1px solid var(--card-hairline); +} + +.lock-step.is-done:not(:has(p)) { + align-items: center; + padding: 16px 0; +} + +.lock-step-mark { + display: flex; + flex: 0 0 24px; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1.5px solid rgb(var(--ink-rgb) / 22%); + border-radius: var(--radius-pill); + color: var(--ink-muted); + font-family: var(--mono); + font-size: 12px; + line-height: 12px; +} + +.lock-step.is-active .lock-step-mark { + border-color: var(--ink); + color: var(--ink); +} + +.lock-step.is-done .lock-step-mark { + background: var(--accent-deep); + border-color: transparent; + color: var(--on-accent); +} + +.lock-step-mark svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.6px; +} + +.lock-step.is-working .lock-step-mark { + border-color: rgb(var(--ink-rgb) / 18%); + border-top-color: var(--ink); + animation: lock-step-spin 1.1s linear infinite; +} + +@keyframes lock-step-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .lock-step.is-working .lock-step-mark { + animation: none; + } +} + +.lock-step-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.lock-step-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + color: var(--ink-secondary); + font-size: 17px; + line-height: 24px; +} + +.lock-step.is-active .lock-step-title, +.lock-step.is-working .lock-step-title, +.lock-step.is-done:has(p) .lock-step-title { + color: var(--ink); +} + +.lock-step-title time { + flex: 0 0 auto; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 12px; + line-height: 16px; +} + +/* Spoken before each title; the mark carries it visually. */ +.lock-step-state { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.lock-step-body p { + margin: 0; + color: var(--ink-muted); font-size: 15px; line-height: 22px; - text-align: center; } -.lock-page p strong { +.lock-step.is-working .lock-step-body p, +.lock-step.is-done .lock-step-body p { + color: var(--ink-secondary); +} + +.lock-page-status { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + padding: 18px 0 0 40px; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 12px; + line-height: 16px; +} + +.lock-page-status::before { + flex: 0 0 6px; + width: 6px; + height: 6px; + background: currentColor; + border-radius: var(--radius-pill); + content: ""; +} + +.lock-page-open { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding: 24px 0 0 40px; +} + +.lock-page-open button { + min-height: 40px; + padding: 0 20px; + background: var(--ink); + border-radius: var(--radius-pill); + color: var(--paper-card); + font-size: 14px; font-weight: 700; + line-height: 16px; + cursor: pointer; +} + +.lock-page-open span { + color: var(--ink-muted); + font-family: var(--mono); + font-size: 12px; + line-height: 16px; } .lock-page-install { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; width: 100%; - margin-top: 36px; + margin-top: 12px; } .lock-page-tabs { @@ -10012,13 +10184,16 @@ html[data-theme="dark"] .md-embed-frame { color: #857c6f; } +/* One line, like the website's install box; Copy takes the whole command. */ .lock-page-line code { flex: 1; min-width: 0; - overflow-wrap: anywhere; + overflow: hidden; font: inherit; /* Geist Mono joins "--" into one glyph and swallows the space before it. */ font-variant-ligatures: none; + text-overflow: ellipsis; + white-space: nowrap; } /* Dark theme: the page ground is already coal, so the line needs its own edge. */ @@ -10064,10 +10239,27 @@ html[data-theme="dark"] .lock-page-line { padding: 32px 18px 64px; } + .lock-page h1 { + font-size: 26px; + line-height: 32px; + } + .lock-page-line { + align-items: flex-start; padding: 14px 16px 14px 20px; border-radius: 20px; } + + .lock-page-line button { + margin-top: -5px; + } + + /* On a phone the box wraps, so the whole command stays readable. */ + .lock-page-line code { + overflow: visible; + overflow-wrap: anywhere; + white-space: normal; + } } /* Review context supplied by Tin, outside the generated Markdown. */ diff --git a/src/tin_lite/static/app.js b/src/tin_lite/static/app.js index b7d731cf..4d49595e 100644 --- a/src/tin_lite/static/app.js +++ b/src/tin_lite/static/app.js @@ -276,6 +276,11 @@ const state = { agentTab: "claude", // The lock page starts on Codex like the website hero; a click moves both surfaces. lockTab: null, + // The agent's progress the lock page follows, its poll, and the switch to the plan's project. + lockSetup: null, + lockPollTimer: null, + lockOpenTimer: null, + lockOpenAt: null, integrationFilter: "all", integrationSearch: "", expandedIntegration: null, @@ -1305,60 +1310,263 @@ function updateAgentRail() { }); } -// The locked dashboard. One install line per agent, no comment lines: the sign-in opens -// by itself and the rail block carries the longer version once the project unlocks. +// The locked dashboard: a browser sign-up whose project has no workflow or run yet. +// Three steps follow the coding agent as Tin sees it: the install line, the agent's +// sign-in and setup. Paper boards "LOCK-N · A" and "LOCK-N · B and C". const LOCK_PAGE_TABS = [ { tab: "codex", label: "Codex" }, { tab: "claude", label: "Claude Code" }, { tab: "api", label: "API" }, ]; +// The first message rides on the install line, so the agent starts fresh in the project +// folder and asks for the sign-in itself. Change the lines here and nowhere else. +const LOCK_PAGE_FIRST_ASK = "Set up Tin"; +const LOCK_PAGE_COMMANDS = { + codex: (mcpUrl) => `codex mcp add tin --url ${mcpUrl} && codex "${LOCK_PAGE_FIRST_ASK}"`, + claude: (mcpUrl) => `claude mcp add -s user -t http tin ${mcpUrl} && claude "${LOCK_PAGE_FIRST_ASK}"`, +}; +const LOCK_PAGE_AGENT_NAMES = { codex: "Codex", claude: "Claude Code", claude_code: "Claude Code" }; +const LOCK_PAGE_POLL_MS = 5000; +const LOCK_PAGE_OPEN_SECONDS = 3; function lockPageLine(tab) { if (tab === "api") return { prompt: "GET", command: `${new URL(MCP_URL).origin}/api/workflows?project_id={project_id}` }; - return { prompt: "$", command: agentCommandFor(tab).split("\n")[0] }; + return { prompt: "$", command: LOCK_PAGE_COMMANDS[tab](MCP_URL) }; } -function renderLockPage() { - const tab = LOCK_PAGE_TABS.some((item) => item.tab === state.lockTab) ? state.lockTab : "codex"; +function currentLockTab() { + return LOCK_PAGE_TABS.some((item) => item.tab === state.lockTab) ? state.lockTab : "codex"; +} + +// What the agent has done so far, from /agent-setup. The agent may set up a new project +// for the business, so the setup project is often not the one this tab has open. +function lockProgress(setup, tab = currentLockTab()) { + const agent = setup?.agent || {}; + const run = setup?.setup || {}; + const started = Boolean(run.started_at && run.project_id); + return { + connected: Boolean(agent.connected_at) || started, + connectedAt: agent.connected_at || run.started_at || null, + agentName: LOCK_PAGE_AGENT_NAMES[agent.client] || LOCK_PAGE_AGENT_NAMES[tab] || "your agent", + started, + startedAt: run.started_at || null, + stopped: started && ["failed", "stopped"].includes(run.status), + ready: started && Boolean(run.plan_ready), + projectId: run.project_id || null, + projectName: run.project_name || "your project", + }; +} + +function lockFingerprint(setup) { + const progress = lockProgress(setup); + return [progress.connected, progress.agentName, progress.started, progress.stopped, progress.ready, progress.projectId].join(":"); +} + +function lockStep({ state: stepState, number, title, detail = "", time = "", body = "" }) { + const mark = stepState === "done" + ? '' + : stepState === "working" ? "" : String(number); + const spoken = { done: "Done", working: "In progress", active: "Next", pending: "Later" }[stepState]; + return ` +
  • + +
    +
    ${spoken}: ${escapeHtml(title)}${time ? `` : ""}
    + ${detail ? `

    ${escapeHtml(detail)}

    ` : ""} + ${body} +
    +
  • `; +} + +function lockInstall(tab) { const line = lockPageLine(tab); + return ` +
    +
    + ${LOCK_PAGE_TABS.map((item) => ``).join("")} +
    +
    + + ${escapeHtml(line.command)} + +
    +
    `; +} + +function renderLockPage() { main.innerHTML = `
    -

    Set up Tin from your coding agent

    -

    Your coding agent already knows your repo and how you write. Connect Tin, and it will pick the workflows that fit and get the first ones running for you in a few minutes.

    -

    Browser setup is not available yet.

    -
    -
    - ${LOCK_PAGE_TABS.map((item) => ``).join("")} -
    -
    - - ${escapeHtml(line.command)} - -
    -
    +
    +

    Set up Tin from your coding agent

    +

    Your agent reads your repo, picks the workflows that fit and starts the first ones. This page follows along.

    +
    +
    `; - main.querySelectorAll("[data-lock-tab]").forEach((button) => { + renderLockSteps(); + if (!lockProgress(state.lockSetup).ready) scheduleLockPolling({ immediate: !state.lockSetup }); +} + +function renderLockSteps() { + const container = main.querySelector("#lock-page-progress"); + if (!container) return; + const tab = currentLockTab(); + const progress = lockProgress(state.lockSetup, tab); + const steps = [ + progress.connected + ? lockStep({ state: "done", title: `Tin added to ${progress.agentName}` }) + : lockStep({ state: "active", number: 1, title: "Run this in a terminal, inside your project", body: lockInstall(tab) }), + progress.connected + ? lockStep({ state: "done", title: `${progress.agentName} connected`, time: clockTime(progress.connectedAt) }) + : lockStep({ + state: "pending", number: 2, title: `Click Allow in the tab ${progress.agentName} opens`, + detail: "That lets your agent use your Tin account. It's Tin's page; nothing to fill in.", + }), + ]; + let after = '

    waiting for your agent · this page updates by itself

    '; + if (progress.ready) { + steps.push(lockStep({ + state: "done", title: `${progress.projectName}'s plan is ready`, + detail: "Pick what runs here or in your agent. Nothing starts until you approve it.", + })); + after = ` +
    + + opening in ${lockOpenSecondsLeft()} s +
    `; + } else if (progress.stopped) { + steps.push(lockStep({ + state: "active", number: 3, title: `Setup stopped in ${progress.projectName}`, + detail: "Ask your agent to start it again; it can read why it stopped.", + })); + after = '

    this page updates by itself

    '; + } else if (progress.started) { + steps.push(lockStep({ + state: "working", title: "Tin is reading your project", + detail: "Your agent may ask a few things in the terminal meanwhile. About five minutes.", + })); + after = `

    started ${escapeHtml(clockTime(progress.startedAt))} · this page opens the project when the plan is ready

    `; + } else { + steps.push(lockStep({ + state: progress.connected ? "active" : "pending", number: 3, title: "Tin reads your project and drafts a plan", + detail: "About five minutes. Your new project opens here when it's ready.", + })); + } + container.innerHTML = `
      ${steps.join("")}
    ${after}`; + container.querySelectorAll("[data-lock-tab]").forEach((button) => { button.addEventListener("click", () => { state.lockTab = button.dataset.lockTab; state.agentTab = button.dataset.lockTab; - renderLockPage(); + renderLockSteps(); updateAgentRail(); }); }); - main.querySelector("#copy-lock-command").addEventListener("click", async () => { + container.querySelector("#copy-lock-command")?.addEventListener("click", async () => { + const command = lockPageLine(tab).command; try { - await navigator.clipboard.writeText(line.command); + await navigator.clipboard.writeText(command); } catch (_error) { showToast("Copy did not work. Select the command and copy it from here."); return; } - showToast(tab === "api" ? "Copied." : `Copied. Run it, then ask your coding agent: “${AGENT_PROMPT}”`); + showToast(tab === "api" ? "Copied." : "Copied. Paste it in a terminal inside your project."); recordLockPageEvent("install_copied", tab); }); + container.querySelector("[data-lock-open]")?.addEventListener("click", () => openLockProject()); + if (progress.ready) startLockOpenCountdown(); +} + +function scheduleLockPolling({ immediate = false } = {}) { + window.clearTimeout(state.lockPollTimer); + state.lockPollTimer = null; + if (document.hidden || state.projectAccess !== "locked" || !state.project) return; + state.lockPollTimer = window.setTimeout(pollLockSetup, immediate ? 0 : LOCK_PAGE_POLL_MS); +} + +function stopLockPolling() { + window.clearTimeout(state.lockPollTimer); + state.lockPollTimer = null; +} + +async function pollLockSetup() { + state.lockPollTimer = null; + if (document.hidden || state.projectAccess !== "locked" || !state.project || !main.querySelector(".lock-page")) return; + const generation = state.projectGeneration; + let setup = null; + try { + setup = await api(`/api/projects/${encodeURIComponent(state.project.id)}/agent-setup`); + } catch (_error) { + // Keep the last known steps; the next poll tries again. + } + if (generation !== state.projectGeneration || state.projectAccess !== "locked") return; + if (setup && typeof setup === "object" && !Array.isArray(setup)) { + const before = state.lockSetup; + state.lockSetup = setup; + recordLockSteps(before, setup); + if (!before || lockFingerprint(before) !== lockFingerprint(setup)) renderLockSteps(); + } + if (!lockProgress(state.lockSetup).ready) scheduleLockPolling(); +} + +// A step counts once per page view, and only when this page watched it happen. +function recordLockSteps(before, after) { + if (!before) return; + const was = lockProgress(before); + const now = lockProgress(after); + const tab = currentLockTab(); + if (!was.connected && now.connected) recordLockPageEvent("agent_connected", tab); + if (!was.started && now.started) recordLockPageEvent("setup_started", tab); + if (!was.ready && now.ready) recordLockPageEvent("plan_ready", tab); +} + +function lockOpenSecondsLeft() { + if (!state.lockOpenAt) return LOCK_PAGE_OPEN_SECONDS; + return Math.max(1, Math.ceil((state.lockOpenAt - Date.now()) / 1000)); +} + +function startLockOpenCountdown() { + if (state.lockOpenTimer) return; + state.lockOpenAt = Date.now() + LOCK_PAGE_OPEN_SECONDS * 1000; + state.lockOpenTimer = window.setInterval(() => { + const countdown = main.querySelector("#lock-page-countdown"); + if (!countdown) { + clearLockOpenCountdown(); + return; + } + if (Date.now() >= state.lockOpenAt) { + openLockProject(); + return; + } + countdown.textContent = `opening in ${lockOpenSecondsLeft()} s`; + }, 250); +} + +function clearLockOpenCountdown() { + window.clearInterval(state.lockOpenTimer); + state.lockOpenTimer = null; + state.lockOpenAt = null; +} + +// The plan's project is usually new to this tab: fetch the list before switching to it. +async function openLockProject() { + clearLockOpenCountdown(); + stopLockPolling(); + const { projectId, projectName } = lockProgress(state.lockSetup); + if (!projectId) return; + try { + let project = state.projects.find((item) => item.id === projectId); + if (!project) { + state.projects = await api("/api/projects"); + project = state.projects.find((item) => item.id === projectId); + } + if (!project) throw new Error("project not listed"); + await loadProject(project, { announce: project.id !== state.project?.id }); + } catch (_error) { + showToast(`Could not open ${projectName}. Refresh to try again.`); + } } function recordLockPageEvent(action, agent = null) { @@ -7250,6 +7458,9 @@ function resetProjectState(project) { if (state.project) state.chatDrafts.set(state.project.id, state.chatDraft); window.clearTimeout(state.pollTimer); state.pollTimer = null; + stopLockPolling(); + clearLockOpenCountdown(); + state.lockSetup = null; disposeDocument(); disposeFilesTree(); state.projectGeneration += 1; @@ -8393,8 +8604,11 @@ document.addEventListener("visibilitychange", () => { if (document.hidden) { window.clearTimeout(state.pollTimer); state.pollTimer = null; + stopLockPolling(); } else { schedulePolling({ immediate: state.view === "workflows" }); + // Back from the terminal: show what the agent did while this tab was hidden. + if (main.querySelector(".lock-page") && !lockProgress(state.lockSetup).ready) scheduleLockPolling({ immediate: true }); } }); diff --git a/tests/test_agent_setup.py b/tests/test_agent_setup.py new file mode 100644 index 00000000..a2056b31 --- /dev/null +++ b/tests/test_agent_setup.py @@ -0,0 +1,269 @@ +"""The locked dashboard follows the coding agent: connected, setup started, plan ready.""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import asyncpg +import httpx +import pytest +from fastapi import FastAPI + +from tin_lite.api import router +from tin_lite.auth import AuthContext, require_user +from tin_lite.db import Database, apply_migrations + +USER = "user_founder" +OTHER = "user_teammate" + + +class FakeDatabase: + def __init__(self, members: set[str]) -> None: + self.members = members + self.asked: list[str] = [] + + async def has_project_access(self, *, project_id: UUID, clerk_user_id: str) -> bool: + return clerk_user_id in self.members + + async def agent_setup_status(self, clerk_user_id: str) -> dict: + self.asked.append(clerk_user_id) + return {"agent": {"connected_at": None, "client": None}, "setup": {"plan_ready": False}} + + +def setup_app(database: FakeDatabase, clerk_user_id: str) -> FastAPI: + app = FastAPI() + app.include_router(router) + app.state.runtime = SimpleNamespace(database=database) + app.dependency_overrides[require_user] = lambda: AuthContext( + clerk_user_id=clerk_user_id, + token_type="session_token", # noqa: S106 + ) + return app + + +@pytest.mark.asyncio +async def test_agent_setup_answers_members_only() -> None: + database = FakeDatabase(members={USER}) + project_id = uuid4() + for clerk_user_id, expected in ((USER, 200), (OTHER, 404)): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=setup_app(database, clerk_user_id)), + base_url="http://test", + ) as client: + response = await client.get(f"/api/projects/{project_id}/agent-setup") + assert response.status_code == expected + # A stranger's request never reaches the per-person query. + assert database.asked == [USER] + + +@pytest.fixture +async def setup_db(): + """Real migrations/SQL in a disposable schema, never the configured product DB.""" + dsn = os.environ.get("TIN_LITE_TEST_DATABASE_DSN") + if not dsn: + pytest.skip("set TIN_LITE_TEST_DATABASE_DSN for isolated Postgres contract tests") + schema = "agent_setup_test_" + uuid4().hex + admin = await asyncpg.connect(dsn) + await admin.execute(f'CREATE SCHEMA "{schema}"') + database = Database(dsn) + try: + database._pool = await asyncpg.create_pool( + dsn, min_size=1, max_size=4, server_settings={"search_path": schema} + ) + scoped_dsn = dsn + ("&" if "?" in dsn else "?") + f"search_path={schema}" + await apply_migrations(scoped_dsn, Path(__file__).parents[1] / "migrations") + yield database + finally: + await database.close() + await admin.execute(f'DROP SCHEMA "{schema}" CASCADE') + await admin.close() + + +async def _workflow(database: Database, key: str) -> UUID: + workflow_id = uuid4() + await database.pool.execute( + """INSERT INTO workflows (id, key, title, executor, definition_repo_id, definition_path, + current_commit_sha, version_label, definition) + VALUES ($1, $2, $2, $2, 'registry/workflows', 'w.json', $3, '1', '{}')""", + workflow_id, + key, + "d" * 40, + ) + return workflow_id + + +async def _run( + database: Database, + *, + project_id: UUID, + workflow_id: UUID, + executor: str, + status: str, + started_by: str = USER, + trigger_source: str = "mcp", + trigger_client: str | None = "claude_code", + created_at: datetime, +) -> UUID: + run_id = uuid4() + await database.pool.execute( + """INSERT INTO workflow_runs (id, project_id, workflow_id, executor, definition_commit_sha, + temporal_workflow_id, thread_id, generation, fencing_token, status, lease_active, + expected_head_sha, started_by_clerk_user_id, trigger_source, trigger_client, + created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 1, 1, $8, false, $9, $10, $11, $12, $13)""", + run_id, + project_id, + workflow_id, + executor, + "d" * 40, + f"run:{run_id}", + str(run_id), + status, + "a" * 40, + started_by, + trigger_source, + trigger_client, + created_at, + ) + return run_id + + +async def test_nothing_yet_reads_as_waiting(setup_db) -> None: + database = setup_db + personal = await database.create_project(name="Founder's project", state_repo_id="p/personal") + await database.grant_project_membership(project_id=personal.id, clerk_user_id=USER) + status = await database.agent_setup_status(USER) + assert status["agent"] == {"connected_at": None, "client": None} + assert status["setup"]["started_at"] is None + assert status["setup"]["plan_ready"] is False + + +async def test_a_project_call_connects_the_agent_and_onboarding_elsewhere_is_followed( + setup_db, +) -> None: + database = setup_db + personal = await database.create_project(name="Founder's project", state_repo_id="p/personal") + business = await database.create_project(name="Acme", state_repo_id="p/acme") + for project in (personal, business): + await database.grant_project_membership(project_id=project.id, clerk_user_id=USER) + + # The agent made a project and called get_started on it: connected, nothing started. + await database.record_mcp_usage( + project_id=business.id, + clerk_user_id=USER, + oauth_client_id="client-1", + tool_name="get_started", + ) + status = await database.agent_setup_status(USER) + assert status["agent"]["connected_at"] is not None + assert status["agent"]["client"] is None + assert status["setup"]["started_at"] is None + + # It started onboarding in the business project; the setup names that project. + onboarding = await _workflow(database, "growth.onboarding") + now = datetime.now(UTC) + run_id = await _run( + database, + project_id=business.id, + workflow_id=onboarding, + executor="growth.onboarding", + status="running", + created_at=now, + ) + status = await database.agent_setup_status(USER) + assert status["agent"]["client"] == "claude_code" + assert status["setup"]["run_id"] == run_id + assert status["setup"]["project_id"] == business.id + assert status["setup"]["project_name"] == "Acme" + assert status["setup"]["status"] == "running" + assert status["setup"]["plan_ready"] is False + + # Waiting for picks is a ready plan. + await database.pool.execute("UPDATE workflow_runs SET status='needs_input' WHERE id=$1", run_id) + assert (await database.agent_setup_status(USER))["setup"]["plan_ready"] is True + + +async def test_setup_ignores_superseded_runs_other_people_and_projects_they_left(setup_db) -> None: + database = setup_db + business = await database.create_project(name="Acme", state_repo_id="p/acme") + shared = await database.create_project(name="Team", state_repo_id="p/team") + stranger = await database.create_project(name="Not mine", state_repo_id="p/not-mine") + for project in (business, shared): + await database.grant_project_membership(project_id=project.id, clerk_user_id=USER) + await database.grant_project_membership(project_id=shared.id, clerk_user_id=OTHER) + await database.grant_project_membership(project_id=stranger.id, clerk_user_id=OTHER) + onboarding = await _workflow(database, "growth.onboarding") + now = datetime.now(UTC) + + kept = await _run( + database, + project_id=business.id, + workflow_id=onboarding, + executor="growth.onboarding", + status="succeeded", + trigger_client="codex", + created_at=now - timedelta(minutes=10), + ) + # A newer restart that was superseded, a teammate's onboarding in a shared project and + # an onboarding in a project this person is not in do not move the setup. + await _run( + database, + project_id=business.id, + workflow_id=onboarding, + executor="growth.onboarding", + status="superseded", + trigger_client="codex", + created_at=now - timedelta(minutes=5), + ) + await _run( + database, + project_id=shared.id, + workflow_id=onboarding, + executor="growth.onboarding", + status="running", + started_by=OTHER, + created_at=now, + ) + await _run( + database, + project_id=stranger.id, + workflow_id=onboarding, + executor="growth.onboarding", + status="running", + started_by=USER, + created_at=now, + ) + status = await database.agent_setup_status(USER) + assert status["setup"]["run_id"] == kept + assert status["setup"]["plan_ready"] is True + assert status["agent"]["client"] == "codex" + + # A deleted project no longer counts. + await database.pool.execute("UPDATE projects SET deleted_at=now() WHERE id=$1", business.id) + status = await database.agent_setup_status(USER) + assert status["setup"]["run_id"] is None + assert status["agent"]["connected_at"] is None + + +async def test_a_browser_run_does_not_count_as_the_agent_connecting(setup_db) -> None: + database = setup_db + personal = await database.create_project(name="Founder's project", state_repo_id="p/personal") + await database.grant_project_membership(project_id=personal.id, clerk_user_id=USER) + audit = await _workflow(database, "organic.audit") + await _run( + database, + project_id=personal.id, + workflow_id=audit, + executor="organic.audit", + status="running", + trigger_source="manual", + trigger_client=None, + created_at=datetime.now(UTC), + ) + status = await database.agent_setup_status(USER) + assert status["agent"]["connected_at"] is None + assert status["setup"]["started_at"] is None diff --git a/tests/test_lock_page_event.py b/tests/test_lock_page_event.py index eac399be..d82d8d71 100644 --- a/tests/test_lock_page_event.py +++ b/tests/test_lock_page_event.py @@ -54,6 +54,26 @@ async def test_lock_page_events_carry_the_project_and_agent(monkeypatch) -> None assert captured[1]["properties"]["agent"] == "codex" +@pytest.mark.asyncio +async def test_lock_page_steps_it_watched_are_events(monkeypatch) -> None: + app, captured = lock_page_app(monkeypatch) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + for action in ("agent_connected", "setup_started", "plan_ready"): + response = await client.post( + "/api/events/lock-page", + json={"action": action, "agent": "claude", "project_id": "project-1"}, + ) + assert response.status_code == 204 + assert [item["event"] for item in captured] == [ + "lock_page_agent_connected", + "lock_page_setup_started", + "lock_page_plan_ready", + ] + assert all(item["properties"]["agent"] == "claude" for item in captured) + + @pytest.mark.asyncio async def test_lock_page_event_rejects_free_text(monkeypatch) -> None: app, captured = lock_page_app(monkeypatch) diff --git a/tests/test_product_ui.py b/tests/test_product_ui.py index 94f8b948..fe7c69b4 100644 --- a/tests/test_product_ui.py +++ b/tests/test_product_ui.py @@ -624,9 +624,11 @@ def test_product_ui_assets_are_packaged_beside_the_application() -> None: assert "function renderLockPage()" in script assert 'agentRail.hidden = state.projectAccess === "locked"' in script assert "Set up Tin from your coding agent" in script - assert "Browser setup is not available yet." in script + assert "Browser setup is not available yet." not in script assert 'api("/api/events/lock-page", {' in script + assert "/agent-setup`" in script assert ".lock-page-line" in stylesheet + assert ".lock-step.is-done .lock-step-mark" in stylesheet # State construction calls viewFromLocation(), which reads this constant synchronously. assert script.index("const ALLOWED_VIEWS") < script.index("const state") assert index.index('data-view="workflows"') < index.index('data-view="chat"') diff --git a/web/lock-page.browser.test.js b/web/lock-page.browser.test.js index 1d2e9c97..83a557c3 100644 --- a/web/lock-page.browser.test.js +++ b/web/lock-page.browser.test.js @@ -9,9 +9,15 @@ import test from "node:test"; import { chromium } from "playwright"; const assets = path.resolve("src/tin_lite/static"); +// GET /api/projects/{id}/agent-setup before the agent has done anything. +const WAITING = {agent: {connected_at: null, client: null}, setup: {run_id: null, project_id: null, project_name: null, started_at: null, status: null, plan_ready: false}}; -async function serve({ lockEnabled, projectWorkflows, runs = [], connections = false }) { +async function serve({ lockEnabled, projectWorkflows, runs = [], connections = false, agentSetup = null }) { const project = {id: "project-1", name: "QA’s project", workspace_id: "ws", workspace_name: "QA", member_count: 1, hidden: false}; + // The agent's progress the lock page polls; a test moves it along by replacing `.value`. + const setup = {value: agentSetup}; + const business = {...project, id: "project-acme", name: "Acme"}; + const businessRun = {id: "run-acme", workflow_id: "wf-onboarding", workflow: "growth.onboarding", status: "needs_input", created_at: "2026-10-11T21:42:00Z"}; const integrations = connections ? [ {key: "infra.github", name: "GitHub", badge: "GH", description: "Repositories", unlocks: [], configured: true, status: "available", connection_id: null}, {key: "analytics.gsc", name: "Search Console", badge: "SC", description: "Search performance", unlocks: [], configured: true, status: "available", connection_id: null}, @@ -52,8 +58,11 @@ async function serve({ lockEnabled, projectWorkflows, runs = [], connections = f } response.statusCode = 204; return response.end(); } - if (url.pathname === "/api/projects") return send(connections ? [project, {...project, id: "project-2", name: "Second project"}] : [project]); + const planReady = Boolean(setup.value?.setup?.plan_ready); + if (url.pathname === "/api/projects") return send(connections ? [project, {...project, id: "project-2", name: "Second project"}] : planReady ? [project, business] : [project]); + if (url.pathname.endsWith("/agent-setup")) return send(setup.value || WAITING); if (/\/api\/projects\/[^/]+\/workflows$/.test(url.pathname)) return send(projectWorkflows); + if (url.pathname === `/api/projects/${business.id}/runs`) return send([businessRun]); if (/\/api\/projects\/[^/]+\/runs$/.test(url.pathname)) return send(runs); if (url.pathname.endsWith("/integrations")) return send(integrations); if (url.pathname.endsWith("/infra.github/options")) return send([{id: "repo-1", label: "example/site"}]); @@ -62,17 +71,17 @@ async function serve({ lockEnabled, projectWorkflows, runs = [], connections = f response.writeHead(404).end(); }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - return {server, writes, base: `http://127.0.0.1:${server.address().port}`}; + return {server, writes, setup, base: `http://127.0.0.1:${server.address().port}`}; } -async function open(browser, base, {viewport = {width: 1440, height: 900}, url = "/system", signedIn = true} = {}) { +async function open(browser, base, {viewport = {width: 1440, height: 900}, url = "/system", signedIn = true, theme = "light"} = {}) { const errors = []; const context = await browser.newContext({viewport}); await context.route("**/*", route => route.request().url().startsWith(base) ? route.continue() : route.abort()); - await context.addInitScript(signedIn => { + await context.addInitScript(({signedIn, theme}) => { window.Clerk = {load: async () => {}, isSignedIn: signedIn, user: {id: "member", firstName: "QA"}, session: {getToken: async () => "synthetic"}, mountSignIn: () => {}}; - localStorage.setItem("tin-lite:theme", "light"); - }, signedIn); + localStorage.setItem("tin-lite:theme", theme); + }, {signedIn, theme}); const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message)); await page.goto(base + url); return {page, context, errors}; @@ -85,38 +94,54 @@ test("lock page: no workflow yet locks the rail and sends the person to their co const {page, context, errors} = await open(browser, base, {url: "/decisions"}); await page.locator(".lock-page").waitFor(); assert.equal(await page.locator(".lock-page h1").textContent(), "Set up Tin from your coding agent"); - assert.equal(await page.locator(".lock-page p strong").textContent(), "Browser setup is not available yet."); + assert.equal(await page.locator(".lock-page-head p").textContent(), "Your agent reads your repo, picks the workflows that fit and starts the first ones. This page follows along."); + assert.equal(await page.getByText("Browser setup is not available yet.").count(), 0); // Every rail item is dimmed and dead; the coding-agent block is gone because the page is that block. assert.deepEqual(await page.locator(".nav-list .nav-item").evaluateAll(items => items.map(item => item.disabled)), [true, true, true, true, true]); assert.equal(await page.locator("#agent-rail").isHidden(), true); assert.equal(await page.locator("#project-switcher").isDisabled(), false); - // Codex first, like the website hero; one line, no comment lines. + // Three steps; the first holds the install line and is the only active one. + assert.deepEqual(await page.locator(".lock-step").evaluateAll(items => items.map(item => item.className)), ["lock-step is-active", "lock-step is-pending", "lock-step is-pending"]); + assert.deepEqual(await stepTitles(page), [ + "Run this in a terminal, inside your project", + "Click Allow in the tab Codex opens", + "Tin reads your project and drafts a plan", + ]); + assert.equal(await page.locator(".lock-page-status").textContent(), "waiting for your agent · this page updates by itself"); + // Codex first, like the website hero. The line also starts the agent with the first message. assert.equal(await page.locator('[data-lock-tab][aria-selected="true"]').getAttribute("data-lock-tab"), "codex"); - assert.equal(await page.locator("#lock-page-command").textContent(), "codex mcp add tin --url https://app.tin.test/mcp"); + assert.equal(await page.locator("#lock-page-command").textContent(), 'codex mcp add tin --url https://app.tin.test/mcp && codex "Set up Tin"'); await page.locator('[data-lock-tab="claude"]').click(); - assert.equal(await page.locator("#lock-page-command").textContent(), "claude mcp add -t http tin https://app.tin.test/mcp"); + assert.equal(await page.locator("#lock-page-command").textContent(), 'claude mcp add -s user -t http tin https://app.tin.test/mcp && claude "Set up Tin"'); + assert.equal((await stepTitles(page))[1], "Click Allow in the tab Claude Code opens"); + // One line on a desktop: the long Claude Code line is clipped with an ellipsis, never wrapped. + const command = await page.locator("#lock-page-command").evaluate(node => ({clipped: node.scrollWidth > node.clientWidth, lines: Math.round(node.getBoundingClientRect().height / 18)})); + assert.deepEqual(command, {clipped: true, lines: 1}); await page.locator('[data-lock-tab="api"]').click(); assert.equal(await page.locator("#lock-page-command").textContent(), "https://app.tin.test/api/workflows?project_id={project_id}"); await page.locator('[data-lock-tab="codex"]').click(); await context.grantPermissions(["clipboard-read", "clipboard-write"], {origin: base}); await page.locator("#copy-lock-command").click(); - assert.equal(await page.evaluate(() => navigator.clipboard.readText()), "codex mcp add tin --url https://app.tin.test/mcp"); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), 'codex mcp add tin --url https://app.tin.test/mcp && codex "Set up Tin"'); + await page.getByText("Copied. Paste it in a terminal inside your project.").waitFor(); await page.waitForTimeout(150); - assert.deepEqual(writes.map(item => item.body), [ + assert.deepEqual(writes.filter(item => item.path === "/api/events/lock-page").map(item => item.body), [ {action: "viewed", agent: null, project_id: "project-1"}, {action: "install_copied", agent: "codex", project_id: "project-1"}, ]); - // Centered in the pane at desktop width. + // Centered in the pane at desktop width, starting high so the steps can fold without a jump. const box = await page.locator(".lock-page-column").boundingBox(); const pane = await page.locator("#main").boundingBox(); assert.ok(Math.abs((box.x + box.width / 2) - (pane.x + pane.width / 2)) < 2, "column is horizontally centered"); - assert.ok(box.y > 150 && box.y + box.height < pane.height - 150, "column sits in the middle of the pane"); + assert.equal(Math.round(box.width), 632); + assert.ok(box.y > 100 && box.y + box.height < pane.height - 40, "column sits in the upper middle of the pane"); assert.deepEqual(errors, []); await context.close(); // Narrow: the rail is a top strip and the page still fits without a horizontal scroll. const narrow = await open(browser, base, {viewport: {width: 390, height: 844}}); - await narrow.page.locator(".lock-page").waitFor(); + await narrow.page.locator(".lock-step").first().waitFor(); assert.equal(await narrow.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); + if (process.env.TIN_LOCK_SCREENSHOTS) await narrow.page.screenshot({path: path.join(process.env.TIN_LOCK_SCREENSHOTS, "lock-a-mobile.png"), fullPage: true}); assert.deepEqual(narrow.errors, []); await narrow.context.close(); } finally { @@ -125,6 +150,85 @@ test("lock page: no workflow yet locks the rail and sends the person to their co } }); +async function stepTitles(page) { + return page.locator(".lock-step-title > span").evaluateAll(items => items.map(item => item.lastChild.textContent)); +} + +// Polls run every 5 s; coming back to the tab polls at once, as after the terminal. +async function pollNow(page) { + await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); +} + +const CONNECTED = {...WAITING, agent: {connected_at: "2026-10-11T21:41:00Z", client: "claude_code"}}; +const STARTED = {...CONNECTED, setup: {run_id: "run-acme", project_id: "project-acme", project_name: "Acme", started_at: "2026-10-11T21:42:00Z", status: "running", plan_ready: false}}; +const READY = {...STARTED, setup: {...STARTED.setup, status: "needs_input", plan_ready: true}}; + +test("lock page: the steps tick as the agent connects, sets up, and the plan opens in its project", async () => { + const {server, writes, setup, base} = await serve({lockEnabled: true, projectWorkflows: []}); + const browser = await chromium.launch({headless: true}); + const shots = process.env.TIN_LOCK_SCREENSHOTS; + try { + for (const theme of shots ? ["light", "dark"] : ["light"]) { + setup.value = null; + writes.length = 0; + const {page, context, errors} = await open(browser, base, {theme}); + await page.locator(".lock-step").first().waitFor(); + if (shots) await page.screenshot({path: path.join(shots, `lock-a-${theme}.png`)}); + + // B: the agent's first signed-in project call. The install line folds away. + setup.value = CONNECTED; + await pollNow(page); + await page.getByText("Claude Code connected").waitFor(); + assert.deepEqual(await stepTitles(page), [ + "Tin added to Claude Code", + "Claude Code connected", + "Tin reads your project and drafts a plan", + ]); + assert.equal(await page.locator("#lock-page-command").count(), 0); + assert.equal(await page.locator(".lock-step").nth(2).getAttribute("class"), "lock-step is-active"); + assert.ok(await page.locator(".lock-step time").textContent()); + + // Setup started in the business project the agent made. + setup.value = STARTED; + await pollNow(page); + await page.getByText("Tin is reading your project").waitFor(); + assert.equal(await page.locator(".lock-step").nth(2).getAttribute("class"), "lock-step is-working"); + assert.match(await page.locator(".lock-page-status").textContent(), /^started .+ · this page opens the project when the plan is ready$/); + if (shots) await page.screenshot({path: path.join(shots, `lock-b-${theme}.png`)}); + + // C: the plan waits for picks. The tab moves to that project after a short countdown. + setup.value = READY; + await pollNow(page); + await page.getByText("Acme's plan is ready").waitFor(); + assert.equal(await page.locator("[data-lock-open]").textContent(), "Open Acme →"); + assert.match(await page.locator("#lock-page-countdown").textContent(), /^opening in [1-3] s$/); + if (shots) await page.screenshot({path: path.join(shots, `lock-c-${theme}.png`)}); + await page.waitForFunction(() => document.querySelector("#project-name")?.textContent === "Acme", null, {timeout: 6000}); + await page.locator(".system-view").waitFor(); + assert.equal(await page.locator(".lock-page").count(), 0); + assert.equal(new URL(page.url()).searchParams.get("project"), "project-acme"); + + // Each step counts once, and only because this page watched it happen. + assert.deepEqual(writes.filter(item => item.path === "/api/events/lock-page").map(item => item.body.action), ["viewed", "agent_connected", "setup_started", "plan_ready"]); + assert.deepEqual(errors, []); + await context.close(); + } + + // Opened once the plan is already ready: no step events, and the button switches at once. + setup.value = READY; + writes.length = 0; + const {page, context, errors} = await open(browser, base); + await page.locator("[data-lock-open]").click(); + await page.waitForFunction(() => document.querySelector("#project-name")?.textContent === "Acme"); + assert.deepEqual(writes.filter(item => item.path === "/api/events/lock-page").map(item => item.body.action), ["viewed"]); + assert.deepEqual(errors, []); + await context.close(); + } finally { + await browser.close(); + server.close(); + } +}); + test("agent connections remain available through OAuth and repository selection without unlocking an empty project", async () => { const {server, writes, base} = await serve({lockEnabled: true, projectWorkflows: [], connections: true}); const browser = await chromium.launch({headless: true}); From c186f9fa5434f2cdb033c62dc30b6538de35ef03 Mon Sep 17 00:00:00 2001 From: egeozin Date: Mon, 12 Oct 2026 08:46:28 -0700 Subject: [PATCH 2/2] Tell Claude Code users to open the sign-in link it prints Claude Code's authenticate tool prints the sign-in link instead of opening a tab, and its turn ends there, so step 2 says to open the link and reply. Codex keeps the tab copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tin_lite/static/app.js | 18 ++++++++++++++---- web/lock-page.browser.test.js | 4 +++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/tin_lite/static/app.js b/src/tin_lite/static/app.js index 4d49595e..d7b0e395 100644 --- a/src/tin_lite/static/app.js +++ b/src/tin_lite/static/app.js @@ -1410,6 +1410,19 @@ function renderLockPage() { if (!lockProgress(state.lockSetup).ready) scheduleLockPolling({ immediate: !state.lockSetup }); } +// Codex opens the sign-in tab itself. Claude Code only prints the link, and goes on once +// the person writes back. +function lockSignIn(tab, agentName) { + if (tab === "claude") return { + title: "Open the link Claude Code shows and click Allow", + detail: "Then tell Claude Code you're done and it carries on.", + }; + return { + title: `Click Allow in the tab ${agentName} opens`, + detail: "That lets your agent use your Tin account. It's Tin's page; nothing to fill in.", + }; +} + function renderLockSteps() { const container = main.querySelector("#lock-page-progress"); if (!container) return; @@ -1421,10 +1434,7 @@ function renderLockSteps() { : lockStep({ state: "active", number: 1, title: "Run this in a terminal, inside your project", body: lockInstall(tab) }), progress.connected ? lockStep({ state: "done", title: `${progress.agentName} connected`, time: clockTime(progress.connectedAt) }) - : lockStep({ - state: "pending", number: 2, title: `Click Allow in the tab ${progress.agentName} opens`, - detail: "That lets your agent use your Tin account. It's Tin's page; nothing to fill in.", - }), + : lockStep({ state: "pending", number: 2, ...lockSignIn(tab, progress.agentName) }), ]; let after = '

    waiting for your agent · this page updates by itself

    '; if (progress.ready) { diff --git a/web/lock-page.browser.test.js b/web/lock-page.browser.test.js index 83a557c3..086f8f11 100644 --- a/web/lock-page.browser.test.js +++ b/web/lock-page.browser.test.js @@ -113,7 +113,9 @@ test("lock page: no workflow yet locks the rail and sends the person to their co assert.equal(await page.locator("#lock-page-command").textContent(), 'codex mcp add tin --url https://app.tin.test/mcp && codex "Set up Tin"'); await page.locator('[data-lock-tab="claude"]').click(); assert.equal(await page.locator("#lock-page-command").textContent(), 'claude mcp add -s user -t http tin https://app.tin.test/mcp && claude "Set up Tin"'); - assert.equal((await stepTitles(page))[1], "Click Allow in the tab Claude Code opens"); + // Claude Code prints the sign-in link instead of opening a tab, then waits for a reply. + assert.equal((await stepTitles(page))[1], "Open the link Claude Code shows and click Allow"); + assert.equal(await page.locator(".lock-step").nth(1).locator("p").textContent(), "Then tell Claude Code you're done and it carries on."); // One line on a desktop: the long Claude Code line is clipped with an ellipsis, never wrapped. const command = await page.locator("#lock-page-command").evaluate(node => ({clipped: node.scrollWidth > node.clientWidth, lines: Math.round(node.getBoundingClientRect().height / 18)})); assert.deepEqual(command, {clipped: true, lines: 1});