Guided first launch behind HERMES_GUEST_ONBOARDING: intro, guided chat, first task in default (NS-848, PR B1) (#107958)

* feat(desktop): port guided onboarding substrate

Add seeded session creation, transcript directives, profile routing, and the shared window and pane primitives needed by the guided flow. Keep later-step mounts deferred and exclude provider selection and retry machinery.

* refactor(desktop): anti-slop cleanup for substrate

Assemble seed parameters in the existing create helper and use the owning transcript attribute type. Read the guaranteed gateway and connection contracts directly to remove runtime type probes and unchecked assertions.

* test(desktop): create-overrides invariants

Verify that reasoning and title overrides do not select a provider or model. Empty overrides and seeds add no parameters.

* feat(desktop): port first-run cinematic window

Play the cinematic behind the guest onboarding launch flag using bundled Collapse and JetBrains Mono. Give the native window its own controller and restore the app on skip, renderer deadman or native watchdog.

Drop the perf scenario because it depends on the removed replay hook. Guided chat kickoff and app-shell gate wiring remain with their later steps.

* refactor(desktop): anti-slop cleanup for cinematic

Preserve audio and canvas behavior through named types and inferred results. Split the viewport node and frame drawing to keep control flow bounded. Cut comments that only repeat the code.

* feat(desktop): add onboarding gate and answers stores

Track cinematic, guided chat, handoff and completion in one phase record. Queue the guide after the intro and share pending kickoff work between callers.

Keep existing saved answers while dropping retired preferences. Leave intro seen-state ownership with the cinematic store.

* feat(desktop): port guided onboarding chat

Add guided setup cards, runbooks, machine context, and onboarding presence. Connect transcript rendering and first-build progress to the desktop behind the onboarding flag. Leave session kickoff and handoff execution for the next step.

* refactor(desktop): anti-slop cleanup for guided chat

Keep directive and layout lookups typed. Remove unsafe test casts and isolate onboarding transcript calculations without changing the flow.

* feat(desktop): connect guided onboarding to durable first-build handoff

Start the guide only after its profile backend confirms bootstrap readiness. Seed or adopt the welcome chat, then transfer the first build to default with a durable receipt and explicit retry.

Wire cinematic completion, screen stand-down, layout growth and progress check-ins. Save agreed preferences before creating the build and release prompt slots after storage refusal.

* refactor(desktop): anti-slop cleanup for onboarding handoff

Reuse the gateway request and error contracts. Isolate guide adoption and snapshot validation while preserving receipt recovery and reasoning overrides.

Validate persisted receipt fields at the JSON boundary without coercion. Keep corrupt identities rejected and retain only the permitted test mocks.

* fix(desktop): guided chat review fixes

Wire the native machine probe so guided setup can suggest a name and offer the right first task. Restore the comments that explain the flow boundaries.

The directive registration uses the launch flag to preserve ordinary chat. Ruling 6 folds active.ts into assembly to keep activity ownership together and removes the second greeting source so the seeded and visible greetings agree.

* fix(desktop): handoff review fixes

Probe the guide backend before switching profiles so a readiness refusal keeps classic onboarding on the current backend.

Restore list-valued personalization coverage and routing rationale. Remove the obsolete setup status fixture.

* chore(desktop): onboarding script cull and rehearsal recipe

Document a temporary-state rehearsal using the existing onboarding flag and optional portal stand-in. Keep the main scripts unchanged and retain window growth for the guided chat.

* fix(connectors): reject incomplete catalog responses

* feat(gateway): scope connector controls to the owning session

* feat(desktop): connect apps through native session-owned controls

* feat(desktop): gate connector cards and enable free-tier access

Use the launch flag before mounting connector controls so classic transcripts add no status requests. Allow existing free-tier identities through the read-only tool gateway gate and test the owning-profile RPC path with A’s launch gate. Keep authorization links out of previews.

* style(desktop): format connector translations

Apply Prettier to the connector copy blocks while preserving upstream translations and free-tier wording.

* refactor(desktop): anti-slop cleanup for connector card

Use the transcript JSON contract and concrete RPC parameters. Preserve malformed-value filtering at one string boundary and make the fixture and row types explicit. Keep connector execution and cancellation behavior unchanged.

* feat(desktop): detect initial language from the OS

Use the native machine locale when no supported language is saved. Preserve explicit choices and leave inferred languages out of config.

* refactor(desktop): anti-slop cleanup for initial locale detection

Keep unvalidated config values at the existing validation boundary. Pass no saved choice after that boundary has ruled it out, preserving locale precedence.

* test(desktop): onboarding port test set

Make native window tests reject duplicate IPC handlers and isolate disabled onboarding. Assert the active gate mock when onboarding re-enables.

Keep the test set limited to behavior carried by the port.

* fix(desktop): recover failed guide kickoff and reveal once

The review found that a failed guide create stranded the solo shell and draft profile, and solo boot faded an already visible window a second time. Restore the prior route and layout, release onboarding through its existing phase record, and surface create failures. Let the film own the reveal while solo boot animates the visible resize.

* fix(desktop): preserve transcript ownership across cards and handoff

The review reproduced answers submitted to the focused chat, repeated questions disabled across sessions, handoff recovery using foreground identity, and mount-dependent progress history. Target each card’s own composer, scope settlement to its message and session, carry the issuing guide through handoff, and derive progress from its transcript with streaming activity. Reuse the existing owner ladder for exact and profile-only routes.

* fix(gateway): preserve connector ownership with profile routing

The review found that shared-primary profile metadata was rejected before connector dispatch, while desktop controls treated a missing registry id as missing ownership. Accept profile only as routing metadata and keep the live transport as authorization. Resolve card ownership through the existing exact/profile ladder, retaining ambient routing only for the single-backend case.

* fix(desktop): resolve plugin roots and gate the Basic layout

The review found that the first plugin build was seeded with a different installation’s fixed path, and the director ruled that flag-off layouts must match main. Resolve the running desktop’s plugin root before seeding a plugin build and register Basic only when onboarding is enabled. Keep the runbook wording and the ordinary four layout presets intact.

* fix(desktop): clear review-fix slop findings

The slop gate flagged an undocumented layout-data assertion and unknown-return types in the new test selectors. Record the layout registry invariant and preserve each selector’s return type. The only remaining production finding is the accepted connector-tools baseline.

* fix(desktop): detect the OS language on a fresh install

The review found that the merged English config default prevented the
desktop from probing the OS language on a fresh install. Add an opt-in
saved-values read so an absent choice remains distinct from saved English.

Preserve default-valued English only for explicit language saves; unrelated
settings saves must not turn a merged default into a language choice.
Older backends ignore the new query options and keep returning merged
English, preserving their existing desktop behavior.

* test(desktop): make the flag-off layout registry test deterministic

The flag-off test awaited the full controller import, pulling in the UI
graph and installing application watchers just to read layout presets.
That import took 9.5 seconds locally and timed out in the director's run.

Move the existing trees and registration into a small layout-presets
module. Production and the synchronous test use the same flag-gated
registration, without starting the controller in the test. Keep the real
registry invariant and dispose the test's contributions after completion.

* fix(desktop): keep the transcript parser and ::ask behind the onboarding flag

Register the guided chat's question card only with onboarding enabled.
Restore main's whole-paragraph parser and contribution rendering when the
flag is off, including its streaming prose behavior. Keep segmentation for
the guided flow until B4 decides the parser's wider use.

Restore main's two parser test files so its existing product and plugin
contracts remain the flag-off check.

* test: drop the onboarding and connector tests pending a later ticket

Apply the director's ruling to remove B1's added test files and restore
main's existing suites. Keep only the gateway route-reader mock contract
that main's profile tests need against the shipped activation behavior;
their cases and assertions stay intact.

The flow's shape is not settled and B3/B4 rewrite it. The connector layer
will also be reworked. The live CDP run is the flow check until a follow-up
ticket brings tests back.

---------

Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
Siddharth Balyan
2026-09-11 15:45:43 +05:30
committed by GitHub
parent 5d2d5e906d
commit 0e927c914d
107 changed files with 8150 additions and 197 deletions
+8
View File
@@ -196,6 +196,14 @@ boundaries, optimistic rollback and stale-response ordering, and both sides of a
local/remote adapter with its profile routing intact. Match how the suite is
actually run rather than inventing a command; when in doubt, read the scripts.
## Rehearsing the guided onboarding
From `apps/desktop`, use a fresh temporary directory for each rehearsal and run
`env -u NODE_ENV HERMES_GUEST_ONBOARDING=1 HERMES_HOME=<tmp>/.hermes HERMES_DESKTOP_USER_DATA_DIR=<tmp>/electron-user-data npm run dev`
(replace `<tmp>` with that directory). To use the portal stand-in, add
`HERMES_PORTAL_BASE_URL=http://127.0.0.1:8765 HERMES_ANON_API_SECRET=test-secret HERMES_SHARED_AUTH_DIR=<tmp>/.hermes/shared`
before `npm run dev`. Stop Electron and its dev server after the run.
## The taste test before you hand off
- Does every piece of state live with its authority, at the narrowest scope?
@@ -0,0 +1,259 @@
import { pathToFileURL } from 'node:url'
import { BrowserWindow, ipcMain, screen } from 'electron'
import { attachRendererConsoleCapture } from './renderer-log'
import { chatWindowWebPreferences } from './session-windows'
import { installWindowRendererLifecycle } from './window-renderer-lifecycle'
import { createWindowRevealController } from './window-reveal'
// The native watchdog must outlast the renderer deadman, even if its clock stalls.
export const INTRO_REVEAL_WATCHDOG_MS = 34_000
const INTRO_FROST_IN_MS = 500
const INTRO_FROST_OUT_MS = 600
interface IntroRevealOpenPayload {
hideMain?: boolean
}
interface IntroRevealClosePayload {
showMain?: boolean
}
interface IntroRevealWindowOptions {
devServer?: string
enabled: boolean
isMac: boolean
loadWindowUrl: (window: BrowserWindow, url: string, label: string) => void
log: (message: string) => void
mainWindow: () => BrowserWindow | null
preloadPath: string
rendererIndex: () => string
showMain: () => void
wireWindow: (window: BrowserWindow) => void
}
export function createIntroRevealWindowController({
devServer,
enabled,
isMac,
loadWindowUrl,
log,
mainWindow,
preloadPath,
rendererIndex,
showMain,
wireWindow
}: IntroRevealWindowOptions) {
let introRevealWindow: BrowserWindow | null = null
let introRevealWatchdog: ReturnType<typeof setTimeout> | null = null
let introRevealShow: ReturnType<typeof createWindowRevealController> | null = null
let frostTimer: ReturnType<typeof setTimeout> | null = null
let mainFadeTimer: ReturnType<typeof setInterval> | null = null
let onboardingFlowHidMain = false
function clearIntroRevealWatchdog() {
if (introRevealWatchdog) {
clearTimeout(introRevealWatchdog)
introRevealWatchdog = null
}
}
function introRevealUrl() {
if (devServer) {
return `${devServer.endsWith('/') ? devServer.slice(0, -1) : devServer}/?win=intro#/`
}
return `${pathToFileURL(rendererIndex()).toString()}?win=intro#/`
}
function showMainAfterOnboarding() {
onboardingFlowHidMain = false
const main = mainWindow()
if (!main || main.isDestroyed()) {
return
}
if (mainFadeTimer) {
clearInterval(mainFadeTimer)
}
const started = Date.now()
main.setOpacity(0)
showMain()
mainFadeTimer = setInterval(() => {
if (main.isDestroyed()) {
clearInterval(mainFadeTimer)
mainFadeTimer = null
return
}
const t = Math.min(1, (Date.now() - started) / 450)
main.setOpacity(t * (2 - t))
if (t >= 1) {
clearInterval(mainFadeTimer)
mainFadeTimer = null
}
}, 16)
}
function armIntroRevealShow(win: BrowserWindow) {
introRevealShow?.dispose()
introRevealShow = createWindowRevealController({
isDestroyed: () => win.isDestroyed(),
isVisible: () => win.isVisible(),
show: () => {
win.show()
if (isMac) {
win.setVibrancy('hud', { animationDuration: INTRO_FROST_IN_MS })
}
}
})
// ready-to-show fires on the empty shell; the renderer signals after paint.
win.webContents.once('did-finish-load', introRevealShow.scheduleFallback)
}
function spawnIntroRevealWindow() {
const win = new BrowserWindow({
...screen.getPrimaryDisplay().bounds,
alwaysOnTop: true,
backgroundColor: '#00000000',
focusable: true,
frame: false,
fullscreenable: false,
hasShadow: false,
hiddenInMissionControl: isMac,
maximizable: false,
minimizable: false,
movable: false,
resizable: false,
show: false,
skipTaskbar: !isMac,
transparent: true,
type: isMac ? 'panel' : undefined,
visualEffectState: isMac ? 'active' : undefined,
webPreferences: { ...chatWindowWebPreferences(preloadPath), backgroundThrottling: false }
})
win.setAlwaysOnTop(true, 'screen-saver')
if (isMac) {
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, skipTransformProcessType: true })
}
wireWindow(win)
armIntroRevealShow(win)
installWindowRendererLifecycle(win, { kind: 'overlay', callbacks: { log } })
attachRendererConsoleCapture(win, 'intro-reveal', log)
win.on('closed', () => {
introRevealShow?.dispose()
introRevealShow = null
introRevealWindow = null
clearIntroRevealWatchdog()
if (onboardingFlowHidMain) {
showMainAfterOnboarding()
}
const main = mainWindow()
if (main && !main.isDestroyed()) {
main.webContents.send('hermes:intro-reveal:closed')
}
})
loadWindowUrl(win, introRevealUrl(), 'Intro reveal')
return win
}
function openIntroReveal(payload: IntroRevealOpenPayload = {}) {
if (!enabled) {
return { ok: false }
}
if (introRevealWindow && !introRevealWindow.isDestroyed()) {
return { ok: true }
}
introRevealWindow = spawnIntroRevealWindow()
introRevealWatchdog = setTimeout(() => closeIntroReveal({ showMain: true }), INTRO_REVEAL_WATCHDOG_MS)
const main = mainWindow()
if (payload.hideMain === true && main && !main.isDestroyed()) {
// Stamp ownership even before first paint: skip must reveal an unshown app.
onboardingFlowHidMain = true
main.hide()
}
return { ok: true }
}
function closeIntroReveal(payload: IntroRevealClosePayload = {}) {
clearIntroRevealWatchdog()
introRevealShow?.dispose()
const win = introRevealWindow
if (win && !win.isDestroyed() && !frostTimer) {
if (isMac) {
win.setVibrancy(null, { animationDuration: INTRO_FROST_OUT_MS })
}
frostTimer = setTimeout(() => {
frostTimer = null
if (!win.isDestroyed()) {
win.close()
}
}, INTRO_FROST_OUT_MS)
}
if (payload.showMain === true && onboardingFlowHidMain) {
showMainAfterOnboarding()
}
return { ok: true }
}
function destroy() {
clearIntroRevealWatchdog()
introRevealShow?.dispose()
if (frostTimer) {
clearTimeout(frostTimer)
frostTimer = null
}
if (mainFadeTimer) {
clearInterval(mainFadeTimer)
mainFadeTimer = null
}
// Teardown must not reveal the app while it is quitting.
onboardingFlowHidMain = false
introRevealWindow?.destroy()
introRevealWindow = null
}
ipcMain.handle('hermes:intro-reveal:open', (_event, payload?: IntroRevealOpenPayload) => openIntroReveal(payload))
ipcMain.handle('hermes:intro-reveal:close', (_event, payload?: IntroRevealClosePayload) => closeIntroReveal(payload))
ipcMain.on('hermes:intro-reveal:ready', event => {
if (event.sender === introRevealWindow?.webContents) {
introRevealShow?.reveal()
}
})
ipcMain.on('hermes:intro-reveal:skip', event => {
const main = mainWindow()
if (event.sender === introRevealWindow?.webContents && main && !main.isDestroyed()) {
main.webContents.send('hermes:intro-reveal:skip')
}
})
return { destroy, showMainAfterOnboarding }
}
+135
View File
@@ -239,6 +239,7 @@ import { snapHudBounds } from './hud-snap'
import { createHudSnapShortcut } from './hud-snap-shortcut'
import { buildHudWindowUrl } from './hud-url'
import { resolveHudWindowing } from './hud-windowing'
import { createIntroRevealWindowController } from './intro-reveal-window'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import { ensureMainWindow } from './main-window-lifecycle'
import {
@@ -424,6 +425,7 @@ import {
registrySshScopeForWindowRoute,
WindowConnectionRouteRegistry
} from './window-connection-route'
import { growWindowBounds } from './window-growth'
import { createWindowOpenHandler } from './window-open-policy'
import { installWindowRendererLifecycle } from './window-renderer-lifecycle'
import { createWindowRevealController } from './window-reveal'
@@ -13840,6 +13842,61 @@ const wakeIndicatorController = createWakeIndicatorWindowController({
wireWindow: window => wireCommonWindowHandlers(window, zoomWiringForWindowKind('wakeIndicator'))
})
const introRevealController = createIntroRevealWindowController({
devServer: DEV_SERVER,
enabled: GUEST_ONBOARDING,
isMac: IS_MAC,
loadWindowUrl,
log: rememberLog,
mainWindow: () => mainWindow,
preloadPath: PRELOAD_PATH,
rendererIndex: resolveRendererIndex,
showMain: () => {
mainWindow.show()
mainWindow.focus()
},
wireWindow: window => wireCommonWindowHandlers(window, zoomWiringForWindowKind('petOverlay'))
})
ipcMain.on('hermes:chat-onboarding:grow', (event, request) => {
if (!GUEST_ONBOARDING || !mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) {
return
}
// Renderer CSS pixels become native DIP here, including the user's zoom.
const bounds = mainWindow.getBounds()
mainWindow.setBounds(
growWindowBounds(request, {
bounds,
frameWidth: bounds.width - mainWindow.getContentBounds().width,
workArea: screen.getDisplayMatching(bounds).workArea,
zoom: event.sender.getZoomFactor() || 1
}),
true
)
})
ipcMain.on('hermes:chat-onboarding:solo-boot', event => {
if (!GUEST_ONBOARDING || !mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) {
return
}
const area = screen.getDisplayMatching(mainWindow.getBounds()).workArea
const width = Math.min(600, area.width)
const height = Math.min(640, area.height)
mainWindow.setBounds(
{
height,
width,
x: Math.round(area.x + (area.width - width) / 2),
y: Math.round(area.y + (area.height - height) / 2)
},
true
)
})
// The pet overlay: a single transparent, frameless, always-on-top window that
// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out"
// here so it can leave the app's bounds and stay visible while Hermes is
@@ -14835,6 +14892,7 @@ function createWindow() {
mainWindow.on('closed', () => {
closePetOverlay()
wakeIndicatorController.close()
introRevealController.destroy()
if (mainWindow === createdMainWindow) {
mainWindow = null
@@ -17763,6 +17821,82 @@ ipcMain.handle('hermes:app:relaunch', async () => {
void exitAfterBackendShutdown(0)
})
// Host facts the guided first run asks for once, to decide whether "set this
// machine up" is the likeliest first task or just one option among several.
// Age is the birthtime of the user's home directory — when the OS created this
// account, the closest thing to "when did this machine become theirs" that
// costs a single stat. Filesystems that keep no birthtime report null, and the
// flow reads unknown as not-new.
ipcMain.handle('hermes:machine:profile', async () => {
let ageDays: null | number = null
try {
const { birthtimeMs } = fs.statSync(os.homedir())
if (birthtimeMs > 0) {
ageDays = Math.max(0, Math.floor((Date.now() - birthtimeMs) / 86_400_000))
}
} catch {
// Unknown age — the option still shows, it just doesn't lead.
}
// The OS login name powers a first-name SUGGESTION in the guided chat ("or
// I can just call you akp"). Best-effort: an unidentifiable user just gets
// no suggestion.
let username = ''
try {
username = os.userInfo().username
} catch {
// No account name to suggest — the guide simply asks.
}
return {
ageDays,
arch: process.arch,
// What the OS is set to, so a first run can open in the user's own
// language instead of asking them to go and find the setting. Chromium
// resolves this from the real OS preference (not the app's own bundle),
// so it is the honest answer even though every UI string is English
// until a translation exists.
locale: app.getLocale() || '',
model: readHardwareModel(),
nvidia: await hasNvidiaGpu(),
platform: process.platform,
release: os.release(),
username
}
})
/** The board's own name for itself. Firmware writes it to the device tree on
* ARM systems (`NVIDIA_DGX_Spark`), which is how the first run can greet a
* DGX Spark as a Spark instead of "a Linux box". Empty everywhere else,
* Windows included — the RTX Spark is identified from the GPU instead. */
function readHardwareModel(): string {
try {
return fs.readFileSync('/proc/device-tree/model', 'utf8').replace(/\0/g, '').trim()
} catch {
return ''
}
}
const NVIDIA_PCI_VENDOR_ID = 0x10de
/** Chromium already enumerated the GPUs to decide how to composite, so this is
* a lookup rather than a probe — no subprocess, no vendor tooling that a
* just-unboxed machine may not have yet. Paired with Windows-on-Arm it is what
* names an RTX Spark. */
async function hasNvidiaGpu(): Promise<boolean> {
try {
// SAFETY: Electron's basic GPU info is Chromium's GPU record; each gpuDevice has a numeric PCI vendorId.
const info = (await app.getGPUInfo('basic')) as { gpuDevice?: { vendorId?: number }[] }
return (info.gpuDevice ?? []).some(device => device.vendorId === NVIDIA_PCI_VENDOR_ID)
} catch {
return false
}
}
// ===========================================================================
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
// ===========================================================================
@@ -18402,6 +18536,7 @@ app.on('before-quit', event => {
// pet can't keep the process alive or float over a quit app.
closePetOverlay()
wakeIndicatorController.close()
introRevealController.destroy()
// Same for the HUD — an always-on-top panel outliving the app would leave a
// floating composer with nothing behind it. Close it directly rather than via
+25
View File
@@ -57,6 +57,30 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:wake-indicator:state', listener)
}
},
chatOnboarding: {
grow: request => ipcRenderer.send('hermes:chat-onboarding:grow', request),
soloBoot: () => ipcRenderer.send('hermes:chat-onboarding:solo-boot')
},
introReveal: {
open: (payload?: { hideMain?: boolean }) => ipcRenderer.invoke('hermes:intro-reveal:open', payload),
close: (payload?: { showMain?: boolean }) => ipcRenderer.invoke('hermes:intro-reveal:close', payload),
skip: () => ipcRenderer.send('hermes:intro-reveal:skip'),
ready: () => ipcRenderer.send('hermes:intro-reveal:ready'),
onSkip: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:intro-reveal:skip', listener)
return () => ipcRenderer.removeListener('hermes:intro-reveal:skip', listener)
},
onClosed: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:intro-reveal:closed', listener)
return () => ipcRenderer.removeListener('hermes:intro-reveal:closed', listener)
}
},
petOverlay: {
// Main renderer → main process: window lifecycle + drag. `request` is
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
@@ -498,6 +522,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
relaunchApp: () => ipcRenderer.invoke('hermes:app:relaunch'),
getMachineProfile: () => ipcRenderer.invoke('hermes:machine:profile'),
getRemoteDisplayReason: () => ipcRenderer.invoke('hermes:get-remote-display-reason'),
uninstall: {
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
+74
View File
@@ -0,0 +1,74 @@
/**
* Where the main window lands when the guided chat assembles the app around it.
*
* Pure geometry, extracted from the `chat-onboarding:grow` handler so the one
* thing that has actually gone wrong here — ending up too small — can be
* asserted rather than eyeballed on a first run.
*/
export interface GrowRequest {
bottom?: number
left?: number
/** Floor for the resulting CSS-pixel viewport width, for a layout with a
* responsive breakpoint to clear. Optional: most growth is just deltas. */
minWidth?: number
right?: number
top?: number
}
export interface GrowInputs {
/** Current window bounds, frame included. */
bounds: { height: number; width: number }
/** Non-zero on framed platforms: `bounds.width` minus the content width. The
* floor is about the viewport, so the frame has to be added back on top. */
frameWidth?: number
/** Display work area the result is centred in and clamped to. */
workArea: { height: number; width: number; x: number; y: number }
/** Renderer zoom. Requests arrive in CSS pixels; windows live in DIP. */
zoom?: number
}
/** Growth is bounded so a malformed request can't ask for a wall-sized window;
* the display clamp below is the real limit. */
const MAX_DELTA_PX = 4000
/** Never fill the whole display — a window pinned to every edge reads as broken
* rather than as an app that grew. */
const MAX_WORK_AREA = 0.92
export function growWindowBounds(
request: GrowRequest | null | undefined,
{ bounds, frameWidth = 0, workArea, zoom = 1 }: GrowInputs
) {
const dip = (value: number | undefined, round: (n: number) => number) =>
Math.max(0, Math.min(MAX_DELTA_PX, round((Number(value) || 0) * zoom)))
const toDip = (value?: number) => dip(value, Math.round)
// The floor CEILS where the deltas round. Rounding a breakpoint down lands
// fractionally under it — at 118% zoom a 768px floor becomes 906 DIP, a
// 767.8px viewport, and the media query the floor exists to satisfy is still
// false. Half a pixel, whole floating sidebar.
const requestedMin = dip(request?.minWidth, Math.ceil)
const grown = bounds.width + toDip(request?.left) + toDip(request?.right)
// Order matters: the floor lifts, then the display clamps. A floor wider than
// the screen loses — growing off-screen to satisfy a breakpoint would trade a
// floating sidebar for an unusable window.
const width = Math.min(
Math.max(grown, requestedMin ? requestedMin + frameWidth : 0),
Math.round(workArea.width * MAX_WORK_AREA)
)
const height = Math.min(
bounds.height + toDip(request?.top) + toDip(request?.bottom),
Math.round(workArea.height * MAX_WORK_AREA)
)
return {
height,
width,
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + (workArea.height - height) / 2)
}
}
+23 -10
View File
@@ -15,17 +15,30 @@
// UA-default white page, and the real theme only lands once the whole
// module graph has executed — i.e. the "white flash" on every new
// window. applyTheme() in src/themes/context.tsx keeps these keys fresh.
//
// OPAQUE WINDOWS ONLY. The `?win=` kinds Electron creates with
// `transparent: true` already have a clear canvas, and pre-painting a
// theme colour there does not prevent a flash, it IS the flash: an
// opaque rectangle standing until that window's module graph executes.
// On the full-screen intro cinematic, over the real desktop, on a dev
// server that serves the graph unbundled, that read as seconds of white.
// The list is TRANSPARENT_WINDOWS in src/main.tsx — keep the two in step.
// `secondary` (a session window) and `browser` are opaque and DO want the
// pre-paint.
try {
let bg = localStorage.getItem('hermes-boot-background')
let scheme = localStorage.getItem('hermes-boot-color-scheme')
if (!bg) {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
bg = dark ? '#111111' : '#f7f7f7'
scheme = dark ? 'dark' : 'light'
}
document.documentElement.style.backgroundColor = bg
if (scheme === 'dark' || scheme === 'light') {
document.documentElement.style.colorScheme = scheme
const win = new URLSearchParams(window.location.search).get('win')
if (!win || ['hud', 'overlay', 'quick', 'wake', 'intro'].indexOf(win) === -1) {
let bg = localStorage.getItem('hermes-boot-background')
let scheme = localStorage.getItem('hermes-boot-color-scheme')
if (!bg) {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
bg = dark ? '#111111' : '#f7f7f7'
scheme = dark ? 'dark' : 'light'
}
document.documentElement.style.backgroundColor = bg
if (scheme === 'dark' || scheme === 'light') {
document.documentElement.style.colorScheme = scheme
}
}
} catch {
// localStorage unavailable — keep UA defaults.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+11 -4
View File
@@ -68,10 +68,13 @@ export function getHermesConfig(profile?: string): Promise<HermesConfig> {
})
}
export function getHermesConfigRecord(profile?: ProfileScope): Promise<HermesConfigRecord> {
export function getHermesConfigRecord(
profile?: ProfileScope,
{ includeDefaults = true }: { includeDefaults?: boolean } = {}
): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...capabilityScoped(profile),
path: '/api/config'
path: includeDefaults ? '/api/config' : '/api/config?include_defaults=false'
})
}
@@ -90,10 +93,14 @@ export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSc
})
}
export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> {
export function saveHermesConfig(
config: HermesConfigRecord,
profile?: null | string,
{ preserveLanguage = false }: { preserveLanguage?: boolean } = {}
): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
path: '/api/config',
path: preserveLanguage ? '/api/config?preserve_language=true' : '/api/config',
method: 'PUT',
body: { config }
})
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useOnboardingChatActive } from '@/components/onboarding-chat/assembly'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -9,6 +10,7 @@ import { cn } from '@/lib/utils'
// the label doesn't blank. Purely visual — pointer-events-none.
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
const { t } = useI18n()
const onboarding = useOnboardingChatActive()
const [label, setLabel] = useState<null | string>(profile)
useEffect(() => {
@@ -17,6 +19,16 @@ export function ChatSwapOverlay({ profile }: { profile: string | null }) {
}
}, [profile])
// The first run swaps profiles twice — into the setup profile, then into the
// task profile — and neither is a thing the user asked for or has a name for.
// "Waking up hermes-setup…" over a greeting that is already on screen reads
// as a stall in the one moment that has to feel instant. The flow narrates
// its own handoff (the handoff card) and the greeting is banked, so there
// is nothing here to cover.
if (onboarding) {
return null
}
return (
<div
aria-hidden
@@ -41,6 +41,7 @@ export function ComposerControls({
disabled,
foldVoice = false,
hasComposerPayload,
hideModelPill = false,
minimal = false,
state,
voiceStatus,
@@ -57,6 +58,7 @@ export function ComposerControls({
disabled: boolean
foldVoice?: boolean
hasComposerPayload: boolean
hideModelPill?: boolean
minimal?: boolean
state: ChatBarState
voiceStatus: VoiceStatus
@@ -107,7 +109,7 @@ export function ComposerControls({
<div className="ml-auto flex min-w-0 shrink items-center gap-(--composer-control-gap)">
{minimal ? null : (
<>
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
{hideModelPill ? null : <ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />}
{voiceControls}
</>
)}
+28 -15
View File
@@ -5,6 +5,8 @@ import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, u
import { useTourMarker } from '@/app/chat/tour-marker'
import { useHudComposerDrag } from '@/app/hud/composer-drag'
import { composerFill, composerFloatingStrip, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { $chatOnboardingSolo, $chatOnboardingThreadIds } from '@/components/onboarding-chat/assembly'
import { OnboardingSkip } from '@/components/onboarding-chat/skip'
import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
@@ -180,6 +182,13 @@ export function ChatBar({
// queue uses the stored-session fallback key (prompts can queue pre-resume).
const statusSessionId = sessionId ?? null
// The guide uses the setup profile's inference route; the model pill and
// git controls would expose settings unrelated to its conversational steps.
// Solo covers startup before the guide's session ids are known.
const onboardingThreadIds = useStore($chatOnboardingThreadIds)
const chatOnboardingSolo = useStore($chatOnboardingSolo)
const guidedChat = chatOnboardingSolo || (sessionId != null && onboardingThreadIds.includes(sessionId))
const composerTourMarker = useTourMarker('composer')
// Coarse edge: re-renders ChatBar only when the stack shows/hides, NOT on
@@ -1026,6 +1035,7 @@ export function ChatBar({
disabled={disabled}
foldVoice={foldVoice}
hasComposerPayload={hasComposerPayload}
hideModelPill={guidedChat}
minimal={minimal}
onDictate={dictate}
onQueue={queueDraft}
@@ -1189,6 +1199,7 @@ export function ChatBar({
<div className={cn(composerFloatingStrip, 'px-[5px] pb-1.5 empty:hidden')}>
<ActionBadges sessionId={statusSessionId} />
<SuggestionPills sessionId={statusSessionId} />
<OnboardingSkip />
</div>
{/* Session-scoped status stack (todos, subagents, background tasks,
queue). An in-flow dock child: the dock is bottom-anchored, so it
@@ -1318,21 +1329,23 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
// A tile's rail reviews ITS worktree: pin the pane's scope to
// this surface's cwd. Main keeps the classic follow-the-
// active-session scope (null).
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
// Blank in a bot chat: the row hides itself without a repo,
// and stops probing git / GitHub for a surface that has no
// branch to show. Cheaper than a second composer.
repoPath={botChat ? undefined : cwd}
/>
{!guidedChat && (
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
// A tile's rail reviews ITS worktree: pin the pane's scope to
// this surface's cwd. Main keeps the classic follow-the-
// active-session scope (null).
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
// Blank in a bot chat: the row hides itself without a repo,
// and stops probing git / GitHub for a surface that has no
// branch to show. Cheaper than a second composer.
repoPath={botChat ? undefined : cwd}
/>
)}
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
+28 -64
View File
@@ -6,10 +6,12 @@ import { SessionDraftTitle } from '@/app/chat/session-draft-title'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { AskDirective } from '@/components/assistant-ui/ask-directive'
import { InlinePreviewDirective } from '@/components/assistant-ui/inline-preview-directive'
import { IdleMount } from '@/components/idle-mount'
import { OnboardingChatDirective } from '@/components/onboarding-chat/directive'
import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
import { allPaneIds, groupLeafIds } from '@/components/pane-shell/tree/model'
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
import {
$layoutTree,
@@ -43,6 +45,7 @@ import { translateNow } from '@/i18n'
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { Download, FileText, LayoutDashboard, PanelBottom, PanelTop, Terminal, Upload, Zap } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
import { setYoloEnabled } from '@/lib/yolo-session'
import { pruneComposerPopoutZones } from '@/store/composer-popout'
@@ -91,6 +94,7 @@ import { HudShell } from '../hud/hud-shell'
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
import { $workspaceIsPage } from '../routes'
import { DEFAULT_TREE, registerLayoutPresets } from './layout-presets'
import { FilesPane, LogsPane, ReviewPaneContent } from './panes'
import { ContribWiring, WiredPane } from './wiring'
@@ -304,6 +308,28 @@ registry.registerMany([
render: ({ attrs, streaming }) => <InlinePreviewDirective attrs={attrs} streaming={streaming} />
} satisfies TranscriptDirectiveContribution
},
...(isOnboardingEnabled()
? [
{
id: 'transcript.onboarding',
area: TRANSCRIPT_DIRECTIVE_AREA,
data: {
name: 'onboarding',
render: ({ attrs, streaming }) => <OnboardingChatDirective attrs={attrs} streaming={streaming} />
} satisfies TranscriptDirectiveContribution
},
// ::ask is the guided chat's question card, registered only with the
// onboarding flag. B4 decides its wider use.
{
id: 'transcript.ask',
area: TRANSCRIPT_DIRECTIVE_AREA,
data: {
name: 'ask',
render: ({ attrs, streaming }) => <AskDirective attrs={attrs} streaming={streaming} />
} satisfies TranscriptDirectiveContribution
}
]
: []),
{
id: 'layout.reset',
area: PALETTE_AREA,
@@ -376,69 +402,7 @@ registry.registerMany([
}
])
// ---------------------------------------------------------------------------
// Layout presets — CHAT (main) always dominates.
// ---------------------------------------------------------------------------
// The REAL default: sessions left, chat main, and the right sidebars in column
// order main | … | review | file-browser (files outermost). Each is its OWN
// zone. Review collapses to nothing while its pane is hidden (⌘G off).
//
// Preview tiles are DYNAMIC panes (like session tiles), so no preset names one:
// they're registered by watchPreviewTiles as tabs open, and dockPaneBeside lands
// each one directly beside the file tree wherever that currently lives — so a
// file double-click still slides a preview open as its own pane next to the
// tree, never as a tab stacked into the files sidebar.
const DEFAULT_TREE = split(
'row',
[
group(['sessions'], { id: 'grp-sessions' }),
group(['workspace'], { id: 'grp-main' }),
split(
'column',
[
split(
'row',
[group(['review'], { id: 'grp-review' }), group(['files'], { id: 'grp-files' })],
[1, 1.2],
'spl-rail'
),
group(['terminal'], { id: 'grp-terminal' })
],
[1.6, 1],
'spl-right'
)
],
[1, 3.4, 1.25],
'spl-root'
)
const FOCUS_TREE = split('row', [group(['sessions']), group(['workspace', 'files', 'review', 'terminal'])], [1, 4.6])
const TERMINAL_TREE = split(
'column',
[
split('row', [group(['sessions']), group(['workspace']), group(['files', 'review'])], [1, 3.2, 1.2]),
group(['terminal'])
],
[3, 1]
)
const QUAD_TREE = split(
'column',
[
split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]),
split('row', [group(['terminal']), group(['review'])], [1.4, 1])
],
[3, 1]
)
registry.registerMany([
{ id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE },
{ id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE },
{ id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE },
{ id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE }
])
registerLayoutPresets()
declareDefaultTree(DEFAULT_TREE)
+174
View File
@@ -0,0 +1,174 @@
/** Stored-id recovery and per-session brief painting adapted from alt-glitch's
* 487823507711c2d26da8fd1684be78f12966d14d. Failed/uncertain submits retain the
* original session; they must never close it or start a second build. */
import { JsonRpcGatewayError } from '@hermes/shared'
import type { ClientSessionState } from '@/app/types'
import type { HandoffPlan } from '@/components/onboarding-chat/setup-profile'
import type { SessionMessage } from '@/types/hermes'
import type { AmbientGatewayRequest } from './session-rpc-dispatcher'
export const BUILD_PROFILE = 'default'
export interface HandoffTask {
task: string
brief: string
plan: HandoffPlan
}
export interface HandoffReceipt extends HandoffTask {
runtimeId: string
storedId: string
/** `connectionId: null` is the ambient route for the profile (a local-only
* install, or a legacy primary with no registry id), never a missing owner. */
owner: { connectionId: null | string; profile: typeof BUILD_PROFILE }
status: 'created' | 'submitting' | 'accepted'
}
export interface HandoffSnapshot {
session_id: string
session_key: string
running: boolean
hydrating?: boolean
messages_omitted?: boolean
messages?: SessionMessage[]
}
export interface HandoffDeps {
create: () => Promise<Pick<HandoffReceipt, 'runtimeId' | 'storedId' | 'owner'>>
personalize: () => Promise<void>
request: <T>(
owner: HandoffReceipt['owner'],
method: string,
params: NonNullable<Parameters<AmbientGatewayRequest>[1]>
) => Promise<T>
read: () => HandoffReceipt | null
save: (receipt: HandoffReceipt) => void
bind: (receipt: HandoffReceipt, running: boolean, snapshot?: HandoffSnapshot) => void
}
/** Only preflight refusals in methods_prompt authorize another submit. A
* generic server error, like a lost ACK, may follow a side effect. */
const PREFLIGHT_REJECTIONS = new Set([4001, 4004, 4009, 4018, 4090, 4091, 4120, 4121, 5070, 5071, 5072, 5122])
interface HydratedHandoffSnapshot extends HandoffSnapshot {
messages: SessionMessage[]
}
function verifyHandoffSnapshot(snapshot: HandoffSnapshot): asserts snapshot is HydratedHandoffSnapshot {
if (
snapshot.hydrating ||
snapshot.messages_omitted ||
!snapshot.session_id ||
!snapshot.session_key ||
!Array.isArray(snapshot.messages) ||
(snapshot.running !== true && snapshot.running !== false)
) {
throw new Error('Could not verify the first build. Retry when the connection recovers.')
}
}
export async function startHandoff(deps: HandoffDeps, task: HandoffTask, recoverGone = true): Promise<HandoffReceipt> {
let receipt = deps.read()
if (!receipt) {
await deps.personalize()
const identity = await deps.create()
receipt = { ...task, ...identity, status: 'created' }
deps.save(receipt)
} else {
const snapshot = await deps.request<HandoffSnapshot>(receipt.owner, 'session.resume', {
session_id: receipt.storedId,
omit_messages: false
})
verifyHandoffSnapshot(snapshot)
receipt = { ...receipt, runtimeId: snapshot.session_id }
// A visible user turn in this dedicated session is durable acceptance,
// even when the build has finished or its context has been compressed.
// A confirmed refusal (created) cannot be overturned by a stale busy flag.
if (
(receipt.status === 'submitting' && snapshot.running) ||
snapshot.messages.some(message => message.role === 'user' && message.display_kind !== 'hidden')
) {
receipt = { ...receipt, status: 'accepted' }
}
deps.save(receipt)
deps.bind(receipt, snapshot.running, snapshot)
if (receipt.status === 'accepted') {
return receipt
}
if (snapshot.running) {
throw new Error(
'The first build has no confirmed start, but its session still reports running. Retry when it is idle; no duplicate was sent.'
)
}
if (receipt.status === 'submitting') {
throw new Error(
'The first build has not acknowledged its start. Check its session before retrying; no duplicate was sent.'
)
}
}
deps.bind(receipt, true)
receipt = { ...receipt, status: 'submitting' }
deps.save(receipt)
try {
const response = await deps.request<{ status?: string }>(receipt.owner, 'prompt.submit', {
session_id: receipt.runtimeId,
text: receipt.brief
})
if (response.status !== 'streaming') {
throw new Error('The first build did not acknowledge starting. Check its session before retrying.')
}
} catch (error) {
const code = error instanceof JsonRpcGatewayError ? error.code : undefined
if (code !== undefined && PREFLIGHT_REJECTIONS.has(code)) {
receipt = { ...receipt, status: 'created' }
deps.save(receipt)
if (code === 4001 && recoverGone) {
return startHandoff(deps, task, false)
}
}
throw error
}
receipt = { ...receipt, status: 'accepted' }
deps.save(receipt)
return receipt
}
export function paintHandoffBrief(state: ClientSessionState, brief: string, storedId: string): ClientSessionState {
const id = `user-handoff-brief-${storedId}`
return {
...state,
messages: state.messages.some(message => message.id === id)
? state.messages
: [
...state.messages,
{
id,
role: 'user',
parts: [{ text: brief, type: 'text' }],
timestamp: Date.now() / 1000
}
],
busy: true,
awaitingResponse: true,
turnStartedAt: state.turnStartedAt ?? Date.now()
}
}
@@ -0,0 +1,87 @@
import { atom } from 'nanostores'
import { $setupHandoff } from '@/components/onboarding-chat/setup-profile'
import { readKey, writeJson } from '@/lib/storage'
import type { HandoffReceipt } from './handoff-leg'
export const $handoffError = atom<string | null>(null)
// A failed disk write still remembers the original identity for this window.
// Nothing is submitted until the next save verifies durable persistence.
const unsavedReceipts = new Map<string, HandoffReceipt>()
/** Only a deliberate retry lifts an error; re-rendering a directive does not. */
export function retrySetupHandoff(): void {
const state = $setupHandoff.get()
if (state?.phase !== 'error') {
return
}
$handoffError.set(null)
$setupHandoff.set({ ...state, phase: 'pending' })
}
/** A navigation/submit receipt, never a copy of either profile's memory. */
export function handoffReceiptKey(connection: null | string, guideStoredId: string): string {
return `hermes.onboarding.handoff.v1.connection.${encodeURIComponent(connection ?? 'ambient')}.profile.default.guide.${encodeURIComponent(guideStoredId)}`
}
export function readHandoffReceipt(key: string): HandoffReceipt | null {
const unsaved = unsavedReceipts.get(key)
if (unsaved) {
return unsaved
}
const raw = readKey(key)
if (raw === null) {
return null
}
let value: HandoffReceipt
try {
value = JSON.parse(raw)
} catch {
throw new Error(
'The saved first-build receipt could not be read. Check your sessions before starting another build.'
)
}
// JSON cannot encode a constructor function: only primitive strings have
// String as their constructor here. Validate without coercing corrupt ids.
const hasTextFields = [value?.storedId, value?.runtimeId, value?.task, value?.brief].every(
field => field?.constructor === String
)
const connectionId = value?.owner?.connectionId
const validConnection = connectionId === null || (connectionId?.constructor === String && connectionId.length > 0)
if (
!hasTextFields ||
!value.storedId ||
!validConnection ||
value.owner?.profile !== 'default' ||
!['build', 'plugin', 'machine-setup'].includes(value.plan) ||
!['created', 'submitting', 'accepted'].includes(value.status)
) {
throw new Error(
'The saved first-build receipt could not be read. Check your sessions before starting another build.'
)
}
return value
}
export function saveHandoffReceipt(key: string, receipt: HandoffReceipt): void {
unsavedReceipts.set(key, receipt)
writeJson(key, receipt)
if (readKey(key) !== JSON.stringify(receipt)) {
throw new Error('Could not save the first-build session for recovery. No new start was sent.')
}
unsavedReceipts.delete(key)
}
@@ -0,0 +1,74 @@
import { group, split } from '@/components/pane-shell/tree/model'
import { registry } from '@/contrib/registry'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
// ---------------------------------------------------------------------------
// Layout presets — CHAT (main) always dominates.
// ---------------------------------------------------------------------------
// The REAL default: sessions left, chat main, and the right sidebars in column
// order main | … | review | file-browser (files outermost). Each is its OWN
// zone. Review collapses to nothing while its pane is hidden (⌘G off).
//
// Preview tiles are DYNAMIC panes (like session tiles), so no preset names one:
// they're registered by watchPreviewTiles as tabs open, and dockPaneBeside lands
// each one directly beside the file tree wherever that currently lives — so a
// file double-click still slides a preview open as its own pane next to the
// tree, never as a tab stacked into the files sidebar.
export const DEFAULT_TREE = split(
'row',
[
group(['sessions'], { id: 'grp-sessions' }),
group(['workspace'], { id: 'grp-main' }),
split(
'column',
[
split(
'row',
[group(['review'], { id: 'grp-review' }), group(['files'], { id: 'grp-files' })],
[1, 1.2],
'spl-rail'
),
group(['terminal'], { id: 'grp-terminal' })
],
[1.6, 1],
'spl-right'
)
],
[1, 3.4, 1.25],
'spl-root'
)
const FOCUS_TREE = split('row', [group(['sessions']), group(['workspace', 'files', 'review', 'terminal'])], [1, 4.6])
// Basic starts with sessions and chat so first-run users need not learn
// terminal, files or review panes before using Hermes.
const BASIC_TREE = split('row', [group(['sessions']), group(['workspace'])], [1, 4.6])
const TERMINAL_TREE = split(
'column',
[
split('row', [group(['sessions']), group(['workspace']), group(['files', 'review'])], [1, 3.2, 1.2]),
group(['terminal'])
],
[3, 1]
)
const QUAD_TREE = split(
'column',
[
split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]),
split('row', [group(['terminal']), group(['review'])], [1.4, 1])
],
[3, 1]
)
export function registerLayoutPresets() {
return registry.registerMany([
{ id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE },
...(isOnboardingEnabled() ? [{ id: 'basic', area: 'layouts', title: 'Basic', order: 5, data: BASIC_TREE }] : []),
{ id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE },
{ id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE },
{ id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE }
])
}
@@ -0,0 +1,560 @@
/** Welcome-chat creation, durable first-build handoff, and progress check-ins. */
import { useStore } from '@nanostores/react'
import { useCallback, useEffect } from 'react'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/api/client'
import type { useSessionActions } from '@/app/session/hooks/use-session-actions'
import type { SessionCreateOverrides } from '@/app/session/hooks/use-session-actions/create-overrides'
import type { ClientSessionState } from '@/app/types'
import {
$chatOnboardingThreadIds,
endChatOnboardingSolo,
pickOnboardingGreeting,
startChatOnboardingSolo
} from '@/components/onboarding-chat/assembly'
import { $setupCheckIn, watchFirstBuild } from '@/components/onboarding-chat/first-build'
import {
$setupHandoff,
$setupSession,
buildFirstTaskSeedMessages,
buildHandoffCompleteNote,
ensureSetupProfile,
firstTaskTitle,
markSetupHandoffDone,
SETUP_CHAT_TITLE,
SETUP_PROFILE
} from '@/components/onboarding-chat/setup-profile'
import { declinedLookAround, showProfileSignpost } from '@/components/onboarding-chat/signpost'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import { $layoutTree, activateTreePane } from '@/components/pane-shell/tree/store'
import { toChatMessages } from '@/lib/chat-messages'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { activeGatewayConnectionId, requestGatewayForAgent, requestGatewayForProfile } from '@/store/gateway'
import { loadMachineProfile } from '@/store/machine'
import { dismissNotification, notify } from '@/store/notifications'
import { $onboardingAnswers } from '@/store/onboarding-answers'
import { beginOnboardingHandoff, completeOnboardingFlow, skipGuide } from '@/store/onboarding-gate'
import { buildChatOnboardingSeedMessages } from '@/store/onboarding-script'
import {
$activeGatewayProfile,
$newChatProfile,
$newChatRoute,
ensureGatewayAgent,
ensureGatewayProfile
} from '@/store/profile'
import {
$activeSessionId,
$messages,
$selectedStoredSessionId,
forgetSessionOwnerHintsForSession,
getSessionOwnerHint,
setActiveSessionId,
setAwaitingResponse,
setBusy,
setSessionOwnerHint
} from '@/store/session'
import { patchSessionTile } from '@/store/session-states'
import { BUILD_PROFILE, type HandoffDeps, type HandoffReceipt, paintHandoffBrief, startHandoff } from './handoff-leg'
import {
$handoffError,
handoffReceiptKey,
readHandoffReceipt,
retrySetupHandoff,
saveHandoffReceipt
} from './handoff-receipt'
import type { AmbientGatewayRequest } from './session-rpc-dispatcher'
type SeedMessage = ReturnType<typeof buildChatOnboardingSeedMessages>[number]
interface SetupStatus {
ready?: boolean
provider_configured?: boolean
free_tier?: boolean
}
/** The welcome chat's owning source: its recorded owner hint, else the active
* gateway's registry id, else null. Null is the ambient route for the profile
* (a local-only install creates the setup profile as a registry secondary
* with no connection id and records no hint; a legacy primary has no registry
* id either), the same socket the welcome chat itself runs on. It is never a
* missing owner, and never 'local': an explicit local id would retarget a
* legacy remote primary onto this machine. */
function guideSourceConnectionId(guideStoredId: null | string | undefined): null | string {
return (guideStoredId && getSessionOwnerHint(guideStoredId)?.connectionId) || activeGatewayConnectionId() || null
}
export interface OnboardingHandoffOptions {
activeSessionIdRef: { current: null | string }
ensureSessionState: (sessionId: string, storedSessionId?: null | string) => ClientSessionState
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: null | string
) => ClientSessionState
createBackendSessionForSend: (
preview?: null | string,
seedMessages?: SeedMessage[],
overrides?: SessionCreateOverrides
) => Promise<null | string>
requestGateway: AmbientGatewayRequest
resumeSession: ReturnType<typeof useSessionActions>['resumeSession']
/** Runs `create` with the session-create leg pinned to `profile` instead of
* the selected chat's owner. The caller owns the mechanism (its own
* `requestGateway` is what reads the pin); the hook only needs to say which
* backend the new session belongs on. */
runCreatePinnedTo: <T>(profile: string, create: () => Promise<T>) => Promise<T>
}
interface GuideSession {
id: string
resolved_id?: string
}
async function adoptGuideSession(
canonical: GuideSession,
freeTier: SetupStatus['free_tier'],
resumeSession: OnboardingHandoffOptions['resumeSession'],
guideRequest: AmbientGatewayRequest
): Promise<void> {
await resumeSession(canonical.resolved_id ?? canonical.id, true)
const adoptedRuntimeId = $activeSessionId.get()
$chatOnboardingThreadIds.set(adoptedRuntimeId ? [canonical.id, adoptedRuntimeId] : [canonical.id])
$setupSession.set({
connectionId: guideSourceConnectionId(canonical.id),
profile: SETUP_PROFILE,
runtimeId: adoptedRuntimeId ?? canonical.id,
storedId: canonical.id
})
if (freeTier) {
await guideRequest('config.set', {
session_id: adoptedRuntimeId ?? canonical.id,
key: 'reasoning',
value: 'minimal'
})
}
}
/** Returns the first-chat kickoff; wires the handoff and check-in effects. */
export function useOnboardingHandoff({
activeSessionIdRef,
ensureSessionState,
updateSessionState,
createBackendSessionForSend,
requestGateway,
resumeSession,
runCreatePinnedTo
}: OnboardingHandoffOptions) {
const kickoffFirstChat = useCallback(async (): Promise<boolean> => {
if (!isOnboardingEnabled()) {
return false
}
const previousNewChatProfile = $newChatProfile.get()
const previousNewChatRoute = $newChatRoute.get()
const previousProfile = $activeGatewayProfile.get()
const previousConnectionId = activeGatewayConnectionId()
const previousSetupSession = $setupSession.get()
const previousThreadIds = $chatOnboardingThreadIds.get()
let swapped = false
try {
await ensureSetupProfile(requestGateway)
// Probe the guide's own socket before switching profiles so a refusal
// leaves classic onboarding on the user's current backend.
const record = await requestGatewayForProfile<SetupStatus>(SETUP_PROFILE, 'setup.status', {})
if (record.ready !== true || record.provider_configured !== true) {
return false
}
swapped = true
$newChatRoute.set(null)
$newChatProfile.set(SETUP_PROFILE)
await ensureGatewayProfile(SETUP_PROFILE)
startChatOnboardingSolo()
window.hermesDesktop?.chatOnboarding?.soloBoot?.()
await loadMachineProfile()
const seedMessages = buildChatOnboardingSeedMessages(
pickOnboardingGreeting(),
record.provider_configured === true && record.free_tier !== true
)
const guideRequest: OnboardingHandoffOptions['requestGateway'] = (method, params, timeout) =>
requestGatewayForProfile(SETUP_PROFILE, method, params, timeout)
// The exact title is the durable registry: a relaunch adopts the guide
// before creating, so UNIQUE(title) cannot strand an untitled duplicate.
const registryHit = await guideRequest<{ sessions?: GuideSession[] }>('session.list', {
include_hidden: true,
title: SETUP_CHAT_TITLE
})
const canonical = registryHit?.sessions?.[0]
if (canonical?.id) {
await adoptGuideSession(canonical, record.free_tier, resumeSession, guideRequest)
// runGuideKickoff records the guided phase only after adoption.
return true
}
const createOverrides: SessionCreateOverrides = { title: SETUP_CHAT_TITLE }
if (record.free_tier) {
createOverrides.reasoningEffort = 'minimal'
}
const runtimeId = await runCreatePinnedTo(SETUP_PROFILE, () =>
createBackendSessionForSend(null, seedMessages, createOverrides)
)
if (!runtimeId) {
throw new Error('The welcome chat could not be created. Please try again.')
}
const storedId = $selectedStoredSessionId.get()
$chatOnboardingThreadIds.set(storedId ? [storedId, runtimeId] : [runtimeId])
$setupSession.set({
connectionId: guideSourceConnectionId(storedId),
profile: SETUP_PROFILE,
runtimeId,
storedId
})
// Manual title authority prevents the hidden runbook becoming the title.
await guideRequest('session.title', { session_id: runtimeId, title: SETUP_CHAT_TITLE }).catch(() => undefined)
// session.create persisted both seed rows before the phase can advance.
return true
} catch (error) {
$newChatProfile.set(previousNewChatProfile)
$newChatRoute.set(previousNewChatRoute)
$setupSession.set(previousSetupSession)
$chatOnboardingThreadIds.set(previousThreadIds)
endChatOnboardingSolo()
skipGuide()
if (swapped) {
await (
previousConnectionId
? ensureGatewayAgent(previousConnectionId, previousProfile)
: ensureGatewayProfile(previousProfile)
).catch(restoreError => {
notify({ kind: 'error', title: 'Could not restore your profile', message: String(restoreError) })
})
}
console.error('[setup] welcome chat could not start', error)
notify({
kind: 'error',
title: 'Welcome chat needs attention',
message: error instanceof Error ? error.message : 'The welcome chat could not start.'
})
return false
}
}, [createBackendSessionForSend, requestGateway, resumeSession, runCreatePinnedTo])
// The receipt survives failure and relaunch; only a confirmed go signal
// completes onboarding. Never fall back to building in the guide chat.
const setupHandoff = useStore($setupHandoff)
const selectedStoredId = useStore($selectedStoredSessionId)
// Resume only an EXISTING receipt when the welcome chat is reopened after
// relaunch. Replayed directives stay inert; recovery never mints a new build.
useEffect(() => {
if (
!isOnboardingEnabled() ||
$setupHandoff.get() ||
!selectedStoredId ||
$activeGatewayProfile.get() !== SETUP_PROFILE
) {
return
}
const connectionId = guideSourceConnectionId(selectedStoredId)
try {
const saved = readHandoffReceipt(handoffReceiptKey(connectionId, selectedStoredId))
if (!saved) {
return
}
if (saved.status === 'accepted') {
completeOnboardingFlow()
$setupHandoff.set({
task: saved.task,
brief: saved.brief,
plan: saved.plan,
phase: 'done',
sessionTitle: firstTaskTitle(saved.task)
})
return
}
$setupSession.set({
connectionId,
profile: SETUP_PROFILE,
runtimeId: $activeSessionId.get() ?? '',
storedId: selectedStoredId
})
$setupHandoff.set({ task: saved.task, brief: saved.brief, plan: saved.plan, phase: 'pending' })
} catch (error) {
notify({
kind: 'error',
title: 'First build needs attention',
message: error instanceof Error ? error.message : 'The first-build receipt could not be read.'
})
}
}, [selectedStoredId])
// Rebind the runtime pointer after session.resume; this is not an atom-to-ref mirror.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isOnboardingEnabled() || setupHandoff?.phase !== 'pending' || $setupHandoff.get() !== setupHandoff) {
return
}
beginOnboardingHandoff()
$setupHandoff.set({ ...setupHandoff, phase: 'opening' })
void (async () => {
const setupSession = setupHandoff.guide ?? $setupSession.get()
const connectionId = setupSession?.connectionId ?? null
const signpost = !declinedLookAround($messages.get())
const previousNewChatProfile = $newChatProfile.get()
const previousNewChatRoute = $newChatRoute.get()
let receipt: HandoffReceipt | null = null
let receiptKey: string | null = null
const request: HandoffDeps['request'] = (owner, method, params) =>
requestGatewayForAgent(owner.connectionId, owner.profile, method, params, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
try {
if (!setupSession?.storedId) {
throw new Error('The welcome chat owner is not available yet. Reopen it and retry the first build.')
}
$setupSession.set(setupSession)
receiptKey = handoffReceiptKey(connectionId, setupSession.storedId)
receipt = readHandoffReceipt(receiptKey)
const owner: HandoffReceipt['owner'] = receipt?.owner ?? { connectionId, profile: BUILD_PROFILE }
// Save facts before session.create freezes the new agent's memory.
// A retry never re-creates the session or copies the guide's memory.
receipt = await startHandoff(
{
read: () => receipt,
save: value => {
receipt = value
saveHandoffReceipt(receiptKey!, value)
},
personalize: async () => {
const result = await request<{ saved?: boolean; profile?: string; target?: string }>(
owner,
'profiles.remember_onboarding',
{ answers: $onboardingAnswers.get() }
)
if (!result.saved || result.profile !== BUILD_PROFILE || result.target !== 'user') {
throw new Error('Could not save your onboarding preferences. Retry before starting the first build.')
}
},
create: async () => {
await ensureGatewayAgent(owner.connectionId, owner.profile)
$newChatProfile.set(BUILD_PROFILE)
// An ambient owner pins no route on the new chat either; the
// profile is the whole address.
$newChatRoute.set(
owner.connectionId ? { connectionId: owner.connectionId, profile: owner.profile } : null
)
const seed = await buildFirstTaskSeedMessages(
setupHandoff.task,
$onboardingAnswers.get(),
setupHandoff.plan
)
const runtimeId = await runCreatePinnedTo(BUILD_PROFILE, () =>
createBackendSessionForSend(setupHandoff.brief, seed)
)
if (!runtimeId) {
throw new Error('Could not open the first-build session.')
}
// Selection is usable only while it still names THIS create, not
// whichever thread the user clicked while the request was away.
const storedId =
ensureSessionState(runtimeId).storedSessionId ??
($activeSessionId.get() === runtimeId ? $selectedStoredSessionId.get() : null)
if (!storedId || storedId === setupSession.storedId) {
throw new Error(
'The first-build session did not return a durable identity. Check your sessions before retrying.'
)
}
return { runtimeId, storedId, owner }
},
request,
bind: (value, running, snapshot) => {
// An ambient owner (null connection id) has no route to pin: the
// build session is reached the way any session on that profile
// is, through the profile resolver.
if (value.owner.connectionId) {
const ownerRoute = { connectionId: value.owner.connectionId, profile: value.owner.profile }
setSessionOwnerHint(value.storedId, ownerRoute)
patchSessionTile(value.storedId, { runtimeId: value.runtimeId, ownerRoute })
} else {
// Explicit on both records: the tile patch merges, so an
// omitted route would keep whatever a previous bind left there.
forgetSessionOwnerHintsForSession(value.storedId)
patchSessionTile(value.storedId, { runtimeId: value.runtimeId, ownerRoute: undefined })
}
ensureSessionState(value.runtimeId, value.storedId)
updateSessionState(
value.runtimeId,
state =>
snapshot
? {
...state,
messages: toChatMessages(snapshot.messages ?? []),
busy: running,
awaitingResponse: running
}
: paintHandoffBrief(state, value.brief, value.storedId),
value.storedId
)
// Rebind a reclaimed runtime only if the user still has this
// stored chat selected. Background recovery must not steal focus.
if ($selectedStoredSessionId.get() === value.storedId) {
activeSessionIdRef.current = value.runtimeId
setActiveSessionId(value.runtimeId)
setAwaitingResponse(running)
setBusy(running)
}
}
},
setupHandoff
)
// Naming is not identity and must not gate submission. Create's title
// is pending metadata on older backends, so explicitly title afterward.
const chatTitle = firstTaskTitle(receipt.task)
await request(receipt.owner, 'session.title', { session_id: receipt.runtimeId, title: chatTitle }).catch(
error => console.warn('[handoff] title could not be saved', error)
)
markSetupHandoffDone()
completeOnboardingFlow()
$handoffError.set(null)
dismissNotification('onboarding-handoff')
$setupHandoff.set({
brief: receipt.brief,
phase: 'done',
plan: receipt.plan,
sessionTitle: chatTitle,
task: receipt.task
})
watchFirstBuild(receipt.runtimeId, receipt.owner.profile)
const tree = $layoutTree.get()
const sessionsGroup = tree ? findGroupOfPane(tree, 'sessions') : null
if (sessionsGroup && sessionsGroup.active !== 'sessions') {
activateTreePane(sessionsGroup.id, 'sessions')
}
// This is an informational success note, never an alternate build.
void requestGatewayForAgent(
connectionId,
setupSession.profile ?? BUILD_PROFILE,
'prompt.submit',
{
display_kind: 'hidden',
session_id: setupSession.runtimeId,
text: buildHandoffCompleteNote(receipt.task)
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
).catch(error => console.warn('[handoff] guide note was not delivered', error))
if (signpost && $selectedStoredSessionId.get() === receipt.storedId) {
void showProfileSignpost()
}
} catch (error) {
console.error('[handoff] first build needs recovery', error)
if (receipt) {
const briefId = `user-handoff-brief-${receipt.storedId}`
updateSessionState(
receipt.runtimeId,
state => ({
...state,
busy: false,
awaitingResponse: false,
turnStartedAt: null,
messages: state.messages.filter(message => message.id !== briefId)
}),
receipt.storedId
)
if ($selectedStoredSessionId.get() === receipt.storedId) {
setAwaitingResponse(false)
setBusy(false)
}
}
$newChatProfile.set(previousNewChatProfile)
$newChatRoute.set(previousNewChatRoute)
const message = error instanceof Error ? error.message : 'The first build could not be started.'
$handoffError.set(message)
$setupHandoff.set({ ...setupHandoff, phase: 'error' })
notify({
id: 'onboarding-handoff',
kind: 'error',
title: 'First build needs attention',
message,
action: { label: 'Retry first build', onClick: retrySetupHandoff }
})
}
})()
}, [
activeSessionIdRef,
createBackendSessionForSend,
ensureSessionState,
runCreatePinnedTo,
setupHandoff,
updateSessionState
])
// The during-task check-in: first-build.ts decides WHEN (see its header),
// this delivers it — a hidden note into the build's own session, which the
// agent answers as a short status plus one ask.
const checkIn = useStore($setupCheckIn)
useEffect(() => {
if (!isOnboardingEnabled() || !checkIn) {
return
}
// The session dispatcher resolves the stored owner hint and runtime map
// published by the handoff, including its exact registry connection.
void requestGateway(
'prompt.submit',
{ display_kind: 'hidden', session_id: checkIn.sessionId, text: checkIn.note },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
).catch(() => undefined)
}, [checkIn, requestGateway])
return kickoffFirstChat
}
+58 -2
View File
@@ -21,8 +21,10 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { FindBar } from '@/components/find-bar'
import { FreeTierSignInDialog } from '@/components/free-tier/sign-in-dialog'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { IntroRevealGate } from '@/components/intro-reveal'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { OnboardingChatGate } from '@/components/onboarding-chat/gate'
import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store'
import {
$workspaceMode,
@@ -46,6 +48,7 @@ import { $desktopBoot } from '@/store/boot'
import { requestVoiceConversationStart } from '@/store/composer'
import { $activeConnectionId } from '@/store/connections'
import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron'
import { requestGatewayForProfile } from '@/store/gateway'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { $previewTarget } from '@/store/preview'
@@ -153,8 +156,9 @@ import { usePetBridge } from './hooks/use-pet-bridge'
import { useQuickEntryBridge } from './hooks/use-quick-entry-bridge'
import { useSessionTileDelegate } from './hooks/use-session-tile-delegate'
import { McpInstallDeepLinkDialog } from './mcp-install-deeplink-dialog'
import { useOnboardingHandoff } from './onboarding-handoff'
import { $restartPreviewServer, useTitlebarToolContributions } from './panes'
import { createSessionRpcDispatcher } from './session-rpc-dispatcher'
import { type AmbientGatewayRequest, createSessionRpcDispatcher } from './session-rpc-dispatcher'
import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces'
import type { WiringActions, WiringApi } from './types'
@@ -174,6 +178,9 @@ const StarmapView = lazy(async () => ({ default: (await import('../starmap')).St
// the controller that assembles them.
export { WiredPane } from './context'
// Only the RPCs issued by session creation follow the handoff's profile pin.
const HANDOFF_CREATE_LEG_METHODS = new Set(['config.set', 'session.close', 'session.create'])
export function ContribWiring({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const location = useLocation()
@@ -299,12 +306,18 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const { connectionRef, gateway, gatewayRef, requestGateway: ambientRequestGateway } = useGatewayRequest()
// The guide remains selected while handoff creates on another profile.
// Without this pin, the owner ladder sends session.create to hermes-setup
// despite the gateway switch (#89206). Scope it to the create leg so
// concurrent session traffic keeps its recorded owner.
const handoffCreateProfileRef = useRef<null | string>(null)
// When chrome stays on the launch backend (Bot Mode / all-profiles
// navigation), session-owned RPCs still have to hit the session's backend.
// The routing itself lives in createSessionRpcDispatcher (routed by the
// session the RPC targets, owner ladder in resolveSessionRpcOwner) so the
// exact production dispatcher is what the integration tests drive.
const requestGateway = useMemo(
const dispatchSessionRpc = useMemo(
() =>
createSessionRpcDispatcher({
ambientRequest: ambientRequestGateway,
@@ -315,6 +328,21 @@ export function ContribWiring({ children }: { children: ReactNode }) {
[ambientRequestGateway, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef]
)
const requestGateway = useCallback<AmbientGatewayRequest>(
(method, params, timeoutMs, signal) => {
// The new build belongs to the handoff target; the selected guide's
// owner ladder would send its create to the wrong socket (#89206).
const handoffProfile = handoffCreateProfileRef.current
if (handoffProfile !== null && HANDOFF_CREATE_LEG_METHODS.has(method)) {
return requestGatewayForProfile(handoffProfile, method, params ?? {}, timeoutMs, signal)
}
return dispatchSessionRpc(method, params, timeoutMs, signal)
},
[dispatchSessionRpc]
)
const { loadMoreMessagingForPlatform, loadMoreSessions, refreshCronJobs, refreshMessagingSessions, refreshSessions } =
useSessionListActions({ profileScope })
@@ -602,6 +630,26 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
}, [startSessionInWorkspace, startWorkSessionRequest])
const runCreatePinnedTo = useCallback(async <T,>(profile: string, create: () => Promise<T>): Promise<T> => {
handoffCreateProfileRef.current = profile
try {
return await create()
} finally {
handoffCreateProfileRef.current = null
}
}, [])
const kickoffFirstChat = useOnboardingHandoff({
activeSessionIdRef,
ensureSessionState,
updateSessionState,
createBackendSessionForSend,
requestGateway,
resumeSession,
runCreatePinnedTo
})
// "New project" DRAG completion: the dialog created a project that was
// dropped onto a chat zone (tab-strip slot / pane edge / pane center). Open
// its fresh session draft exactly there — the same `openNewSessionTile`
@@ -1183,6 +1231,14 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
<RemoteDisplayBanner />
{!isAuxiliaryWindow() && <DesktopInstallOverlay />}
{!isAuxiliaryWindow() && <IntroRevealGate enabled={gatewayState === 'open'} />}
{!isAuxiliaryWindow() && (
<OnboardingChatGate
enabled={gatewayState === 'open'}
onKickoff={kickoffFirstChat}
requestGateway={ambientRequestGateway}
/>
)}
{!isAuxiliaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
@@ -1,6 +1,7 @@
import type { BillingBlock } from '@hermes/shared'
import { burstVibeHearts } from '@/components/chat/vibe-hearts'
import { reportFirstBuildTurnComplete } from '@/components/onboarding-chat/first-build'
import { translateNow } from '@/i18n'
import { coerceGatewayText, coerceThinkingText } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
@@ -351,6 +352,10 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
completeAssistantMessage(sessionId, finalText, payload?.response_previewed, failure, occurredAt)
// Onboarding's first build: between turns is the only moment Setup may
// put a check-in into that session (no-op everywhere else).
reportFirstBuildTurnComplete(sessionId, finalText)
// Structured billing wall forwarded by the gateway (out of credits /
// payment required) — cache it + raise a billing-specific toast.
if (payload?.billing) {
@@ -1,3 +1,4 @@
import { reportFirstBuildToolComplete } from '@/components/onboarding-chat/first-build'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { flashPetActivity, setPetActivity } from '@/store/pet'
@@ -68,6 +69,9 @@ export function handleToolEvent(ctx: GatewayEventContext): boolean {
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type, occurredAt)
// Onboarding's first build paces its check-ins off real work done
// (no-op in every other session).
reportFirstBuildToolComplete(sessionId)
if (isActiveEvent) {
setPetActivity({ toolRunning: false })
@@ -0,0 +1,48 @@
/**
* Per-create overrides, translated to `session.create` params and folded over
* the ones `desktopSessionCreateParams` derived from the visible selection.
*
* Reasoning effort rides alone here, with no model pin: the guided onboarding
* chat wants `minimal` on whatever model the backend already resolved for the
* profile. A model override would be a different kind of thing — the
* composer's model and provider are a PAIR, so overriding one without the
* other mints a session pointing a provider at a model it does not serve — and
* no caller needs one.
*/
export interface SessionCreateOverrides {
reasoningEffort?: string
title?: string
}
export interface SessionSeedMessage {
content: string
display_kind?: 'hidden'
role: 'assistant' | 'user'
}
export interface SessionCreateOverrideParams {
messages?: SessionSeedMessage[]
reasoning_effort?: string
title?: string
}
export function sessionCreateOverrideParams(
overrides: SessionCreateOverrides | undefined,
seedMessages?: SessionSeedMessage[]
): SessionCreateOverrideParams {
const params: SessionCreateOverrideParams = {}
if (overrides?.title) {
params.title = overrides.title
}
if (overrides?.reasoningEffort) {
params.reasoning_effort = overrides.reasoningEffort
}
if (seedMessages?.length) {
params.messages = seedMessages
}
return params
}
@@ -137,6 +137,7 @@ import type { ClientSessionState, SidebarNavItem } from '../../../types'
import { sessionContextDrift } from '../session-context-drift'
import { singleFlightSessionResume } from '../use-prompt-actions/single-flight-resume'
import { sessionCreateOverrideParams, type SessionCreateOverrides, type SessionSeedMessage } from './create-overrides'
import { pendingClarifyToolPayload, restorePendingClarifyFromSnapshot } from './restore-pending-clarify'
import {
createPersistedDisplayTranscriptProvenance,
@@ -531,7 +532,16 @@ export function useSessionActions({
)
const createBackendSessionForSend = useCallback(
async (preview: string | null = null): Promise<string | null> => {
async (
preview: string | null = null,
seedMessages?: SessionSeedMessage[],
// Create the session titled or at a pinned reasoning effort (guided
// onboarding mints its welcome chat this way). The owning profile is NOT
// an override — point $newChatProfile at it first (selectProfile-style)
// so the create lands on that profile's own backend and every later
// ambient RPC follows.
createOverrides?: SessionCreateOverrides
): Promise<string | null> => {
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
@@ -561,7 +571,11 @@ export function useSessionActions({
// reduce the owner to a bare profile name that later RPCs dial on a
// different socket than the one that minted the runtime.
const capturedRoute = resolveNewChatOwnerRoute()
const params = await desktopSessionCreateParams(cwd, capturedRoute)
const params = {
...(await desktopSessionCreateParams(cwd, capturedRoute)),
...sessionCreateOverrideParams(createOverrides, seedMessages)
}
// Lease the owner socket for the whole create → owner-publication
// sequence (#93602 primitive). The per-request lease inside
@@ -1,3 +1,4 @@
import { resolveSessionRpcOwner } from '@/app/contrib/wiring-routing'
import { textWithoutReferenceLines } from '@/components/assistant-ui/reference-kinds'
import { getSession } from '@/hermes'
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
@@ -15,6 +16,9 @@ import {
$messagingSessions,
$sessions,
commitWorkspaceCwdForSelectedSession,
getSessionOwnerHint,
knownSessionOwner,
ownerLookupSessionRows,
releaseWorkspaceCwdOwner,
sessionMatchesStoredId,
setCronSessions,
@@ -34,6 +38,7 @@ import {
setYoloActive
} from '@/store/session'
import type { SessionProfileRoute } from '@/store/session-request-router'
import { sessionTileOwnerRoute } from '@/store/session-states'
// Re-exported for the many session-actions/tile call sites that already import
// it from here; the canonical definition lives in @/store/session.
@@ -1570,6 +1575,17 @@ export async function resolveSessionOwner(storedSessionId: null | string): Promi
return undefined
}
const owner = resolveSessionRpcOwner({
routingSessionId: storedSessionId,
tileOwnerRoute: sessionTileOwnerRoute,
sessionOwnerHint: getSessionOwnerHint,
sessionRowOwner: id => knownSessionOwner(ownerLookupSessionRows(), id)
})
if (owner) {
return owner
}
const row = await resolveStoredSession(storedSessionId)
return sessionOwnerRouteFromRow(row) ?? (row?.profile?.trim() || undefined)
@@ -0,0 +1,98 @@
/**
* `::ask{...}` — the model's interactive question, inline in its message.
*
* The conversational counterpart of a wall of text: whenever the agent needs
* a decision, it emits ONE line and the transcript renders real controls —
* option pills; a single click submits the pick as a visible user turn.
* Works in every session (it is a core transcript directive, not an
* onboarding-only one), so dashboard button responses, refinement dialogues,
* and ordinary chats can all fork interactively.
*
* ::ask{question="Which angle leads?" options="Lead story|Exclusive|Embargoed brief"}
* ::ask{question="Paste the runway number" input="true"}
*
* Options are pipe-separated. `input="true"` means a typed answer is welcome —
* that keeps the QUESTION rendering even with no options, but it draws no
* input row of its own: the composer is always right below the transcript,
* and a second "type here" bar beside it read as clutter (first live-run
* feedback). A pick submits VISIBLY so the user sees their choice become a
* turn.
*/
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { cn } from '@/lib/utils'
// Picked questions, module-scoped: transcript virtualization remounts
// directives with fresh local state, which would resurrect a settled picker.
// A repeated question in a later message or another session is a new choice.
const settled = new Set<string>()
export function AskDirective({ attrs, streaming }: { attrs: Record<string, string>; streaming: boolean }) {
const view = useSessionView()
const storedId = useStore(view.$storedId)
const runtimeId = useStore(view.$runtimeId)
const messageId = useAuiState(state => state.message.id)
const question = (attrs.question ?? '').trim()
const identity = JSON.stringify([storedId ?? runtimeId, messageId, question])
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
const options = (attrs.options ?? '')
.split('|')
.map(option => option.trim())
.filter(Boolean)
.slice(0, 6)
const wantsInput = attrs.input === 'true' || attrs.input === 'yes'
const [picked, setPicked] = useState<null | string>(() => (settled.has(identity) ? '' : null))
if (!question || (options.length === 0 && !wantsInput)) {
return null
}
const submit = (value: string) => {
if (picked !== null || streaming || !value.trim()) {
return
}
if (requestComposerSubmit(value.trim(), { target })) {
settled.add(identity)
setPicked(value.trim())
}
}
return (
<div
className="my-3 flex min-w-0 max-w-full flex-col gap-2 overflow-visible duration-300 animate-in fade-in-0 slide-in-from-bottom-2"
data-onboarding-card
>
<div className="text-[13px] font-medium">{question}</div>
{options.length > 0 && (
<div className="flex min-w-0 max-w-full flex-wrap gap-2">
{options.map(option => (
<button
className={cn(
'max-w-full shrink-0 rounded-full border px-3 py-1.5 text-left text-[12px] whitespace-normal wrap-anywhere transition-colors',
picked === option
? 'border-primary bg-primary text-primary-foreground'
: picked !== null
? 'border-border/60 text-muted-foreground/50'
: 'border-border bg-card hover:border-primary/50 hover:bg-primary/10'
)}
disabled={picked !== null || streaming}
key={option}
onClick={() => submit(option)}
type="button"
>
{option}
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,346 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { Button } from '@/components/ui/button'
import { ConnectorCard, type ConnectorCardCopy } from '@/components/ui/connector-card'
import { Loader } from '@/components/ui/loader'
import { SearchField } from '@/components/ui/search-field'
import { useI18n } from '@/i18n'
import { connectionRows, connectorCalls, connectorTitle, connectorToolName, recordOf } from '@/lib/connector-tools'
import { createConnectorFlow } from '@/store/connector-flow'
import { requestGatewayForAgent } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { isSessionOwnerRoute } from '@/store/session-request-router'
export function ConnectorTool(props: ToolCallMessagePartProps) {
const view = useSessionView()
const runtimeId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
const busy = useStore(view.$busy)
const messages = useStore(view.$messages)
const latest = messages
.flatMap(message => message.parts)
.filter(
part =>
part.type === 'tool-call' &&
(part.toolName === 'manage_connections' || connectorCalls(part.toolName, part.args).length > 0)
)
.at(-1)
const historical = latest?.type === 'tool-call' && latest.toolCallId !== props.toolCallId
const [owner, setOwner] = useState<{
storedId: string
runtimeId: string
connectionId: null | string
profile: string
} | null>(null)
useEffect(() => {
if (!storedId || !runtimeId || historical) {
return
}
let cancelled = false
const ambientProfile = $activeGatewayProfile.get()
void resolveSessionOwner(storedId)
.then(scope => {
assertSessionOwnerResolved(scope, { method: 'connectors.list', sessionId: storedId })
if (!cancelled) {
setOwner({
storedId,
runtimeId,
connectionId: isSessionOwnerRoute(scope) ? scope.connectionId : null,
profile: isSessionOwnerRoute(scope) ? scope.profile : scope || ambientProfile
})
}
})
.catch(() => {
if (!cancelled) {
setOwner(null)
}
})
return () => {
cancelled = true
}
}, [storedId, runtimeId, historical])
const rows = connectionRows(props.args, props.result)
const signature = rows.map(row => row.connector).join('|')
const flow = useMemo(() => {
if (historical || !runtimeId || !owner || owner.storedId !== storedId || owner.runtimeId !== runtimeId) {
return null
}
const seeds = signature ? signature.split('|').map(connector => ({ connector })) : []
return createConnectorFlow(runtimeId, seeds, {
request: (method, params) => requestGatewayForAgent(owner.connectionId, owner.profile, method, params, 45000),
open: async url => {
if (!window.hermesDesktop?.openExternal) {
throw new Error('System browser unavailable')
}
await window.hermesDesktop.openExternal(url)
}
})
}, [runtimeId, owner, storedId, signature, historical])
const { t } = useI18n()
// A result is a snapshot. Reopening a transcript only refreshes status; it
// cannot mint links, open tabs or restart an abandoned authorization.
useEffect(() => {
if (!flow) {
return
}
return () => flow.dispose()
}, [flow])
useEffect(() => {
if (flow) {
void flow.refresh()
}
}, [flow, props.result])
if (historical) {
return <ToolFallback {...props} />
}
if (!flow) {
return <p className="text-xs text-muted-foreground">{t.connectors.ownerMissing}</p>
}
return (
<ConnectorOffer
busy={busy}
flow={flow}
key={`${runtimeId}:${signature}`}
onContinue={async text => {
if (!owner || !runtimeId) {
throw new Error('Session unavailable')
}
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
if (!requestComposerSubmit(text, { target })) {
throw new Error('Composer unavailable')
}
}}
/>
)
}
interface ConnectorOfferProps {
flow: ReturnType<typeof createConnectorFlow>
busy: boolean
onContinue: (text: string) => Promise<void>
}
export function ConnectorOffer({ flow, busy, onContinue }: ConnectorOfferProps) {
const state = useStore(flow.state)
const { t } = useI18n()
const copy = t.connectors
const [query, setQuery] = useState('')
const [continuing, setContinuing] = useState(false)
const [continued, setContinued] = useState(false)
const [continueError, setContinueError] = useState(false)
const active = state.rows.some(row => row.phase === 'opening' || row.phase === 'waiting')
const decided = state.rows.some(row => row.phase === 'connected' || row.phase === 'skipped')
const cardCopy: ConnectorCardCopy = {
connectAction: copy.connect,
decline: copy.skip,
envRequired: '',
grantAction: copy.grant,
retryAction: copy.retry,
stateConnected: copy.connected,
stateDeclined: copy.skipped,
stateDisabled: copy.disabled,
stateFailed: copy.failed,
stateNeedsAuth: copy.needsAuth,
toolCount: count => String(count),
trustCommunity: '',
trustCommunityTip: () => '',
trustVerified: () => '',
trustVerifiedTip: () => ''
}
if (state.loading) {
return <Loader />
}
const rows = state.rows.filter(row => connectorTitle(row.connector).toLowerCase().includes(query.toLowerCase()))
return (
<div className="my-2 grid min-w-0 max-w-lg gap-3" data-connector-offer>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{copy.title}</span>
<Button onClick={() => void flow.refresh()} size="xs" variant="text">
{copy.refresh}
</Button>
</div>
<p className="text-xs text-muted-foreground">{copy.disclaimer}</p>
{state.error ? (
<p className="text-xs text-destructive" role="alert">
{copy.statusError}
</p>
) : null}
{!state.available && !state.error ? <p className="text-xs text-muted-foreground">{copy.unavailable}</p> : null}
{state.rows.length > 6 ? <SearchField onChange={setQuery} placeholder={copy.search} value={query} /> : null}
<div className="grid max-h-96 min-w-0 gap-3 overflow-y-auto">
{rows.map(row => (
<div className="grid gap-1" key={row.connector}>
<ConnectorCard
actionDisabled={!state.available || row.enabled === false || !!state.error}
connector={{
name: row.connector,
title: row.name || connectorTitle(row.connector),
description: row.description
}}
copy={{
...cardCopy,
decline: row.phase === 'opening' || row.phase === 'waiting' ? copy.cancel : copy.skip,
connectAction: ['expired', 'revoked'].includes(row.connectionStatus ?? '') ? copy.grant : copy.connect
}}
dismissed={row.phase === 'skipped'}
onConnect={() => void flow.connect(row.connector)}
onDismiss={() => flow.skip(row.connector)}
otherBusy={active && !['opening', 'waiting'].includes(row.phase)}
outcome={
row.phase === 'connected'
? { status: 'connected' }
: row.phase === 'error'
? {
status: 'error',
detail:
row.error === 'connect'
? copy.connectError
: row.error === 'unavailable'
? copy.unavailable
: copy.statusError
}
: undefined
}
phase={row.phase === 'opening' ? copy.opening : row.phase === 'waiting' ? copy.waiting : undefined}
state={
row.enabled === false
? 'disabled'
: ['expired', 'revoked'].includes(row.connectionStatus ?? '')
? 'needs_auth'
: 'not_configured'
}
/>
{row.phase === 'timeout' ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{copy.timeout}</span>
<Button onClick={() => void flow.keepWaiting(row.connector)} size="xs" variant="textStrong">
{copy.keepWaiting}
</Button>
</div>
) : null}
</div>
))}
{!rows.length && state.available ? <p className="text-xs text-muted-foreground">{copy.empty}</p> : null}
</div>
{decided && !continued ? (
<div>
<Button
disabled={busy || active || continuing}
onClick={() => {
setContinuing(true)
setContinueError(false)
const connected = state.rows
.filter(row => row.phase === 'connected')
.map(row => connectorTitle(row.connector))
const skipped = state.rows
.filter(row => row.phase === 'skipped')
.map(row => connectorTitle(row.connector))
void onContinue(
`Continue the task. Connected apps: ${connected.join(', ') || 'none'}. Continue without: ${skipped.join(', ') || 'none'}. Use current connector status before accessing anything.`
)
.then(() => setContinued(true))
.catch(() => setContinueError(true))
.finally(() => setContinuing(false))
}}
size="sm"
>
{busy ? copy.continueBusy : copy.continue}
</Button>
{continueError ? (
<p className="text-xs text-destructive" role="alert">
{copy.continueFailed}
</p>
) : null}
</div>
) : null}
</div>
)
}
/** Keep execution output in the standard disclosure, with one row per app call. */
export function ConnectorExecution(props: ToolCallMessagePartProps) {
const calls = connectorCalls(props.toolName, props.args)
const input = recordOf(props.args)
const batch = Array.isArray(input.calls) ? input.calls : [input]
// Mixed remote batches keep their complete disclosure and original result order.
if (props.toolName === 'tool_call' && calls.length !== batch.length) {
return <ToolFallback {...props} />
}
const output = recordOf(props.result)
const results = Array.isArray(output.results) ? output.results : []
const repair = calls
.filter((_call, index) => {
const item = recordOf(props.toolName === 'tool_call' ? results[index] : props.result)
return ['CONNECTION_REQUIRED', 'CONNECTION_EXPIRED', 'AUTH_REQUIRED'].includes(
String(recordOf(item.error).code ?? '')
)
})
.map(call => {
// SAFETY: connectorCalls includes only names accepted by connectorToolName.
return connectorToolName(call.name)!.connector
})
return (
<>
{calls.map((call, index) => {
const item =
props.toolName === 'tool_call' ? (results[index] ?? (output.error ? output : undefined)) : props.result
const result = recordOf(item)
// SAFETY: connectorCalls includes only names accepted by connectorToolName.
const identity = connectorToolName(call.name)!
return (
<ToolFallback
{...props}
args={recordOf(call.arguments)}
isError={Boolean(result.error) || props.isError === true}
key={`${props.toolCallId}:${index}`}
result={props.result === undefined ? undefined : (item ?? { error: 'Missing connector result' })}
toolCallId={`${props.toolCallId}:${index}`}
toolName={`${connectorTitle(identity.connector)}: ${identity.action}`}
/>
)
})}
{repair.length ? (
<ConnectorTool {...props} args={{ action: 'status', connectors: repair }} result={undefined} />
) : null}
</>
)
}
@@ -33,15 +33,19 @@ import {
resolveMediaDisplaySrc,
resolveMediaPlaybackSrc
} from '@/lib/media'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
import { isDirectiveInProgress } from '@/lib/transcript-directives'
import { cn } from '@/lib/utils'
import { ArtifactCard } from './artifact-card'
import { SessionRefLink } from './directive-text'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
import { ResizableMarkdownTable, ResizableMarkdownTh } from './markdown-table'
import { paragraphPlainText, TranscriptDirectiveLeaf, useIsClaimedDirective } from './transcript-directive'
import { paragraphPlainText, TranscriptDirectiveLeaf, useResolvedParagraph } from './transcript-directive'
const onboardingEnabled = isOnboardingEnabled()
// Math rendering plugin (KaTeX). Configured once at module scope — the
// plugin is stateless beyond its internal cache so re-creating per-render
@@ -446,6 +450,12 @@ interface MarkdownTextSurfaceProps {
containerClassName?: string
containerProps?: ComponentProps<'div'>
defer?: boolean
/** This text is the model's private scratchpad (reasoning), so nothing in it
* may be promoted into app chrome: no artifact cards from fenced blocks (a
* draft must not register artifact versions), and no transcript directives
* (a `::onboarding{step="look"}` the model was only reminding itself about
* otherwise mounted a live accent picker inside the thinking block). */
scratchpad?: boolean
/** Disable artifact-card promotion for fenced blocks (reasoning text — a
* model's scratchpad draft must not register artifact versions). */
disableArtifacts?: boolean
@@ -515,21 +525,49 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
function MarkdownParagraph({
children,
className,
scratchpad,
streaming,
...props
}: ComponentProps<'p'> & { streaming?: boolean }) {
}: ComponentProps<'p'> & { scratchpad?: boolean; streaming?: boolean }) {
const plain = paragraphPlainText(children)
const claimed = useIsClaimedDirective(plain)
const resolved = useResolvedParagraph(scratchpad ? null : plain)
if (claimed && plain !== null) {
return <TranscriptDirectiveLeaf streaming={streaming} text={plain} />
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which must
// out-specify Tailwind Typography's `prose` margins — so no `my-*` here.
const paragraphClass = cn('wrap-anywhere leading-(--dt-line-height)', className)
// A paragraph that is one directive renders as the card alone; one that
// ends in a directive renders as its sentence followed by the card.
if (resolved) {
return (
<>
{resolved.map((segment, index) =>
segment.kind === 'directive' ? (
<TranscriptDirectiveLeaf key={index} streaming={streaming} text={segment.source} />
) : (
<p className={paragraphClass} key={index} {...props}>
{segment.text.trim()}
</p>
)
)}
</>
)
}
// Directive-in-progress: while the message is still streaming, a paragraph
// that begins with `::` is a directive whose closing shape hasn't fully
// arrived (directives always sit alone in their own paragraph — FLOW.md),
// so it can't be claimed yet. Rendering the plain <p> here is the raw-text
// flash (`::ask{question="Wha…`) that snaps into a card on settle — hold
// the slot empty instead. Once streaming ends this branch is dead, so a
// SETTLED malformed/unclaimed directive still shows as prose (an authoring
// bug the user should see).
if (onboardingEnabled && streaming && plain !== null && isDirectiveInProgress(plain)) {
return null
}
return (
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
// must out-specify Tailwind Typography's `prose` margins — so no
// `my-*` here on purpose.
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props}>
<p className={paragraphClass} {...props}>
{children}
</p>
)
@@ -540,7 +578,8 @@ function MarkdownTextSurface({
containerProps,
defer,
disableArtifacts,
previewOnly
previewOnly,
scratchpad
}: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
@@ -568,7 +607,11 @@ function MarkdownTextSurface({
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
),
p: (props: ComponentProps<'p'>) =>
previewOnly ? <p {...props} /> : <MarkdownParagraph {...props} streaming={isStreaming} />,
previewOnly ? (
<p {...props} />
) : (
<MarkdownParagraph {...props} scratchpad={scratchpad} streaming={isStreaming} />
),
a: previewOnly ? ({ children }: ComponentProps<'a'>) => <span>{children}</span> : MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
@@ -634,7 +677,7 @@ function MarkdownTextSurface({
// right rail; every other language falls back to the Shiki-highlighted
// code block.
SyntaxHighlighter: (props: SyntaxHighlighterProps) => {
const artifact = disableArtifacts || previewOnly ? null : detectArtifact(props.language, props.code)
const artifact = disableArtifacts || previewOnly || scratchpad ? null : detectArtifact(props.language, props.code)
if (artifact) {
return <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
@@ -650,7 +693,7 @@ function MarkdownTextSurface({
)
}
}) as StreamdownTextComponents,
[disableArtifacts, isStreaming, previewOnly]
[disableArtifacts, isStreaming, previewOnly, scratchpad]
)
if (text.length > MAX_MARKDOWN_CHARS) {
@@ -17,6 +17,7 @@ import {
} from 'react'
import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom'
import { $chatOnboardingSolo, $chatOnboardingThreadIds, $onboardingGreeting } from '@/components/onboarding-chat/assembly'
import { usePaneLifecycle, usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useI18n } from '@/i18n'
import { messagePaintWeight } from '@/lib/render-weight'
@@ -163,6 +164,73 @@ export function shouldRePinOnTranscriptReload(opts: { sessionSwitched: boolean;
return opts.sessionSwitched || !opts.settledNonEmpty
}
/** The greeting types once per app run. The row starts on the pre-session
* draft and MOUNTS AGAIN when the seeded session replaces it (new thread key)
* — replaying the animation there would read as the agent stuttering its own
* opening line. */
let greetingRevealed = false
/** The pre-banked onboarding greeting, revealed like a streamed turn: a short
* beat (the agent "starting"), then word-cluster typing over ~1.4s with a
* caret that blinks out when done. prefers-reduced-motion renders instantly.
* Reveal length is state; the full text stays in the DOM for layout only via
* the visible slice (height grows exactly like real streaming). */
function OnboardingGreetingRow({ text }: { text: string }) {
const [shown, setShown] = useState(() =>
greetingRevealed ||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
? text.length
: 0
)
const done = shown >= text.length
useEffect(() => {
if (shown >= text.length) {
greetingRevealed = true
return
}
// Word-cluster cadence: reveal 1-3 words per tick at 60-110ms — the
// shape of real model streaming, not a teletype.
const tick = () => {
setShown(current => {
if (current >= text.length) {
return current
}
let next = current
const words = 1 + Math.floor(Math.random() * 3)
for (let i = 0; i < words; i += 1) {
const space = text.indexOf(' ', next + 1)
next = space === -1 ? text.length : space
}
return Math.min(next, text.length)
})
}
const start = window.setTimeout(tick, shown === 0 ? 450 : 60 + Math.random() * 50)
return () => window.clearTimeout(start)
}, [shown, text])
return (
<div
className="mb-(--conversation-turn-gap) whitespace-pre-wrap leading-relaxed"
data-onboarding-greeting
>
{text.slice(0, shown)}
{!done && (
<span className="ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.18em] animate-pulse bg-foreground/70" />
)}
</div>
)
}
export function subscribeToThreadForeground(shouldReanchor: () => boolean, onReanchor: () => void): () => void {
let frameId: number | null = null
let framePending = false
@@ -398,6 +466,51 @@ const TurnRow = memo(function TurnRow({ components, group, resetKey, virtualized
)
})
function useOnboardingTranscript(structuralSignature: string, sessionKey: string | null | undefined) {
const onboardingThreadIds = useStore($chatOnboardingThreadIds)
const onboardingGreeting = useStore($onboardingGreeting)
// Solo mode makes the pane the guided chat BEFORE the seeded session exists
// — kickoff spends seconds (a cold dev boot, minutes) on the setup profile
// and its backend, and the greeting must own that whole window. Without the
// solo arm the draft renders the vanilla wordmark hero until the ids land.
const onboardingSolo = useStore($chatOnboardingSolo)
const onboardingThread = onboardingSolo || Boolean(sessionKey && onboardingThreadIds.includes(sessionKey))
const bankedGreeting = onboardingThread && Boolean(onboardingGreeting)
const groups = useMemo(() => {
const built = buildGroups(structuralSignature)
if (!bankedGreeting) {
return built
}
// The banked greeting owns everything before the user's first visible
// message. The model's reply to the hidden kickoff is SUPPOSED to be an
// invisible ::onboarding{step="ready"} ack, but a small model narrates
// instead ("I'll start by understanding the current state of this
// task…" leaked in a live run) — so the guarantee is structural: leading
// assistant-only groups render as nothing, whatever they contain.
const firstTurn = built.findIndex(group => group.kind === 'turn')
return firstTurn === -1 ? [] : firstTurn === 0 ? built : built.slice(firstTurn)
}, [structuralSignature, bankedGreeting])
const threadType = onboardingThread ? 'onboarding' : undefined
// The guided chat's opening line is PRE-BANKED and rendered here, client-
// side, the instant the thread mounts — the model's cold first turn took up
// to 10s in live runs and the greeting must never wait on it. The kickoff
// brief tells the model exactly what was said; its first reply is an
// invisible ready-ack, and the conversation continues from the user's name.
// It TYPES itself in (OnboardingGreetingRow) so it reads as the agent
// speaking, not a static label — the banked line must be indistinguishable
// from a streamed turn.
const greetingRow =
threadType === 'onboarding' && onboardingGreeting ? <OnboardingGreetingRow text={onboardingGreeting} /> : null
return { groups, onboardingThread, threadType, greetingRow }
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
afterContent,
clampToComposer,
@@ -424,11 +537,16 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
)
const { t } = useI18n()
// Row structure is memoized on the STRUCTURAL signature only, so streaming
// part-appends can't churn group identity (that would defeat the rows memo
// below on every tick). Weights are folded in separately for the budget.
const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
const { groups, onboardingThread, threadType, greetingRow } = useOnboardingTranscript(
structuralSignature,
sessionKey
)
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) && !onboardingThread
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
@@ -1071,6 +1189,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
<div
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
data-slot="aui_thread-content"
data-thread-type={threadType}
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
{(hiddenCount > 0 || olderAvailable) && (
@@ -1082,6 +1201,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
{t.assistant.thread.showEarlier}
</button>
)}
{greetingRow}
{rows}
{loadingIndicator}
{afterContent}
@@ -9,6 +9,7 @@ import { useStore } from '@nanostores/react'
import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { ConnectorExecution, ConnectorTool } from '@/components/assistant-ui/connector-tool'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { McpSetupTool } from '@/components/assistant-ui/mcp-setup-tool'
import { AgentDeliveryNotice, deliveryTargetFromCommand } from '@/components/assistant-ui/thread/agent-delivery'
@@ -20,7 +21,9 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
import { useI18n } from '@/i18n'
import { connectorCalls } from '@/lib/connector-tools'
import { generatedImageFromResult } from '@/lib/generated-images'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks'
import { isTodoToolName } from '@/lib/todos'
import { useEnterAnimation } from '@/lib/use-enter-animation'
@@ -105,6 +108,14 @@ const ChainToolFallback: FC<TimelineToolCallProps> = props => {
)
}
if (isOnboardingEnabled() && props.toolName === 'manage_connections') {
return <ConnectorTool {...props} />
}
if (isOnboardingEnabled() && connectorCalls(props.toolName, props.args).length > 0) {
return <ConnectorExecution {...props} />
}
if (props.toolName === 'setup_mcp') {
return <McpSetupTool {...props} />
}
@@ -41,8 +41,10 @@ import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { ToolIcon } from '@/components/ui/tool-icon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { connectorCalls } from '@/lib/connector-tools'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { normalize } from '@/lib/text'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@@ -999,7 +1001,13 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
const toolNameKey = useAuiState(state =>
state.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.map(part => (part.type === 'tool-call' ? part.toolName : ''))
.map(part =>
part.type === 'tool-call'
? isOnboardingEnabled() && connectorCalls(part.toolName, part.args).length
? 'manage_connections'
: part.toolName
: ''
)
.join('\u0000')
)
@@ -3,17 +3,24 @@ import { useMemo } from 'react'
import { type Contribution, useContributions } from '@/contrib'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import {
type ParsedTranscriptDirective,
parseTranscriptDirective,
segmentTranscriptDirectives,
TRANSCRIPT_DIRECTIVE_AREA,
type TranscriptDirectiveContribution
type TranscriptDirectiveContribution,
type TranscriptParagraphSegment
} from '@/lib/transcript-directives'
// B4 reworks the parser; until then the prototype's parser is an onboarding feature.
const onboardingEnabled = isOnboardingEnabled()
/**
* The transcript's directive slot. Given a paragraph's raw text, renders the
* registered plugin component when the whole paragraph is a claimed
* `::name{...}` directive; returns null otherwise so the caller keeps its
* plain `<p>` — an unclaimed directive is just prose.
* The transcript's directive slot. Given text, renders the plugin component
* for every claimed `::name{...}` in it, in order — several, because models
* merge lines under formatting pressure. Nothing renders for a name no plugin
* claimed; that text stays the prose it always was.
*
* Resolution is registry-backed (`transcript.directives`), so hot-loading a
* plugin upgrades already-rendered paragraphs in place, exactly like every
@@ -39,12 +46,63 @@ function claimFor(contributions: readonly Contribution[], name: string) {
return contributions.find(c => (c.data as TranscriptDirectiveContribution | undefined)?.name === name)
}
const DirectiveEntry: FC<{
contribution: Contribution
parsed: ParsedTranscriptDirective
streaming: boolean
}> = ({ contribution, parsed, streaming }) => {
const render = (contribution.data as TranscriptDirectiveContribution).render
// Stable component IDENTITY per (render, parsed) — a fresh type per parent
// render would remount the widget (card-sized jump). Streaming arrives as a
// real prop on that stable type, so the settle flip re-renders the same
// mount instead of being memo-skipped (ref-only reads were exactly that).
const Leaf = useMemo(
() =>
function DirectiveLeafHost({ streaming: live }: { streaming: boolean }) {
return <>{render({ attrs: parsed.attrs, source: parsed.source, streaming: live })}</>
},
[render, parsed]
)
return (
<ContribBoundary id={contribution.id} variant="chip">
<Leaf streaming={streaming} />
</ContribBoundary>
)
}
export const TranscriptDirectiveLeaf: FC<{ text: string; streaming?: boolean }> = ({ text, streaming }) => {
const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA)
const parsed = useMemo(() => parseTranscriptDirective(text), [text])
const match = parsed ? claimFor(contributions, parsed.name) : undefined
const contribution = match?.data as TranscriptDirectiveContribution | undefined
const render = contribution?.render
const segments = useMemo<TranscriptParagraphSegment[] | null>(() => {
if (onboardingEnabled) {
return segmentTranscriptDirectives(text)
}
const parsed = parseTranscriptDirective(text)
return parsed ? [{ kind: 'directive', directive: parsed }] : null
}, [text])
const entries = useMemo(
() =>
(segments ?? []).flatMap(segment => {
if (segment.kind !== 'directive') {
return []
}
const match = claimFor(contributions, segment.directive.name)
return match ? [{ key: `${match.id}:${segment.directive.source}`, match, parsed: segment.directive }] : []
}),
[contributions, segments]
)
const match = entries[0]?.match
const parsed = entries[0]?.parsed
// SAFETY: claimFor resolved this entry from the directive area by its registered name.
const render = (match?.data as TranscriptDirectiveContribution | undefined)?.render
// Stable component identity for ContribRender (which mounts this AS a
// component): a fresh closure per render would remount the widget on
@@ -57,23 +115,88 @@ export const TranscriptDirectiveLeaf: FC<{ text: string; streaming?: boolean }>
[render, parsed, streaming]
)
if (!match || !renderLeaf) {
if (!onboardingEnabled) {
if (!match || !renderLeaf) {
return null
}
return (
<ContribBoundary id={match.id} variant="chip">
<ContribRender render={renderLeaf} />
</ContribBoundary>
)
}
if (entries.length === 0) {
return null
}
return (
<ContribBoundary id={match.id} variant="chip">
<ContribRender render={renderLeaf} />
</ContribBoundary>
<>
{entries.map(entry => (
<DirectiveEntry contribution={entry.match} key={entry.key} parsed={entry.parsed} streaming={streaming ?? false} />
))}
</>
)
}
/** True when the paragraph text will resolve to a registered directive —
* callers that must decide `<p>` vs slot before rendering use this with the
* same registry snapshot the leaf reads. */
export function useIsClaimedDirective(text: string | null): boolean {
const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA)
const parsed = text === null ? null : parseTranscriptDirective(text)
/** A paragraph resolved against the registry: the prose to keep as prose, and
* the claimed directives to render as cards, in the order they were written. */
export type ResolvedParagraphSegment = { kind: 'prose'; text: string } | { kind: 'directive'; source: string }
return parsed !== null && claimFor(contributions, parsed.name) !== undefined
/**
* How a paragraph should render. Null means "as the plain `<p>` it always
* was" — no directive in it, or none that anyone registered.
*
* A directive nobody claimed is folded back into the prose around it, which is
* what keeps this from taking text away from the reader: the only thing that
* can be lifted out of a sentence is markup a plugin is standing by to draw.
*/
export function useResolvedParagraph(text: string | null): ResolvedParagraphSegment[] | null {
const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA)
return useMemo(() => {
if (!onboardingEnabled) {
const parsed = text === null ? null : parseTranscriptDirective(text)
return parsed && claimFor(contributions, parsed.name) ? [{ kind: 'directive', source: parsed.source }] : null
}
const segments = text === null ? null : segmentTranscriptDirectives(text)
if (!segments) {
return null
}
const out: ResolvedParagraphSegment[] = []
let claimed = false
for (const segment of segments) {
const isCard = segment.kind === 'directive' && claimFor(contributions, segment.directive.name) !== undefined
if (isCard) {
claimed = true
out.push({ kind: 'directive', source: segment.directive.source })
continue
}
// Prose, or an unclaimed directive that is only ever text. Merge into the
// run before it so a fold never splits one sentence across two <p>s.
const raw = segment.kind === 'prose' ? segment.text : segment.directive.source
const previous = out.at(-1)
if (previous?.kind === 'prose') {
previous.text += raw
} else {
out.push({ kind: 'prose', text: raw })
}
}
if (!claimed) {
return null
}
return out.filter(segment => segment.kind === 'directive' || segment.text.trim() !== '')
}, [contributions, text])
}
@@ -0,0 +1,31 @@
# Intro reveal
The cinematic runs in a transparent Electron window at `?win=intro`.
It covers the primary display and plays over the desktop while the app hides.
The surface owns its animation clock and synthesized sound; it has no gateway.
The main renderer owns the phase and the persistent seen key.
The seven beats are ask, send, working, reply, everywhere, brand and dissolve.
Typing, tool activity, the cube and sound follow the same score.
Normal playback lasts 22 seconds; the exit dissolve lasts 900 ms.
Reduced motion shows the brand briefly. Click, Enter or Escape skips.
Sound defaults on and respects the existing haptics mute preference.
There is one launch gate, enabled by `HERMES_GUEST_ONBOARDING=1` or
`--guest-onboarding`. Preload exposes that decision as `guestOnboardingEnabled`.
First-run eligibility also requires an unseen intro and no first-run skip.
With the launch gate off, neither the store nor native IPC opens the film.
The renderer deadman is 26 seconds. The independent native watchdog is
34 seconds, deliberately longer; both return the main window if playback stalls.
Finishing records seen, clears the phase and requests `{showMain: true}`.
The guided-chat edge and app-shell mount arrive with the later gate/handoff steps.
Rehearse from `apps/desktop` with isolated app state:
```sh
intro_tmp=$(mktemp -d /tmp/hermes-intro.XXXXXX)
env -u NODE_ENV HERMES_GUEST_ONBOARDING=1 HERMES_HOME="$intro_tmp/.hermes" HERMES_DESKTOP_USER_DATA_DIR="$intro_tmp/electron-user-data" npm run dev
```
Collapse comes from the UI package; JetBrains Mono comes from desktop styles.
@@ -0,0 +1,81 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import {
$introReveal,
finishIntroReveal,
installIntroRevealBridgeListeners,
isIntroRevealEnabled,
leaveIntroReveal,
shouldPlayFirstRunIntro,
startIntroReveal
} from '@/store/intro-reveal'
import { $desktopOnboarding } from '@/store/onboarding'
import { beginOnboardingFlow, queueGuideAfterIntro } from '@/store/onboarding-gate'
import { IntroRevealSurface } from './intro-reveal-surface'
import { INTRO_DEADMAN_MS, INTRO_EXIT_MS, INTRO_WALL_MS } from './timeline'
interface IntroRevealGateProps {
enabled: boolean
}
export function IntroRevealGate({ enabled }: IntroRevealGateProps) {
const onboarding = useStore($desktopOnboarding)
const intro = useStore($introReveal)
const nativeSurface = Boolean(window.hermesDesktop?.introReveal)
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
useEffect(() => {
if (enabled && isIntroRevealEnabled()) {
return installIntroRevealBridgeListeners()
}
}, [enabled])
useEffect(() => {
if (!enabled || !isIntroRevealEnabled()) {
return
}
// Observe the store edge directly: a failed native open can finish before
// React renders the playing phase.
return $introReveal.listen((state, previous) => {
if (state.phase === 'hidden' && previous?.phase !== 'hidden') {
queueGuideAfterIntro()
}
})
}, [enabled])
useEffect(() => {
if (enabled && intro.phase === 'hidden' && shouldPlayFirstRunIntro(onboarding.firstRunSkipped)) {
beginOnboardingFlow()
startIntroReveal()
}
}, [enabled, intro.phase, onboarding.firstRunSkipped])
// The native surface owns rAF: the hidden main renderer's clock is throttled.
useEffect(() => {
if (intro.phase === 'hidden') {
return
}
const total = reduceMotion ? 2600 : nativeSurface ? INTRO_DEADMAN_MS : INTRO_WALL_MS
const id = window.setTimeout(
intro.phase === 'leaving' ? finishIntroReveal : leaveIntroReveal,
intro.phase === 'leaving' ? INTRO_EXIT_MS : total
)
return () => window.clearTimeout(id)
}, [intro.phase, nativeSurface, reduceMotion])
if (!enabled || !isIntroRevealEnabled() || intro.phase === 'hidden' || nativeSurface) {
return null
}
return (
<div className="fixed inset-0 z-(--z-onboarding)">
<IntroRevealSurface onSkip={leaveIntroReveal} />
</div>
)
}
@@ -0,0 +1,377 @@
import './intro-reveal.css'
import type { Ref } from 'react'
import { cn } from '@/lib/utils'
import { BrandClose } from './scenes/brand'
import { SideAgents } from './scenes/side-agents'
import { BLUE, BLUE_DIM, EASE, NOUS_SHADOW } from './scenes/style'
import { decoded } from './scenes/text'
import { INTRO_BEATS, INTRO_PROMPT, INTRO_REPLY_WORDS, INTRO_TOOL_ROWS } from './timeline'
import { useIntroClock } from './use-intro-clock'
import { viewportSlot } from './viewport-cube'
const INTRO_BEAT_INDEX: Record<string, number> = Object.fromEntries(INTRO_BEATS.map((b, i) => [b.id, i]))
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const SKIP = 'Skip'
const SURFACES = 'Desktop · Messages · Phone · Anywhere'
interface IntroRevealSurfaceProps {
onSkip?: () => void
}
export function IntroRevealSurface({ onSkip }: IntroRevealSurfaceProps = {}) {
const { frame, leaving, faded, skip, glowRef, stageRef, brandRef, viewportRef } = useIntroClock(onSkip)
const everywhere = frame.beat >= INTRO_BEAT_INDEX.everywhere
const brand = frame.beat >= INTRO_BEAT_INDEX.brand
return (
<div
aria-label={SKIP}
aria-modal="true"
className={cn(
'fixed inset-0 flex items-center justify-center overflow-hidden',
'transition-opacity ease-out',
leaving ? 'pointer-events-none opacity-0 duration-[900ms]' : faded ? 'opacity-100 duration-700' : 'opacity-0'
)}
onClick={skip}
onKeyDown={e => {
if (e.key === 'Enter') {
skip()
}
}}
role="dialog"
tabIndex={-1}
>
{/* The film stays dark regardless of the desktop beneath it. */}
<div
className="absolute inset-0 bg-black/82"
style={{ opacity: faded && !leaving ? 1 : 0, transition: `opacity 900ms ${EASE}` }}
/>
{/* One frame clock keeps the spotlight and brand group together. */}
<div
className="pointer-events-none absolute left-1/2 top-0 h-[130vmin] w-[150vmin] opacity-0"
ref={glowRef}
style={{
background:
'radial-gradient(ellipse 46% 44% at 50% 22%, rgba(255,255,255,0.16), rgba(255,255,255,0.045) 48%, transparent 72%)',
transform: 'translate(-50%, -30%) scale(0.9)'
}}
/>
{/* One transform keeps the constellation drifting as a group. */}
<div
className="relative flex items-center justify-center gap-[2vw]"
ref={stageRef}
style={{ perspective: '1400px', transformStyle: 'preserve-3d', willChange: 'transform, opacity' }}
>
<SideAgents active={everywhere && !brand} side="left" tick={frame.tick} />
<HeroChat frame={frame} viewportRef={viewportRef} />
<SideAgents active={everywhere && !brand} side="right" tick={frame.tick} />
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-[13vh] text-center text-[1.02rem] tracking-[0.34em] text-white/60 uppercase"
style={{
fontFamily: "'Collapse', sans-serif",
opacity: everywhere && !brand ? 1 : 0,
transform: everywhere && !brand ? 'translateY(0)' : 'translateY(12px)',
transition: `opacity 620ms ${EASE} 180ms, transform 620ms ${EASE} 180ms`
}}
>
{SURFACES}
</div>
<BrandClose ref={brandRef} />
<button
className="absolute bottom-6 right-7 text-[0.72rem] uppercase tracking-[0.24em] text-white/40 transition-colors hover:text-white/80"
onClick={skip}
style={{ fontFamily: "'Collapse', sans-serif" }}
type="button"
>
{SKIP}
</button>
</div>
)
}
interface HeroChatProps {
frame: ReturnType<typeof useIntroClock>['frame']
viewportRef: Ref<HTMLCanvasElement>
}
function HeroChat({ frame, viewportRef }: HeroChatProps) {
const beat = frame.beat
const sent = beat >= INTRO_BEAT_INDEX.send
const replying = beat >= INTRO_BEAT_INDEX.reply
const everywhere = beat >= INTRO_BEAT_INDEX.everywhere
const typedText = INTRO_PROMPT.slice(0, frame.typed)
const replyText = INTRO_REPLY_WORDS.slice(0, frame.replyWords).join(' ')
return (
<div
className="relative w-[46vw] min-w-[560px] max-w-[900px] rounded-xl p-7"
style={{
background: 'rgba(10, 11, 14, 0.88)',
border: '1px solid rgba(255,255,255,0.09)',
boxShadow: NOUS_SHADOW,
animation: 'intro-hover-a 8.4s ease-in-out infinite alternate',
transform: everywhere ? 'rotateX(4deg) translateZ(-60px) scale(0.86)' : 'rotateX(1.6deg) scale(1)',
transition: `transform 1100ms ${EASE}`,
transformOrigin: 'center 60%',
willChange: 'transform'
}}
>
<ViewportNode frame={frame} viewportRef={viewportRef} />
<div className="flex min-h-[3.9rem] justify-end">
<div
className="max-w-[80%] px-1 py-3.5 text-right text-[1.02rem] leading-7 text-white/92"
style={{
opacity: sent ? 1 : 0,
transform: sent ? 'translateY(0) scale(1)' : 'translateY(10px) scale(0.97)',
transition: `opacity 480ms ${EASE}, transform 480ms ${EASE}`,
willChange: 'transform, opacity'
}}
>
{INTRO_PROMPT}
</div>
</div>
<div className="mt-5 grid min-h-[10.5rem] content-start gap-2.5">
{INTRO_TOOL_ROWS.map((row, i) => {
const shown = Boolean(frame.toolShown & (1 << i)) && sent
const done = Boolean(frame.toolDone & (1 << i))
return (
<div
className="flex items-center gap-3 rounded-lg px-4 py-3"
key={row.label}
style={{
background: 'rgba(255,255,255,0.045)',
border: '1px solid rgba(255,255,255,0.06)',
opacity: shown ? 1 : 0,
transform: shown ? 'translateY(0)' : 'translateY(6px)',
transition: `opacity 520ms ${EASE}, transform 520ms ${EASE}`,
willChange: 'transform, opacity'
}}
>
<span
className={cn('w-4 text-center font-mono text-[0.95rem]', !done && 'text-white/55')}
style={{ color: done ? BLUE : undefined, fontFamily: "'JetBrains Mono', monospace" }}
>
{done ? '✓' : SPINNER[frame.tick % SPINNER.length]}
</span>
<span
className="text-[0.66rem] font-bold uppercase tracking-[0.18em] text-white/55"
style={{ fontFamily: "'Collapse', sans-serif" }}
>
{row.label}
</span>
<span
className="ml-auto grid text-[0.8rem] text-white/50"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{/* Stacking keeps the running/done crossfade in place. */}
<span
className="col-start-1 row-start-1 text-right"
style={{ opacity: done ? 0 : 1, transition: `opacity 400ms ${EASE}` }}
>
{shown && !done ? decoded(row.runningText, row.at, frame.tick) : row.runningText}
</span>
<span
className="col-start-1 row-start-1 text-right"
style={{ color: BLUE_DIM, opacity: done ? 1 : 0, transition: `opacity 400ms ${EASE}` }}
>
{done ? decoded(row.doneText, row.doneAt, frame.tick, 380) : row.doneText}
</span>
</span>
</div>
)
})}
</div>
<div className="mt-5 min-h-[6.5rem]">
<div
className="max-w-[88%] rounded-xl rounded-bl-md px-5 py-3.5 text-[1.02rem] leading-7 text-white/88"
style={{
background: 'rgba(255,255,255,0.055)',
border: '1px solid rgba(255,255,255,0.07)',
opacity: replying ? 1 : 0,
transform: replying ? 'translateY(0)' : 'translateY(6px)',
transition: `opacity 500ms ${EASE}, transform 500ms ${EASE}`,
willChange: 'transform, opacity'
}}
>
{replyText || '\u00a0'}
{replying && frame.replyWords < INTRO_REPLY_WORDS.length ? (
<span
className="dither ml-1 inline-block h-[1.05em] w-[0.5em] translate-y-[3px]"
style={{ animation: 'intro-caret 0.9s step-end infinite', color: BLUE }}
/>
) : null}
</div>
</div>
<div className="mt-5">
<div
className="rounded-2xl px-3 py-2.5"
style={{
background: 'color-mix(in srgb, #16171b 78%, transparent)',
backdropFilter: 'blur(12px) saturate(1.12)',
border: '1px solid rgba(255,255,255,0.12)'
}}
>
<div className="min-h-[2rem] px-1.5 pt-0.5 text-[1.02rem] leading-7 text-white/90">
{sent || typedText.length === 0 ? (
<span className="text-white/28">Ask anything. Build anything.</span>
) : (
typedText
)}
{!sent ? (
<span
className="dither ml-0.5 inline-block h-[1.1em] w-[0.52em] translate-y-[3px]"
style={{ animation: 'intro-caret 1.05s step-end infinite', color: BLUE }}
/>
) : null}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<span className="grid size-6 place-items-center rounded-full text-white/45">
<svg
fill="none"
height="13"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.6"
viewBox="0 0 16 16"
width="13"
>
<path d="M8 3.5v9M3.5 8h9" />
</svg>
</span>
<span className="ml-auto grid size-6 place-items-center rounded-full text-white/45">
<svg
fill="none"
height="13"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width="13"
>
<rect height="12" rx="3" width="6" x="9" y="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
</span>
<span
className="grid size-[1.65rem] shrink-0 place-items-center rounded-full"
style={{
background: sent ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.92)',
color: '#0a0b0e',
transform: !sent && frame.typed >= INTRO_PROMPT.length ? 'scale(1.08)' : 'scale(1)',
transition: `transform 300ms ${EASE}, background 400ms ${EASE}`
}}
>
<svg
fill="none"
height="13"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.4"
viewBox="0 0 24 24"
width="13"
>
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</span>
</div>
</div>
</div>
</div>
)
}
function ViewportNode({ frame, viewportRef }: HeroChatProps) {
const viewport = viewportSlot(frame.tick * 45)
const sent = frame.beat >= INTRO_BEAT_INDEX.send
const everywhere = frame.beat >= INTRO_BEAT_INDEX.everywhere
return (
<>
<div
className="absolute -left-64 -top-20 w-52 rounded-xl"
style={{
background: 'rgba(10, 11, 14, 0.88)',
border: '1px solid rgba(255,255,255,0.09)',
boxShadow: NOUS_SHADOW,
animation: 'intro-hover-b 6.8s ease-in-out infinite alternate',
opacity: sent && !everywhere ? 1 : 0,
transform:
sent && !everywhere
? 'translateZ(70px) rotateX(-2deg) rotateY(2.5deg) translateY(0) scale(1)'
: everywhere
? 'translateZ(70px) rotateX(-2deg) rotateY(2.5deg) translateY(26px) scale(0.97)'
: 'translateZ(70px) rotateX(-2deg) rotateY(2.5deg) translateY(12px) scale(0.95)',
transition: `opacity 480ms ${EASE}, transform 560ms ${EASE}`,
willChange: 'transform, opacity'
}}
>
<div
className="flex items-center justify-between px-3 pt-2.5 text-[0.5rem] uppercase tracking-[0.2em] text-white/30"
style={{ fontFamily: "'Collapse', sans-serif" }}
>
<span className="flex items-center gap-1.5">
<span
className="inline-block size-1 rounded-full"
style={{ animation: 'intro-dot 1.6s ease-in-out infinite', background: BLUE }}
/>
viewport
</span>
<span
className="text-[0.6rem] normal-case tracking-normal"
style={{ color: BLUE_DIM, fontFamily: "'JetBrains Mono', monospace" }}
>
{decoded(viewport.mode, viewport.at, frame.tick, 300)}
</span>
</div>
<canvas className="block h-40 w-full" ref={viewportRef} />
<span className="absolute -right-[5px] top-1/2 size-2.5 -translate-y-1/2 rounded-full border border-black/55 bg-[#0a0b0e]" />
</div>
<svg
aria-hidden
className="pointer-events-none absolute -left-12 top-0 h-16 w-12 overflow-visible"
style={{ opacity: everywhere ? 0 : sent ? 1 : 0, transition: `opacity 300ms ${EASE}` }}
viewBox="0 0 48 64"
>
<path
d="M 0 15 C 21 15, 27 44, 48 44"
fill="none"
pathLength={1}
stroke="rgba(0,0,0,0.55)"
strokeDasharray="1"
strokeDashoffset={sent ? 0 : 1}
strokeWidth="1.5"
style={{ transition: `stroke-dashoffset 440ms ${EASE}` }}
/>
</svg>
<span
className="absolute -left-[5px] top-[40px] size-2.5 rounded-full border bg-[#0a0b0e]"
style={{
borderColor: 'rgba(0,0,0,0.55)',
opacity: everywhere ? 0 : sent ? 1 : 0,
transition: `opacity 380ms ${EASE}, border-color 380ms ${EASE}`
}}
/>
</>
)
}
@@ -0,0 +1,59 @@
@keyframes intro-caret {
0%,
55% {
opacity: 1;
}
56%,
100% {
opacity: 0;
}
}
@keyframes intro-dot {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@keyframes intro-float-a {
from {
transform: translateY(-5px);
}
to {
transform: translateY(6px);
}
}
@keyframes intro-float-b {
from {
transform: translateY(4px);
}
to {
transform: translateY(-7px);
}
}
@keyframes intro-float-c {
from {
transform: translateY(-3px);
}
to {
transform: translateY(5px);
}
}
@keyframes intro-hover-a {
from {
translate: 0 -4px;
}
to {
translate: 0 5px;
}
}
@keyframes intro-hover-b {
from {
translate: 0 4px;
}
to {
translate: 0 -6px;
}
}
@@ -0,0 +1,35 @@
import '@nous-research/ui/styles/fonts.css'
import { createRoot } from 'react-dom/client'
import { OverlayErrorBoundary } from '@/components/overlay-error-boundary'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { IntroRevealSurface } from './intro-reveal-surface'
export function mountIntroReveal(): void {
if (!isOnboardingEnabled()) {
return
}
document.title = 'Hermes'
const root = document.getElementById('root')
if (!root) {
return
}
// StrictMode would double-start this disposable window's clock and sound.
createRoot(root).render(
<OverlayErrorBoundary label="intro-reveal">
<IntroRevealSurface />
</OverlayErrorBoundary>
)
// Native ready-to-show can precede the first React paint.
requestAnimationFrame(() =>
requestAnimationFrame(() => {
window.hermesDesktop?.introReveal?.ready()
})
)
}
@@ -0,0 +1,36 @@
import type { ComponentProps } from 'react'
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
interface BrandCloseProps extends ComponentProps<'div'> {}
export function BrandClose({ ref }: BrandCloseProps) {
return (
<div
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-[3.2vmin] opacity-0"
ref={ref}
style={{ willChange: 'transform, opacity' }}
>
<img alt="" className="h-[32vmin] w-auto object-contain" src={assetPath('nous-badge.png')} />
<div className="flex flex-col items-center gap-[1.6vmin]">
<h1
className="text-[6.8vmin] leading-none uppercase text-white/95"
style={{
fontFamily: "'Collapse', sans-serif",
fontWeight: 700,
letterSpacing: '0.06em',
textShadow: '0 2px 24px rgba(0,0,0,0.45)'
}}
>
Hermes Agent
</h1>
<p
className="text-[1.35vmin] uppercase tracking-[0.42em] text-white/50"
style={{ fontFamily: "'Collapse', sans-serif" }}
>
Your agent, everywhere
</p>
</div>
</div>
)
}
@@ -0,0 +1,83 @@
import { INTRO_BEATS } from '../timeline'
import { BLUE, BLUE_FAINT, EASE, NOUS_SHADOW } from './style'
import { decoded } from './text'
const EVERYWHERE_T = INTRO_BEATS.find(b => b.id === 'everywhere')!.t
interface SideAgentsProps {
active: boolean
side: 'left' | 'right'
tick: number
}
export function SideAgents({ active, side, tick }: SideAgentsProps) {
const sideCard = (title: string, line1: string, line2: string, offset: string, delayMs = 0, tilt = 0) => (
<div
className="w-full rounded-xl p-5"
style={{
background: 'rgba(12, 13, 16, 0.82)',
border: '1px solid rgba(255,255,255,0.09)',
boxShadow: NOUS_SHADOW,
opacity: active ? 1 : 0,
transform: active
? `translateZ(-90px) rotateY(${tilt}deg) translateY(0) scale(1)`
: `translateZ(-90px) rotateY(${tilt}deg) translateY(${offset}) scale(0.94)`,
transition: `opacity 760ms ${EASE} ${delayMs}ms, transform 760ms ${EASE} ${delayMs}ms`,
willChange: 'transform, opacity'
}}
>
<div
className="mb-3 flex items-center gap-2 text-[0.72rem] uppercase tracking-[0.18em] text-white/50"
style={{ fontFamily: "'Collapse', sans-serif" }}
>
<span
className="inline-block size-1.5 rounded-full"
style={{ animation: 'intro-dot 1.6s ease-in-out infinite', background: BLUE }}
/>
{title}
</div>
<div className="text-[0.95rem] leading-6 text-white/85">{line1}</div>
<div
className="mt-1 text-[0.85rem] leading-6"
style={{ color: BLUE_FAINT, fontFamily: "'JetBrains Mono', monospace" }}
>
{active ? decoded(line2, EVERYWHERE_T + delayMs + 500, tick, 700) : line2}
</div>
</div>
)
return side === 'left' ? (
<div className="flex w-[19vw] min-w-[240px] flex-col gap-4 self-start pt-[6vh]">
<div style={{ animation: 'intro-float-a 5.2s ease-in-out infinite alternate' }}>
{sideCard(
'research agent',
'Apartment hunt: 3 new listings shortlisted',
'↳ compiling tour schedule…',
'26px',
0,
7
)}
</div>
<div style={{ animation: 'intro-float-b 6.1s ease-in-out infinite alternate' }}>
{sideCard('groceries', 'Weekly order built from your list', '↳ delivery booked for Sunday', '38px', 220, 7)}
</div>
</div>
) : (
<div className="flex w-[19vw] min-w-[240px] flex-col gap-4 self-end pb-[5vh]">
<div style={{ animation: 'intro-float-c 5.7s ease-in-out infinite alternate' }}>
{sideCard(
'inbox agent',
'2 replies drafted, waiting for your ok',
'↳ calendar updated for Friday',
'34px',
120,
-7
)}
</div>
<div style={{ animation: 'intro-float-a 6.6s ease-in-out infinite alternate' }}>
{sideCard('morning brief', 'Tomorrow: 3 meetings, rain at 8', '↳ ready before you wake', '30px', 340, -7)}
</div>
</div>
)
}
@@ -0,0 +1,13 @@
export const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
// Hermes blue — the app's --theme-primary (#0053fd), lifted for dark ground.
export const BLUE = '#4d8dff'
export const BLUE_DIM = 'rgba(77, 141, 255, 0.55)'
export const BLUE_FAINT = 'rgba(77, 141, 255, 0.4)'
// One shadow for every floating surface — --shadow-nous's recipe (single top
// light, layered contact→ambient, x=0, negative spread pulling each layer
// inward) restated for a dark ground at LOW opacity, so cards sit on the
// frost instead of dragging black halos across it.
export const NOUS_SHADOW =
'0 2px 4px -2px rgba(0,0,0,0.3), 0 8px 12px -6px rgba(0,0,0,0.24), 0 20px 28px -14px rgba(0,0,0,0.2), 0 36px 48px -28px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.05)'
@@ -0,0 +1,15 @@
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
export const scrambleGlyph = (i: number, tick: number) => {
const n = (i * 2654435761 + tick * 40503) >>> 0
return SCRAMBLE_CHARS[n % SCRAMBLE_CHARS.length]
}
/** Spaces stay fixed so word boundaries survive the scramble. */
export function decoded(text: string, bornAt: number, tick: number, spanMs = 520): string {
const age = tick * 45 - bornAt
const resolved = Math.max(0, Math.min(text.length, Math.ceil((age / spanMs) * text.length)))
return Array.from(text, (ch, i) => (ch === ' ' || i < resolved ? ch : scrambleGlyph(i, tick))).join('')
}
@@ -0,0 +1,217 @@
/** Beat scheduling keeps synthesized audio and animation on one clock. */
import { $hapticsMuted } from '@/store/haptics'
interface IntroAudioWindow extends Window {
webkitAudioContext?: typeof AudioContext
}
interface IntroPad {
setLevel: (level: number) => void
stop: () => void
}
let ctx: AudioContext | null = null
let master: GainNode | null = null
function getCtx(): AudioContext | null {
if (globalThis.window === undefined) {
return null
}
try {
if (!ctx) {
const audioWindow: IntroAudioWindow = window
const Ctor = window.AudioContext || audioWindow.webkitAudioContext
if (!Ctor) {
return null
}
ctx = new Ctor()
master = ctx.createGain()
master.gain.value = 0.55
master.connect(ctx.destination)
}
if (ctx.state === 'suspended') {
void ctx.resume().catch(() => undefined)
}
return ctx
} catch {
return null
}
}
function env(g: GainNode, t0: number, peak: number, attack: number, decay: number): void {
g.gain.setValueAtTime(0.0001, t0)
g.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), t0 + attack)
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay)
}
export function startPad(): IntroPad {
const ac = getCtx()
if (!ac || !master || $hapticsMuted.get()) {
return { setLevel: () => undefined, stop: () => undefined }
}
const lp = ac.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = 600
lp.Q.value = 0.4
const g = ac.createGain()
g.gain.value = 0
lp.connect(g)
g.connect(master)
// D3 / A3 / D4 with slight detune per voice for width.
const oscs: OscillatorNode[] = []
for (const [freq, detune, gain] of [
[146.83, -4, 0.5],
[220.0, 3, 0.35],
[293.66, -2, 0.28]
] as const) {
const osc = ac.createOscillator()
const vg = ac.createGain()
osc.type = 'triangle'
osc.frequency.value = freq
osc.detune.value = detune
vg.gain.value = gain
osc.connect(vg)
vg.connect(lp)
osc.start()
oscs.push(osc)
}
return {
setLevel: v => {
const t = ac.currentTime
// The filter opens with the swell so the chord brightens as it rises.
lp.frequency.cancelScheduledValues(t)
lp.frequency.setTargetAtTime(600 + v * 900, t, 0.5)
g.gain.setTargetAtTime(v * 0.11, t, 0.35)
},
stop: () => {
const t = ac.currentTime
g.gain.setTargetAtTime(0, t, 0.4)
window.setTimeout(() => {
for (const osc of oscs) {
osc.stop()
}
}, 1600)
}
}
}
export function playTick(pitch = 1): void {
const ac = getCtx()
if (!ac || !master || $hapticsMuted.get()) {
return
}
const t0 = ac.currentTime
const osc = ac.createOscillator()
const g = ac.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(587.33 * pitch, t0)
osc.frequency.exponentialRampToValueAtTime(440 * pitch, t0 + 0.12)
env(g, t0, 0.09, 0.004, 0.22)
osc.connect(g)
g.connect(master)
osc.start(t0)
osc.stop(t0 + 0.3)
}
export function playSwell(): void {
const ac = getCtx()
if (!ac || !master || $hapticsMuted.get()) {
return
}
const t0 = ac.currentTime
const osc = ac.createOscillator()
const g = ac.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(110, t0)
osc.frequency.exponentialRampToValueAtTime(220, t0 + 2.4)
g.gain.setValueAtTime(0.0001, t0)
g.gain.exponentialRampToValueAtTime(0.07, t0 + 1.2)
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 3.2)
osc.connect(g)
g.connect(master)
osc.start(t0)
osc.stop(t0 + 3.4)
}
export function playLatch(): void {
const ac = getCtx()
if (!ac || !master || $hapticsMuted.get()) {
return
}
const t0 = ac.currentTime
for (const [freq, gain, delay] of [
[293.66, 0.13, 0],
[440.0, 0.09, 0.03]
] as const) {
const osc = ac.createOscillator()
const g = ac.createGain()
osc.type = 'sine'
osc.frequency.value = freq
env(g, t0 + delay, gain, 0.014, 0.7)
osc.connect(g)
g.connect(master)
osc.start(t0 + delay)
osc.stop(t0 + delay + 0.8)
}
}
export function playResolve(): void {
const ac = getCtx()
if (!ac || !master || $hapticsMuted.get()) {
return
}
const t0 = ac.currentTime
const partials: Array<readonly [number, number, number]> = [
[587.33, 0.08, 0],
[739.99, 0.07, 0.1],
[880.0, 0.06, 0.2],
[1174.66, 0.05, 0.32]
]
for (const [freq, gain, delay] of partials) {
const osc = ac.createOscillator()
const g = ac.createGain()
osc.type = 'triangle'
osc.frequency.value = freq
env(g, t0 + delay, gain, 0.02, 1.1)
osc.connect(g)
g.connect(master)
osc.start(t0 + delay)
osc.stop(t0 + delay + 1.2)
}
}
@@ -0,0 +1,163 @@
/** Score time stretches animation and sound together through INTRO_PACE.
* Real timers use wall time so recovery still works if the frame clock stalls. */
/** Playback rate for the score. >1 plays slower. */
export const INTRO_PACE = 1.25
export interface IntroBeat {
cue?: 'latch' | 'resolve' | 'swell' | 'tick'
id: string
/** ms from sequence start. */
t: number
}
export const INTRO_BEATS: IntroBeat[] = [
{ id: 'ask', cue: 'tick', t: 0 },
{ id: 'send', cue: 'tick', t: 3900 },
{ id: 'working', cue: 'swell', t: 4500 },
{ id: 'reply', cue: 'latch', t: 7600 },
{ id: 'everywhere', t: 10600 },
{ id: 'brand', cue: 'resolve', t: 13600 },
{ id: 'dissolve', t: 16400 }
]
export const INTRO_TOTAL_MS = 17600
export const INTRO_WALL_MS = Math.round(INTRO_TOTAL_MS * INTRO_PACE)
/** Exit dissolve window after the sequence — kept in one place so the surface
* fade and the window self-close agree. A real CSS transition, so it is wall
* time and the pace does not touch it. */
export const INTRO_EXIT_MS = 900
/** Overlay deadman margin: the surface force-closes its own window this long
* after the nominal end even if the clock stalls, and the main process holds
* an independent watchdog above that. The screen ALWAYS comes back. */
export const INTRO_DEADMAN_MS = INTRO_WALL_MS + 4000
export function sampleCurves(t: number) {
const ramp = (from: number, to: number) => {
if (to <= from) {
return t >= to ? 1 : 0
}
const f = Math.min(1, Math.max(0, (t - from) / (to - from)))
return f * f * (3 - 2 * f)
}
const brandT = INTRO_BEATS.find(b => b.id === 'brand')!.t
const dissolveT = INTRO_BEATS.find(b => b.id === 'dissolve')!.t
return {
glow: ramp(brandT - 300, brandT + 1400) * (1 - ramp(dissolveT, dissolveT + 900)),
scatter: ramp(dissolveT, dissolveT + 1000)
}
}
export const INTRO_PROMPT = 'Model a hero cube in Blender and cycle it through some materials'
export const INTRO_REPLY_WORDS =
'Done — materials compiled and previewed on the cube. Want a turntable render exported?'.split(' ')
/** Tool activity rows that materialize during `working`. `doneAt` flips the
* trailing status from running to the check state. Times are absolute
* sequence ms so the whole piece stays on one clock. */
export interface IntroToolRow {
at: number
doneAt: number
doneText: string
icon: 'browser' | 'cron' | 'terminal'
label: string
runningText: string
}
export const INTRO_TOOL_ROWS: IntroToolRow[] = [
{
at: 4700,
doneAt: 6100,
doneText: 'scene linked',
icon: 'browser',
label: 'blender-mcp',
runningText: 'connecting to Blender…'
},
{
at: 5350,
doneAt: 6800,
doneText: 'metal · rough 0.2',
icon: 'terminal',
label: 'metal',
runningText: 'compiling metal…'
},
{
at: 6000,
doneAt: 7300,
doneText: 'glass · ior 1.45',
icon: 'cron',
label: 'glass',
runningText: 'compiling glass…'
}
]
/** Per-character reveal times for the typed prompt: human cadence (variable
* inter-key delays, tiny pauses after spaces), deterministic via a seeded
* LCG so every run is identical and there is nothing to jitter. */
export function typingSchedule(text: string, startMs: number, endMs: number): number[] {
let seed = 1337
const rand = () => {
seed = (seed * 48271) % 2147483647
return seed / 2147483647
}
const weights = Array.from(text, ch => {
const base = 1 + rand() * 1.1
// Breathe after word boundaries; hesitate slightly on punctuation.
if (ch === ' ') {
return base + 0.9
}
if (/[,.!?]/.test(ch)) {
return base + 1.4
}
return base
})
const total = weights.reduce((a, b) => a + b, 0)
const span = endMs - startMs
const times: number[] = []
let acc = 0
for (const w of weights) {
acc += w
times.push(startMs + (acc / total) * span)
}
return times
}
/** Word reveal times for the streaming reply — front-loaded like real token
* streaming (fast burst, gentle tail). */
export function streamingSchedule(wordCount: number, startMs: number, endMs: number): number[] {
const times: number[] = []
const span = endMs - startMs
for (let i = 0; i < wordCount; i += 1) {
const f = (i + 1) / wordCount
// easeOutQuad on the index → early words arrive quicker.
times.push(startMs + (1 - (1 - f) * (1 - f)) * span)
}
return times
}
/** Beats that land within (prevT, t] — used to fire sound cues exactly once
* even when rAF cadence is irregular. Pass prevT = -1 on the first frame so
* the t=0 beat fires. */
export function beatsBetween(prevT: number, t: number): IntroBeat[] {
return INTRO_BEATS.filter(b => b.t > prevT && b.t <= t)
}
@@ -0,0 +1,275 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { playLatch, playResolve, playSwell, playTick, startPad } from './sound'
import {
beatsBetween,
INTRO_BEATS,
INTRO_DEADMAN_MS,
INTRO_EXIT_MS,
INTRO_PACE,
INTRO_PROMPT,
INTRO_REPLY_WORDS,
INTRO_TOOL_ROWS,
INTRO_TOTAL_MS,
type IntroBeat,
sampleCurves,
streamingSchedule,
typingSchedule
} from './timeline'
import { drawViewport, VIEWPORT_END_MS } from './viewport-cube'
const SOUND_CUES = {
tick: (beat: string) => playTick(beat === 'send' ? 1.35 : 1),
swell: playSwell,
latch: playLatch,
resolve: playResolve
} satisfies Record<NonNullable<IntroBeat['cue']>, (beat: string) => void>
const INTRO_BEAT_INDEX: Record<string, number> = Object.fromEntries(INTRO_BEATS.map((b, i) => [b.id, i]))
const SEND_T = INTRO_BEATS.find(b => b.id === 'send')!.t
const REPLY_T = INTRO_BEATS.find(b => b.id === 'reply')!.t
const EVERYWHERE_T = INTRO_BEATS.find(b => b.id === 'everywhere')!.t
const BRAND_T = INTRO_BEATS.find(b => b.id === 'brand')!.t
const TYPE_TIMES = typingSchedule(INTRO_PROMPT, 700, SEND_T - 450)
const WORD_TIMES = streamingSchedule(INTRO_REPLY_WORDS.length, REPLY_T + 150, REPLY_T + 2400)
interface Frame {
beat: number
replyWords: number
/** 45ms quantized clock — drives braille spinners + scramble decodes. */
tick: number
toolDone: number // bitmask
toolShown: number // bitmask
typed: number
}
const INITIAL_FRAME: Frame = { beat: 0, replyWords: 0, tick: 0, toolDone: 0, toolShown: 0, typed: 0 }
function frameAt(t: number, beat: number): Frame {
let typed = 0
while (typed < TYPE_TIMES.length && TYPE_TIMES[typed] <= t) {
typed += 1
}
let replyWords = 0
while (replyWords < WORD_TIMES.length && WORD_TIMES[replyWords] <= t) {
replyWords += 1
}
let toolShown = 0
let toolDone = 0
for (let i = 0; i < INTRO_TOOL_ROWS.length; i += 1) {
if (t >= INTRO_TOOL_ROWS[i].at) {
toolShown |= 1 << i
}
if (t >= INTRO_TOOL_ROWS[i].doneAt) {
toolDone |= 1 << i
}
}
return { beat, replyWords, tick: Math.floor(t / 45), toolDone, toolShown, typed }
}
export function useIntroClock(onSkip?: () => void) {
const glowRef = useRef<HTMLDivElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const brandRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLCanvasElement>(null)
const [frame, setFrame] = useState<Frame>(INITIAL_FRAME)
const [clockLeaving, setClockLeaving] = useState(false)
const [faded, setFaded] = useState(false)
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const skip = useCallback(() => {
setClockLeaving(true)
if (onSkip) {
onSkip()
return
}
window.hermesDesktop?.introReveal?.skip?.()
window.setTimeout(() => {
void window.hermesDesktop?.introReveal?.close?.({ showMain: true }).catch(() => undefined)
}, 1200)
}, [onSkip])
useEffect(() => {
const id = window.setTimeout(() => {
void window.hermesDesktop?.introReveal?.close?.({ showMain: true }).catch(() => undefined)
}, INTRO_DEADMAN_MS)
return () => window.clearTimeout(id)
}, [])
// The native window keeps the clock running while the main app is hidden.
useEffect(() => {
if (reduceMotion) {
setFrame({ ...INITIAL_FRAME, beat: INTRO_BEAT_INDEX.brand })
// This branch has no frame loop to reveal the brand or hide the demo.
for (const element of [glowRef.current, brandRef.current]) {
if (element) {
element.style.opacity = '1'
}
}
if (stageRef.current) {
stageRef.current.style.opacity = '0'
}
playLatch()
const id = window.setTimeout(() => {
skip()
}, 2600)
return () => window.clearTimeout(id)
}
const pad = startPad()
const start = performance.now()
let prevT = -1
// `start` is wall time; everything downstream of `elapsed` is score time.
// Dividing once, here, is what makes the whole piece — beats, schedules,
// the cube's rotation and tear — play at INTRO_PACE with nothing else to
// keep in step.
const elapsed = () => (performance.now() - start) / INTRO_PACE
let raf = 0
let currentBeat = 0
let reportedDone = false
let lastFrameKey = ''
const tick = () => {
const t = elapsed()
for (const b of beatsBetween(prevT, t)) {
currentBeat = INTRO_BEAT_INDEX[b.id] ?? currentBeat
if (b.cue) {
SOUND_CUES[b.cue](b.id)
}
}
prevT = t
drawViewportFrame(viewportRef.current, t)
const next = frameAt(t, currentBeat)
const key = `${next.beat}:${next.typed}:${next.replyWords}:${next.toolShown}:${next.toolDone}`
if (key !== lastFrameKey) {
lastFrameKey = key
setFrame(next)
}
const curves = sampleCurves(t)
pad.setLevel(Math.max(curves.glow, next.beat >= INTRO_BEAT_INDEX.working ? 0.45 : 0.2))
const ss = (from: number, to: number) => {
const f = Math.min(1, Math.max(0, (t - from) / (to - from)))
return f * f * (3 - 2 * f)
}
// The stage never sits still: a slow drift-up across the whole piece,
// a gentle scale breath, and a lateral ease as the constellation opens
// (hero sits slightly left once the side agents arrive — asymmetric,
// not centered). All one transform, compositor-only.
if (stageRef.current) {
const rise = -10 - ss(0, INTRO_TOTAL_MS) * 26
const breathe = 1 + Math.sin(t / 2600) * 0.004
const openScale = 1 - ss(EVERYWHERE_T - 600, EVERYWHERE_T + 1200) * 0.06
const lateral = ss(EVERYWHERE_T - 600, EVERYWHERE_T + 1400) * -18
const brandPush = ss(BRAND_T - 300, BRAND_T + 1200)
stageRef.current.style.transform = `translate(${lateral}px, ${rise + brandPush * -14}px) scale(${breathe * openScale * (1 - brandPush * 0.05)})`
stageRef.current.style.opacity = String(1 - brandPush)
}
// The ENTIRE brand close (glow + badge + wordmark + tagline) rides ONE
// alpha so nothing is ever readable against a half-faded bloom. It
// rises in with the glow and the whole group breathes out together
// through the exit window.
const brandIn = ss(BRAND_T - 200, BRAND_T + 1300)
const brandOut = 1 - ss(INTRO_TOTAL_MS - 500, INTRO_TOTAL_MS + INTRO_EXIT_MS - 100)
const brandAlpha = brandIn * brandOut
if (glowRef.current) {
glowRef.current.style.opacity = String(brandAlpha)
glowRef.current.style.transform = `translate(-50%, -30%) scale(${0.9 + brandIn * 0.14})`
}
if (brandRef.current) {
brandRef.current.style.opacity = String(brandAlpha)
brandRef.current.style.transform = `translateY(${(1 - brandIn) * 26 - brandIn * 6}px) scale(${0.94 + brandIn * 0.06})`
}
if (t >= INTRO_TOTAL_MS && !reportedDone) {
reportedDone = true
skip()
}
if (t < INTRO_TOTAL_MS + INTRO_EXIT_MS) {
raf = requestAnimationFrame(tick)
}
}
raf = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(raf)
pad.stop()
}
}, [reduceMotion, skip])
// ── Esc to skip (local — never depends on the main renderer). ───────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
skip()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [skip])
useEffect(() => {
const id = requestAnimationFrame(() => setFaded(true))
return () => cancelAnimationFrame(id)
}, [])
return { frame, leaving: clockLeaving, faded, skip, glowRef, stageRef, brandRef, viewportRef }
}
function drawViewportFrame(canvas: HTMLCanvasElement | null, t: number) {
if (canvas && t < VIEWPORT_END_MS) {
const dpr = Math.min(2, window.devicePixelRatio || 1)
const cw = canvas.clientWidth
const ch = canvas.clientHeight
if (cw > 0 && ch > 0) {
if (canvas.width !== cw * dpr || canvas.height !== ch * dpr) {
canvas.width = cw * dpr
canvas.height = ch * dpr
}
const ctx2d = canvas.getContext('2d')
if (ctx2d) {
ctx2d.setTransform(dpr, 0, 0, dpr, 0, 0)
drawViewport(ctx2d, cw, ch, t)
}
}
}
}
@@ -0,0 +1,535 @@
/** Canvas geometry and texture noise derive from score time so every playback agrees. */
import { INTRO_BEATS } from './timeline'
const N = 4
/** How long the cube is alive. The surface stops drawing here, so the last
* slot's tear-out is timed against it. */
export const VIEWPORT_END_MS = INTRO_BEATS.find(b => b.id === 'everywhere')!.t + 700
/**
* Materials are PLACED, not cycled. A round robin made the mark whichever slot
* the modulo happened to land on — five materials at 2.1s each put her 4.5s
* after the node appeared, i.e. most of the way through the cube's life. These
* are cues like every other schedule in the sequence: she arrives as the node
* does, the harder materials fill the middle, and she comes back to tear
* herself apart as the scene changes.
*/
const VIEWPORT_SCHEDULE = [
{ at: 0, mode: 'standard' },
{ at: 1900, mode: 'metal' },
{ at: 3700, mode: 'texture' },
{ at: 5800, mode: 'glass' },
{ at: 7000, mode: 'wireframe' },
{ at: 8300, mode: 'texture' },
{ at: 10500, mode: 'wireframe' }
] as const
const CROSSFADE_MS = 620
export type ViewportMode = (typeof VIEWPORT_SCHEDULE)[number]['mode']
export interface ViewportSlot {
at: number
index: number
mode: ViewportMode
until: number
}
/** The material showing at `t`, with the window it occupies — the caller needs
* the bounds for the crossfade, the tear ramps and the label's decode. */
export function viewportSlot(t: number): ViewportSlot {
let index = 0
for (let i = 0; i < VIEWPORT_SCHEDULE.length; i += 1) {
if (VIEWPORT_SCHEDULE[i].at <= t) {
index = i
}
}
return {
at: VIEWPORT_SCHEDULE[index].at,
index,
mode: VIEWPORT_SCHEDULE[index].mode,
until: VIEWPORT_SCHEDULE[index + 1]?.at ?? VIEWPORT_END_MS
}
}
interface Quad {
z: number
pts: [number, number][]
shade: number
/** Grid cell on its face, for texture coordinates. */
cell: [number, number]
}
/** Subdivided cube quads, rotated + projected. Always a true cube — the
* subdivision exists so per-face shading has facets to work with, and so the
* texture pass has small enough cells for an affine map to pass for one. */
function cubeQuads(t: number, w: number, h: number): Quad[] {
const rx = t * 0.00042
const ry = t * 0.00071
const cx = Math.cos(rx)
const sx = Math.sin(rx)
const cy = Math.cos(ry)
const sy = Math.sin(ry)
// Roomy: the cube never grazes the viewport frame.
const scale = Math.min(w, h) * 0.24
const quads: Quad[] = []
const vert = (u: number, v: number, face: number): [number, number, number] => {
const a = -1 + (2 * u) / N
const b = -1 + (2 * v) / N
const p: [number, number, number] =
face === 0
? [a, b, 1]
: face === 1
? [a, b, -1]
: face === 2
? [1, a, b]
: face === 3
? [-1, a, b]
: face === 4
? [a, 1, b]
: [a, -1, b]
const x1 = p[0] * cy + p[2] * sy
const z1 = -p[0] * sy + p[2] * cy
const y2 = p[1] * cx - z1 * sx
const z2 = p[1] * sx + z1 * cx
return [x1, y2, z2]
}
for (let face = 0; face < 6; face += 1) {
for (let u = 0; u < N; u += 1) {
for (let v = 0; v < N; v += 1) {
const c: [number, number, number][] = [
vert(u, v, face),
vert(u + 1, v, face),
vert(u + 1, v + 1, face),
vert(u, v + 1, face)
]
const z = (c[0][2] + c[1][2] + c[2][2] + c[3][2]) / 4
const ux = c[1][0] - c[0][0]
const uy = c[1][1] - c[0][1]
const uz = c[1][2] - c[0][2]
const vx = c[3][0] - c[0][0]
const vy = c[3][1] - c[0][1]
const vz = c[3][2] - c[0][2]
const nx = uy * vz - uz * vy
const ny = uz * vx - ux * vz
const nz = ux * vy - uy * vx
const nl = Math.hypot(nx, ny, nz) || 1
const shade = Math.abs(nz / nl)
quads.push({
z,
shade,
cell: [u, v],
pts: c.map(([x, y, zz]): [number, number] => {
const persp = 3.6 / (3.6 - zz * 0.9)
return [w / 2 + x * scale * persp, h / 2 + y * scale * persp]
})
})
}
}
}
return quads.sort((a, b) => a.z - b.z)
}
// ── Texture pass ──────────────────────────────────────────────────────────
//
// The mark itself, mapped onto the cube, torn apart on the way in and out.
// The RGB rip is a real channel separation: the cube renders once, is split
// into red/green/blue, and the three are re-composited with 'lighter' at
// diverging offsets. At zero offset they sum back to the untouched image, so
// "settled" costs nothing extra to express — the glitch IS the offset.
const CHANNEL_TINTS = ['#ff0000', '#00ff00', '#0000ff'] as const
const SLICES = 14
let texture: HTMLImageElement | null = null
let textureRequested = false
/** Kicks the load on first use, then answers from memory. Null until decoded,
* which the caller reads as "paint the resting material instead". */
function textureImage(): HTMLImageElement | null {
if (textureRequested || globalThis.document === undefined) {
return texture
}
textureRequested = true
const img = new Image()
img.onload = () => {
texture = img
}
// The cinematic's own cut of the mark, not `nous-girl.jpg` — that one is the
// BrandMark tile art (dark on white) and reads as a solid white block once
// it is wrapped around a cube. This one is light-on-dark line work, so the
// cube keeps the viewport's depth and the channel split has edges to tear.
img.src = `${import.meta.env.BASE_URL}intro-nous-girl.png`
return null
}
const scratch = new Map<string, HTMLCanvasElement>()
/** A cleared offscreen at device resolution. `scale` bakes in the DPR so
* callers keep drawing in the same CSS pixels the quads are projected into. */
function buffer(key: string, w: number, h: number, scale: number): CanvasRenderingContext2D {
let canvas = scratch.get(key)
if (!canvas) {
canvas = document.createElement('canvas')
scratch.set(key, canvas)
}
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w
canvas.height = h
}
const ctx = canvas.getContext('2d')!
ctx.setTransform(scale, 0, 0, scale, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.globalAlpha = 1
ctx.clearRect(0, 0, w, h)
return ctx
}
/** Deterministic value noise — the tear has to replay identically. */
function hash(n: number): number {
const s = Math.sin(n * 12.9898) * 43758.5453
return s - Math.floor(s)
}
/** 0 settled, 1 fully torn. Rips in, holds mostly clean with stutters, rips
* out — so the mark resolves long enough to be read before it comes apart. */
function tearAmount(local: number, span: number): number {
const arriving = 1 - Math.min(1, local / 460)
const leaving = Math.max(0, (local - (span - 420)) / 420)
// A new draw every 90ms, and most of them are nothing.
const step = Math.floor(local / 90)
const stutter = hash(step) > 0.88 ? hash(step * 1.7) * 0.5 : 0
return Math.min(1, Math.max(arriving, leaving, stutter))
}
let scanPattern: CanvasPattern | null = null
/** CRT line grille. Built once — it is painted under the buffer's DPR
* transform, so it holds a constant weight in CSS pixels at any scale. */
function scanlines(ctx: CanvasRenderingContext2D): CanvasPattern | null {
if (!scanPattern) {
const canvas = document.createElement('canvas')
canvas.width = 1
canvas.height = 3
const tile = canvas.getContext('2d')!
tile.fillStyle = 'rgba(0, 0, 0, 0.34)'
tile.fillRect(0, 0, 1, 1)
scanPattern = ctx.createPattern(canvas, 'repeat')
}
return scanPattern
}
function inflate(pts: [number, number][], px: number): [number, number][] {
const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4
const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4
return pts.map(([x, y]): [number, number] => {
const dx = x - cx
const dy = y - cy
const d = Math.hypot(dx, dy) || 1
return [x + (dx / d) * px, y + (dy / d) * px]
})
}
function paintTexturedCube(
ctx: CanvasRenderingContext2D,
quads: Quad[],
w: number,
h: number,
slot: ViewportSlot,
t: number,
alpha: number
): void {
const img = textureImage()
if (!img) {
return
}
const local = t - slot.at
// The surface hands us a DPR-scaled context and CSS-pixel geometry. Match it
// on the offscreens, or the whole pass renders at 1x and gets upscaled.
const dpr = ctx.getTransform().a || 1
const dw = Math.ceil(w * dpr)
const dh = Math.ceil(h * dpr)
const tear = tearAmount(local, slot.until - slot.at)
const cube = buffer('cube', dw, dh, dpr)
const sw = img.naturalWidth / N
const sh = img.naturalHeight / N
for (const q of quads) {
const [p0, p1, , p3] = q.pts
// Cells are clipped, and two clips meeting on an edge each antialias to
// half cover — which on a white texture reads as a grey hairline grid.
// Overlapping them instead is free: the texture is opaque and drawn back
// to front, so a later cell simply repaints the seam.
const poly = inflate(q.pts, 0.6)
cube.save()
cube.beginPath()
cube.moveTo(poly[0][0], poly[0][1])
for (let i = 1; i < 4; i += 1) {
cube.lineTo(poly[i][0], poly[i][1])
}
cube.closePath()
cube.clip()
// The mark is light-on-dark line work, so the face needs a body of its own
// first — otherwise the cube's unlit areas are the same black as the
// viewport behind it and the solid dissolves into stray white curves. This
// also repaints the inflated overlap opaque before the line work lands.
cube.fillStyle = `rgb(${12 + q.shade * 20}, ${13 + q.shade * 22}, ${17 + q.shade * 28})`
cube.fillRect(0, 0, w, h)
// Affine map from the unit cell to this quad. It ignores the fourth
// corner, which is what makes the texture swim slightly across a face —
// PS1 warping, and exactly the register this pass is going for.
cube.transform(p1[0] - p0[0], p1[1] - p0[1], p3[0] - p0[0], p3[1] - p0[1], p0[0], p0[1])
// Add the line work rather than painting over: on this art black is empty,
// so 'lighter' IS the lambert — a grazing face contributes less light.
cube.globalCompositeOperation = 'lighter'
cube.globalAlpha = 0.55 + q.shade * 0.45
cube.drawImage(img, q.cell[0] * sw, q.cell[1] * sh, sw, sh, -0.06, -0.06, 1.12, 1.12)
cube.restore()
}
// ── CRT pass, inside the cube's own alpha so none of it touches the empty
// space around the solid. Both ride the channel split below, so the rip
// tears the grille along with the mark rather than sliding over it.
cube.globalCompositeOperation = 'source-atop'
// A read head sweeping the solid: the brightest thing in the viewport, and
// what sells the cube as a projection rather than a painted object.
const sweep = ((local % 1150) / 1150) * 1.3 - 0.15
const bar = cube.createLinearGradient(0, (sweep - 0.13) * h, 0, (sweep + 0.13) * h)
bar.addColorStop(0, 'rgba(120, 200, 255, 0)')
bar.addColorStop(0.5, 'rgba(165, 220, 255, 0.26)')
bar.addColorStop(1, 'rgba(120, 200, 255, 0)')
cube.fillStyle = bar
cube.fillRect(0, 0, w, h)
const grille = scanlines(cube)
if (grille) {
cube.fillStyle = grille
cube.fillRect(0, 0, w, h)
}
const step = Math.floor(local / 90)
// A sliver of separation survives the settle, so even the held frames carry
// a little instability rather than snapping to a clean print.
const rip = (tear * 8 + 0.7) * dpr
ctx.save()
// Composite in device space: the offsets are pixel work, and the buffers are
// already at device resolution.
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.globalCompositeOperation = 'lighter'
ctx.globalAlpha = alpha
for (let c = 0; c < 3; c += 1) {
const chan = buffer('chan', dw, dh, 1)
chan.drawImage(cube.canvas, 0, 0)
// Isolate one channel: multiply by a primary, then re-apply the cube's own
// alpha, because a full-canvas fill would otherwise tint the empty space.
chan.globalCompositeOperation = 'multiply'
chan.fillStyle = CHANNEL_TINTS[c]
chan.fillRect(0, 0, dw, dh)
chan.globalCompositeOperation = 'destination-in'
chan.drawImage(cube.canvas, 0, 0)
// Red left, blue right, green anchored — the classic separation. Slices
// ride on top so the tear breaks the silhouette, not just the colour.
const dx = (c - 1) * rip
for (let s = 0; s < SLICES; s += 1) {
// Integer, abutting bands. Any overlap would be summed twice by
// 'lighter' and read as bright rules across the cube.
const y0 = Math.round((s * dh) / SLICES)
const band = Math.round(((s + 1) * dh) / SLICES) - y0
const jitter = (hash(step * 31 + s) - 0.5) * 2 * tear * 11 * dpr
ctx.drawImage(chan.canvas, 0, y0, dw, band, dx + jitter, y0, dw, band)
}
}
ctx.restore()
}
export function drawViewport(ctx: CanvasRenderingContext2D, w: number, h: number, t: number) {
// Start the texture fetch on the very first frame. Its pass is eight seconds
// into the sequence, and a cold decode arriving mid-crossfade would show the
// wireframe dissolving into an empty cube.
textureImage()
const slot = viewportSlot(t)
const mode = slot.mode
// Materials CROSSFADE at slot boundaries — the incoming one comes up over
// the outgoing, like a shader recompile settling. Never a hard swap. The
// geometry is ALWAYS a cube.
const prevEntry = VIEWPORT_SCHEDULE[slot.index - 1]
const prevMode = prevEntry?.mode ?? mode
const fade = Math.min(CROSSFADE_MS, (slot.until - slot.at) * 0.4)
const blendF = Math.min(1, (t - slot.at) / fade)
const blend = blendF * blendF * (3 - 2 * blendF)
ctx.clearRect(0, 0, w, h)
const quads = cubeQuads(t, w, h)
ctx.save()
ctx.font = "8px 'JetBrains Mono', monospace"
const rx = t * 0.00042
const ry = t * 0.00071
const gx = 24
const gy = h - 22
const axes: [string, number, number, number, string][] = [
['x', 1, 0, 0, 'rgba(248, 113, 113, 0.8)'],
['y', 0, -1, 0, 'rgba(74, 222, 128, 0.8)'],
['z', 0, 0, 1, 'rgba(96, 165, 250, 0.8)']
]
for (const [label, ax, ay, az, color] of axes) {
const x1 = ax * Math.cos(ry) + az * Math.sin(ry)
const z1 = -ax * Math.sin(ry) + az * Math.cos(ry)
const y2 = ay * Math.cos(rx) - z1 * Math.sin(rx)
const px = gx + x1 * 13
const py = gy + y2 * 13
ctx.strokeStyle = color
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(gx, gy)
ctx.lineTo(px, py)
ctx.stroke()
ctx.fillStyle = color
ctx.fillText(label, px + 2, py + 3)
}
// Rotation readout, top-left; verts, bottom-right. N=4 → 6·(N+1)² shared
// grid verts per face is the honest-ish count for the subdivided cube.
const deg = (r: number) => ((((r * 180) / Math.PI) % 360) | 0).toString().padStart(3, ' ')
ctx.fillStyle = 'rgba(255,255,255,0.22)'
ctx.fillText(`rx ${deg(rx)}\u00b0 ry ${deg(ry)}\u00b0`, 12, 14)
const verts = `${6 * 5 * 5} verts \u00b7 ${quads.length} faces`
ctx.fillText(verts, w - ctx.measureText(verts).width - 12, h - 10)
ctx.restore()
// One painter per material. `standard` is the resting state: the plain
// white default cube under ambient light — lambert with a lifted floor so
// no face ever goes black. `texture` is not here: it is a whole-cube pass
// (below) because its channel split has to happen in screen space.
const paint = (m: ViewportMode, q: Quad, alpha: number) => {
if (alpha <= 0.01 || m === 'texture') {
return
}
ctx.globalAlpha = alpha
if (m === 'standard') {
const l = 152 + q.shade * 88
ctx.fillStyle = `rgb(${l}, ${l}, ${l + 2})`
ctx.fill()
ctx.strokeStyle = 'rgba(0,0,0,0.16)'
ctx.lineWidth = 0.5
ctx.stroke()
} else if (m === 'metal') {
const s = Math.pow(q.shade, 2.6)
const v = 26 + s * 205
ctx.fillStyle = `rgb(${v * 0.92}, ${v * 0.97}, ${Math.min(255, v * 1.06 + 6)})`
ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.07)'
ctx.lineWidth = 0.5
ctx.stroke()
} else if (m === 'glass') {
const rim = 1 - q.shade
ctx.fillStyle = `rgba(140, 180, 255, ${0.05 + rim * 0.17})`
ctx.fill()
ctx.strokeStyle = `rgba(170, 200, 255, ${0.1 + rim * 0.38})`
ctx.lineWidth = 0.7
ctx.stroke()
} else {
ctx.strokeStyle = `rgba(255,255,255,${0.14 + q.shade * 0.2})`
ctx.lineWidth = 1
ctx.stroke()
}
}
for (const q of quads) {
ctx.beginPath()
ctx.moveTo(q.pts[0][0], q.pts[0][1])
for (let i = 1; i < 4; i += 1) {
ctx.lineTo(q.pts[i][0], q.pts[i][1])
}
ctx.closePath()
if (blend < 1) {
paint(prevMode, q, 1 - blend)
}
paint(mode, q, blend)
}
ctx.globalAlpha = 1
if (mode === 'texture') {
paintTexturedCube(ctx, quads, w, h, slot, t, blend)
} else if (prevMode === 'texture' && prevEntry) {
// Still on ITS clock, not the incoming slot's — the tear-out that began at
// the end of its own window has to carry through the crossfade. Reading
// the new slot's local time restarted the ramp and re-tore a mark that was
// supposed to be already in pieces.
paintTexturedCube(
ctx,
quads,
w,
h,
{ at: prevEntry.at, index: slot.index - 1, mode: 'texture', until: slot.at },
t,
1 - blend
)
}
}
@@ -0,0 +1,298 @@
/**
* The layout assembly for in-chat onboarding.
*
* The guided chat starts SOLO: just the chat pane in a small window — no
* sidebar, nothing to explain. When the user picks a layout in the
* ::onboarding card, the app assembles around the conversation.
*
* The OS window grows OUTWARD by the MINIMUM each layout needs — the
* sidebar's width to the left, the terminal/rail minimums where a layout
* has them — animated (macOS setBounds animate), so the chat stays roughly
* where it was and the window ends as small as the layout allows, but never
* so small that the sidebar it just docked pops back out as a floating
* Sheet.
*/
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { allPaneIds, group, type LayoutNode } from '@/components/pane-shell/tree/model'
import { applyLayoutPreset } from '@/components/pane-shell/tree/presets'
import {
$activePresetId,
$layoutTree,
adoptContributedPanes,
dismissTreePane,
resetEnforcedDocks,
undismissTreePanes
} from '@/components/pane-shell/tree/store'
import { registry } from '@/contrib/registry'
import { DOCKED_SIDEBAR_MIN_PX } from '@/hooks/use-mobile'
import { TRANSLATIONS } from '@/i18n/catalog'
import { getRuntimeI18nLocale } from '@/i18n/runtime'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { setSidebarOpen } from '@/store/layout'
import { loadMachineProfile, machineUserName } from '@/store/machine'
import { skipGuide } from '@/store/onboarding-gate'
import { setOnboardingSurfaceActive } from '@/store/onboarding-presence'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
/** True from guide kickoff until the layout pick assembles the app. */
export const $chatOnboardingSolo = atom(false)
// Presence mirror — see onboarding-presence.ts (update toast stands down).
$chatOnboardingSolo.subscribe(solo => setOnboardingSurfaceActive('solo-chat', solo))
/** The guided-setup session's ids — stored AND runtime, because consumers key
* sessions differently (the thread list by stored id, the composer by runtime
* id). That one thread gets the onboarding transcript treatment and drops the
* composer's git strip; every other session is untouched. */
export const $chatOnboardingThreadIds = atom<readonly string[]>([])
/** The opening line of the guided chat — PRE-BANKED, never generated. The
* first thing a new user sees must be instant; the model's cold-stack first
* turn took up to 10 seconds in live runs. The transcript renders this
* client-side the moment the chat opens; the model is told what was said
* and picks up from the user's answer. The lines themselves live in the i18n
* catalog (`guidedGreeting`) so they arrive in the user's language. */
export const $onboardingGreeting = atom('')
/** Pick (and remember) the canned opening line for this run. When the host
* reports a suggestable account name (machineUserName), the greeting ends by
* offering it as a default — \"or I can just call you akp\". The suggestion
* rides the SAME word the seed rows bank, so the canonical row, the typed
* reveal, and what the runbook says was said can never disagree.
*
* Read from the i18n catalog rather than a local constant: the runbook tells
* the model to answer in the user's own language from its first real turn, so
* an English opener on a Japanese machine would be two different agents in
* two consecutive messages. Locales without their own copy fall back to
* English per-key through defineLocale, which is the same trade every other
* string in the app makes. */
export function pickOnboardingGreeting(): string {
const existing = $onboardingGreeting.get()
if (existing) {
return existing
}
const copy = TRANSLATIONS[getRuntimeI18nLocale()].guidedGreeting
const lines = copy.lines.length > 0 ? copy.lines : TRANSLATIONS.en.guidedGreeting.lines
const line = lines[Math.floor(Math.random() * lines.length)] ?? lines[0] ?? ''
const suggested = machineUserName()
$onboardingGreeting.set(suggested ? `${line}\n\n${copy.nameSuggestion(suggested)}` : line)
return $onboardingGreeting.get()
}
/** Whether the layout card's pick happened. A STORE, not card-local state:
* applying the layout replaces the pane tree, which remounts the chat pane
* and the card with it — component state would forget the selection the
* moment it takes effect. */
export const $chatLayoutPicked = atom(false)
let previousLayout: { id: string; tree: LayoutNode | null } | null = null
export function startChatOnboardingSolo(): void {
if (!isOnboardingEnabled() || $chatOnboardingSolo.get()) {
return
}
previousLayout = { id: $activePresetId.get(), tree: $layoutTree.get() }
$chatOnboardingSolo.set(true)
$chatLayoutPicked.set(false)
// Bank the opening line the moment the solo chat owns the screen. The
// machine profile is one local IPC; everything after it in the kickoff —
// the setup profile, its backend boot, the session create — takes seconds
// to minutes, and the greeting must be typing in the whole time instead of
// the empty-draft wordmark. The kickoff picks again and gets this same
// banked line (pick is first-write-wins).
void loadMachineProfile().then(() => {
if ($chatOnboardingSolo.get()) {
pickOnboardingGreeting()
}
})
// One zone, strip pinned off. applyTree ADOPTS panes the preset doesn't
// declare (sessions, terminal, …) into this group as tabs — with the strip
// never shown and workspace active, they're simply invisible until the
// assembled layout re-places them. That adoption is also why reactive
// unhides (files on cwd-arrival) can't pop a zone open mid-flow: there is
// no other zone to open.
applyLayoutPreset('chat-solo', group(['workspace'], { tabStrip: 'never' }))
}
/** A failed kickoff releases the screen so classic onboarding can resume. */
export function endChatOnboardingSolo(): void {
$chatOnboardingSolo.set(false)
$onboardingGreeting.set('')
const previous = previousLayout
previousLayout = null
if (previous) {
const tree = previous.tree ?? registry.getArea('layouts').find(preset => preset.id === 'default')?.data
if (tree) {
// SAFETY: layout contributions declare LayoutNode data, like the saved tree.
applyLayoutPreset(previous.tree ? previous.id : 'default', tree as LayoutNode)
}
}
}
/** Minimal per-edge growth per layout — the least the window must gain for
* the new panes to be usable, NOT a chat-size-preserving projection (which
* balloons the window). Left = sessions sidebar; Elite adds its right rail
* and terminal row. Tune by feel. */
interface LayoutGrowth {
bottom?: number
left?: number
right?: number
top?: number
}
const LAYOUT_GROWTH = new Map<string, LayoutGrowth>([
['basic', { left: 220 }],
['terminal-deck', { bottom: 200, left: 220, right: 240 }]
])
/**
* Put the tree in the state this layout describes — on the first pick AND on
* every re-pick.
*
* All of it has to re-run, because all of it persists: dismissals, dock
* enforcement, the sidebar's open state. A re-pick that only swapped the
* preset tree inherited the previous layout's records and came up as a mix of
* the two (Elite after Basic kept Basic's terminal dismissal, so Elite's
* terminal was placed and invisible).
*/
function reconcileLayout(id: string, tree: LayoutNode): void {
applyLayoutPreset(id, tree)
const declared = new Set(allPaneIds(tree))
// Everything this layout asks for is wanted, whatever the last one decided.
undismissTreePanes(declared)
// The preset IS the layout. Adoption otherwise keeps every pane the preset
// doesn't declare, and during a first run each of them is a surface the
// user has no idea exists: a Terminal tab beside the chat on Basic, an
// empty Cronjobs column, a Bots roster tabbed onto Sessions (its dock is
// `enforce: true`, so it re-homes there on every pick). That last one also
// costs the sidebar its plain face — two panes in the left zone is what
// conjures a tab strip over what should just be the sessions list.
//
// So: anything the picked layout didn't ask for is dismissed. The pane
// isn't gone, only unplaced — its own toggle (⌃` for the terminal, the
// Layout menu, `revealTreePane`) brings it back the moment the user wants
// it.
//
// Candidates come from the REGISTRY, not just the tree: a pane that isn't
// placed yet still gets its dismissal recorded, and adoption skips dismissed
// panes — so this holds whether the pane arrives before or after the sweep.
const dismissUndeclared = () => {
for (const paneId of new Set([
...allPaneIds($layoutTree.get() ?? tree),
...registry.getArea('panes').map(pane => pane.id)
])) {
if (!declared.has(paneId)) {
dismissTreePane(paneId)
}
}
}
// The tree now HAS a sessions column, but the renderer drops the whole left
// column when the persisted ⌘B state says closed ($sidebarOpen →
// $collapsedTreeSides) — picking a layout with a sidebar is an explicit
// intent to see it, so open the side through its store (truthful toggle),
// the same way resetLayoutTree reopens bound sides.
setSidebarOpen(true)
// Dock invariants normally run once at boot, against whatever tree existed
// then — the SOLO tree, which has no sessions column for a left-docking
// pane to anchor to. That pass burns the ledger entry, so re-running
// adoption alone left those panes stranded as tabs in the chat zone. Reopen
// the window first, now that the layout they should dock into exists.
resetEnforcedDocks()
adoptContributedPanes()
// LAST, because panes can be a CONSEQUENCE of the assembly above: a plugin
// registers more panes the moment one of its own becomes visible. Sweeping
// before that point swept a tree those arrivals had not happened in yet,
// and Basic still landed with an empty Cronjobs column beside the chat.
dismissUndeclared()
}
/**
* A layout pick, from the chat card. The FIRST one also performs the solo→app
* transition (see module header); later picks re-arrange the app that is
* already there.
*
* The window is grown once, on that first pick. `grow` moves the edges OUTWARD
* by a delta, so re-growing per pick would ratchet the window bigger every
* time the user toggled between two layouts.
*/
export function assembleChatOnboarding(id: string, tree: LayoutNode): void {
const firstPick = $chatOnboardingSolo.get()
if (firstPick) {
const growth = LAYOUT_GROWTH.get(id) ?? { left: 220 }
window.hermesDesktop?.chatOnboarding?.grow({
bottom: growth.bottom ?? 0,
left: growth.left ?? 0,
right: growth.right ?? 0,
// Every layout here docks a sessions sidebar, and the deltas above are
// measured against the panes, not against the viewport the sidebar needs
// to stay docked. Growing the solo card by a sidebar's width can still
// land under the breakpoint — at first-run zoom, Basic did — and the
// sidebar then arrives as a floating Sheet over the chat instead of
// sliding in beside it. Ask for the floor and let main clamp it to the
// display.
minWidth: DOCKED_SIDEBAR_MIN_PX,
top: growth.top ?? 0
})
}
reconcileLayout(id, tree)
$chatOnboardingSolo.set(false)
}
/** Skip the guided setup: assemble the default layout so the user lands in
* the full app immediately, and mark onboarding done so nothing resumes it.
* The guided chat stays in the transcript — skipping is about ending the
* questionnaire, not destroying the conversation. */
export function skipChatOnboarding(): void {
const preset = registry.getArea('layouts').find(contribution => contribution.id === 'basic')
if (preset?.data) {
// SAFETY: Layout presets declare data: LayoutNode (pane-shell/tree/presets.ts).
assembleChatOnboarding(preset.id, preset.data as LayoutNode)
} else {
$chatOnboardingSolo.set(false)
}
skipGuide()
}
/**
* True while the user is inside the first-run story — the solo guided chat,
* or any thread the flow owns afterwards (Setup's chat, the first
* build). Chrome that would read as noise over those conversations checks
* this: floating panels, the profile-swap spinner.
*
* Threads are matched on BOTH ids because consumers key sessions differently
* (the thread list by stored id, the composer by runtime id).
*/
export function useOnboardingChatActive(): boolean {
const solo = useStore($chatOnboardingSolo)
const threadIds = useStore($chatOnboardingThreadIds)
const runtimeId = useStore($activeSessionId)
const storedId = useStore($selectedStoredSessionId)
return (
solo || (runtimeId != null && threadIds.includes(runtimeId)) || (storedId != null && threadIds.includes(storedId))
)
}
@@ -0,0 +1,231 @@
/**
* The build beat's three cards: choosing what to make, handing it to a session
* of its own, and watching it happen. Unlike the setup picks these read the
* directive's attrs — the payload is model-written, so each one validates
* before it renders.
*/
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { $handoffError, retrySetupHandoff } from '@/app/contrib/handoff-receipt'
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
import type { CardProps } from '@/components/onboarding-chat/cards/frame'
import { Chip } from '@/components/onboarding-chat/chip'
import {
$setupHandoff,
firstTaskTitle,
hasCompletedSetupHandoff,
parseHandoffPlan,
requestSetupHandoff,
SETUP_PROFILE
} from '@/components/onboarding-chat/setup-profile'
import { Button } from '@/components/ui/button'
import { segmentTranscriptDirectives } from '@/lib/transcript-directives'
import { cn } from '@/lib/utils'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { isSessionOwnerRoute } from '@/store/session-request-router'
/** A tappable option is the user's own reply, so it goes out VISIBLE — the
* model's next message answers a real turn, not a hidden [setup] note. */
const FALLBACK_OPTION = "Let's figure it out together"
/**
* The "first build" card — the close of the get-to-know-you beat. The model
* asks a thoughtful question about what the user wants to BUILD first, then
* places this card with the options IT generated from the whole conversation:
* `::onboarding{step="first" options="A Discord bot|A habit tracker|…"}`.
*/
export function FirstBuildCard({ attrs, locked }: CardProps) {
const view = useSessionView()
const storedId = useStore(view.$storedId)
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
const [picked, setPicked] = useState<null | string>(null)
// Parse + validate the model's options: up to 4, each short enough to sit on
// a chip, deduped case-insensitively (models repeat themselves). Garbage in
// (0-1 usable) must not strand the user — the prose says "pick one below",
// so fall back to the one option we can always offer.
const seen = new Set<string>()
const parsed = (attrs.options ?? '')
.split('|')
.map(option => option.trim().replace(/\s+/g, ' '))
.filter(option => {
const key = option.toLowerCase()
if (option.length === 0 || option.length > 60 || seen.has(key)) {
return false
}
seen.add(key)
return true
})
.slice(0, 4)
const options = parsed.length < 2 ? [FALLBACK_OPTION] : parsed
const pick = (option: string) => {
if (picked || locked) {
return
}
if (requestComposerSubmit(option, { target })) {
setPicked(option)
}
}
return (
<div className="my-3 grid min-w-0 max-w-md gap-4" data-onboarding-card inert={locked || undefined}>
<div className="flex min-w-0 max-w-full flex-wrap gap-2">
{options.map(option => (
<Chip key={option} label={option} on={picked === option} onToggle={() => pick(option)} variant="pill" />
))}
</div>
</div>
)
}
/**
* The handoff card — where the first build leaves this chat. Setup emits
* `::onboarding{step="handoff" task="…" brief="…"}` once the task is decided,
* and the card performs it: raise the beacon, and the wiring effect opens a
* session on the user's default profile, seeds it, and moves the user there.
*
* Nothing to ask — the build's shape was settled by the `first` step and there
* is one surface now, so the card just narrates: opening → landed. Both
* latches (atom + storage) make re-parses, re-mounts, and relaunches inert,
* and a locked (replayed) transcript never re-fires.
*/
export function HandoffCard({ attrs, locked }: CardProps) {
const view = useSessionView()
const storedId = useStore(view.$storedId)
const runtimeId = useStore(view.$runtimeId)
const task = (attrs.task ?? '').trim().slice(0, 60)
const brief = (attrs.brief ?? '').trim().slice(0, 240)
const plan = parseHandoffPlan(attrs.plan)
const state = useStore($setupHandoff)
const error = useStore($handoffError)
useEffect(() => {
if (!task || !brief || locked || !storedId || !runtimeId || $setupHandoff.get() || hasCompletedSetupHandoff()) {
return
}
let cancelled = false
void resolveSessionOwner(storedId)
.then(owner => {
assertSessionOwnerResolved(owner, { method: 'onboarding.handoff', sessionId: storedId })
if (!cancelled) {
requestSetupHandoff(task, brief, plan, {
storedId,
runtimeId,
connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null,
profile: isSessionOwnerRoute(owner) ? owner.profile : owner || SETUP_PROFILE
})
}
})
.catch(error => {
if (!cancelled) {
$handoffError.set(String(error))
$setupHandoff.set({ task, brief, plan, phase: 'error' })
}
})
return () => {
cancelled = true
}
}, [brief, locked, plan, task, storedId, runtimeId])
if (!task || !brief) {
return null
}
const settled = state?.phase === 'done' || (state === null && hasCompletedSetupHandoff())
const failed = state?.phase === 'error'
const title = state?.sessionTitle ?? firstTaskTitle(task)
return (
<div className="my-3 flex max-w-md items-center gap-2 text-sm" data-onboarding-card>
<StatusDot live={!settled && !failed} />
<span className="text-(--ui-text-secondary)">
{failed
? (error ?? 'The first build could not be started. Retry to check its session.')
: settled
? `${title} was started — find it in your sessions`
: `Opening ${title}\u2026`}
</span>
{failed && (
<Button disabled={locked} onClick={retrySetupHandoff} size="sm" variant="text">
Retry first build
</Button>
)}
</div>
)
}
/** Progress comes from this transcript, so virtualization cannot append history. */
export function ProgressCard({ attrs, locked }: CardProps) {
const view = useSessionView()
const messages = useStore(view.$messages)
const messageId = useAuiState(state => state.message.id)
const title = (attrs.title ?? '').trim() || 'Working on it'
const index = messages.findIndex(message => message.id === messageId)
const previous = index < 0 ? [] : messages.slice(0, index)
const steps = previous.flatMap(message => {
const directives = message.parts.flatMap(part =>
part.type === 'text' ? (segmentTranscriptDirectives(part.text) ?? []) : []
)
const progress = directives
.filter(
segment =>
segment.kind === 'directive' &&
segment.directive.name === 'onboarding' &&
segment.directive.attrs.step === 'progress'
)
.at(-1)
return progress?.kind === 'directive'
? [{ id: message.id, title: progress.directive.attrs.title?.trim() || 'Working on it' }]
: []
})
steps.push({ id: messageId, title })
return (
<div className="my-3 grid max-w-md gap-1.5" data-onboarding-card>
{steps.map(step => {
const current = step.id === messageId
return (
<div className="flex items-center gap-2 text-sm" key={step.id}>
<StatusDot live={current && locked} muted={!current} />
<span className={current ? 'text-(--ui-text-secondary)' : 'text-(--ui-text-quaternary)'}>
{current && locked ? `${step.title}…` : step.title}
</span>
</div>
)
})}
</div>
)
}
function StatusDot({ live, muted = !live }: { live: boolean; muted?: boolean }) {
return (
<span
aria-hidden
className={cn(
'inline-block size-1.5 shrink-0 rounded-full',
muted ? 'bg-(--ui-text-quaternary)' : 'bg-(--ui-accent)',
live && 'animate-pulse'
)}
/>
)
}
@@ -0,0 +1,79 @@
/**
* What every in-chat onboarding card is made of: the frame it sits in, the
* props it receives, and the one thing it does when the user is finished —
* report the pick so the model moves on.
*/
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export interface CardProps {
/** The directive's raw attrs — the model-written payload. */
attrs: Record<string, string>
/** True while the surrounding turn is still streaming — same card, no clicks. */
locked: boolean
}
export function useCardCommit() {
const view = useSessionView()
const storedId = useStore(view.$storedId)
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
const [done, setDone] = useState(false)
const commit = (summary: string): boolean => {
const sent = requestComposerSubmit(`[setup] ${summary}`, { displayKind: 'hidden', target })
if (sent) {
setDone(true)
}
return sent
}
return { commit, done }
}
/** No chrome — the picker sits directly in the transcript like any other
* message content. The interaction IS the affordance; a border would make it
* read as a form. */
export function CardFrame({
children,
disabled = false,
done,
locked = false,
onContinue
}: {
children: React.ReactNode
disabled?: boolean
done: boolean
locked?: boolean
onContinue: () => void
}) {
return (
<div
className={cn(
'my-3 grid w-full min-w-0 max-w-md gap-4 duration-300 animate-in fade-in-0 slide-in-from-bottom-2',
done && 'opacity-75 transition-opacity duration-500'
)}
data-onboarding-card
inert={locked || undefined}
>
{children}
<div className="flex justify-start">
<Button
className={cn(done && 'scale-95 transition-transform duration-200')}
disabled={done || disabled || locked}
onClick={onContinue}
size="sm"
>
{done ? '✓ Done' : 'Continue'}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,158 @@
/**
* The setup picks — connectors, accent, layout.
*
* Everything applies LIVE on click: the accent retints the app, the layout
* preset rearranges the panes behind the chat. That's the trick. The model
* never enumerates options in prose; it only places the card, and the catalog
* lives in options.tsx so the cards and previews can't drift.
*/
import { useStore } from '@nanostores/react'
import { $chatLayoutPicked, assembleChatOnboarding } from '@/components/onboarding-chat/assembly'
import { CardFrame, type CardProps, useCardCommit } from '@/components/onboarding-chat/cards/frame'
import { Chip } from '@/components/onboarding-chat/chip'
import {
accentsFor,
AccentSwatch,
CONNECTORS,
LayoutPreviewCard,
LAYOUTS,
NOUS_ACCENT
} from '@/components/onboarding-chat/options'
import type { LayoutNode } from '@/components/pane-shell/tree/model'
import { ConnectorLogo } from '@/components/ui/connector-logo'
import { registry } from '@/contrib/registry'
import { $onboardingAnswers, setOnboardingAnswers } from '@/store/onboarding-answers'
import { useTheme } from '@/themes'
import { setAccentOverride } from '@/themes/accent-override'
export function ConnectorsCard({ locked }: CardProps) {
const answers = useStore($onboardingAnswers)
const { commit, done } = useCardCommit()
const toggle = (id: string) =>
setOnboardingAnswers({
connectors: answers.connectors.includes(id)
? answers.connectors.filter(item => item !== id)
: [...answers.connectors, id]
})
return (
<CardFrame
done={done}
locked={locked}
onContinue={() => {
const picked = CONNECTORS.filter(connector => answers.connectors.includes(connector.id))
commit(
`apps I use, not connected yet: ${picked.length > 0 ? picked.map(c => c.name).join(', ') : 'none for now'}`
)
}}
>
<div className="grid grid-cols-3 gap-2">
{CONNECTORS.map(connector => (
<Chip
icon={
<ConnectorLogo
className="size-7 rounded-full text-sm"
connector={{ homepage: connector.homepage, name: connector.id, title: connector.name }}
/>
}
key={connector.id}
label={connector.name}
on={answers.connectors.includes(connector.id)}
onToggle={() => toggle(connector.id)}
/>
))}
</div>
</CardFrame>
)
}
export function LookCard({ locked }: CardProps) {
const answers = useStore($onboardingAnswers)
const { renderedMode } = useTheme()
const { commit, done } = useCardCommit()
const accents = accentsFor(renderedMode === 'dark')
const accent = answers.accent ?? NOUS_ACCENT
const picked = accents.find(swatch => swatch.hex === accent.toLowerCase())
const pickAccent = (hex: string) => {
const seed = hex === NOUS_ACCENT ? null : hex
setOnboardingAnswers({ accent: seed })
setAccentOverride(seed)
}
return (
<CardFrame done={done} locked={locked} onContinue={() => commit(`accent color: ${picked?.name ?? accent}`)}>
<div className="flex flex-wrap gap-2.5">
{accents.map(swatch => (
<AccentSwatch
active={accent.toLowerCase() === swatch.hex}
hex={swatch.hex}
key={swatch.name}
name={swatch.name}
onPick={() => pickAccent(swatch.hex)}
/>
))}
</div>
</CardFrame>
)
}
export function LayoutCard({ locked }: CardProps) {
const answers = useStore($onboardingAnswers)
const { commit, done } = useCardCommit()
// The stored answer defaults to 'basic', but the CHOICE is the point of this
// step — nothing renders selected (and Continue stays off) until they click.
// Store-backed: the pick's own layout apply remounts this card (the pane
// tree is replaced), so local state would drop the highlight instantly.
const picked = useStore($chatLayoutPicked)
const pickLayout = (id: string) => {
$chatLayoutPicked.set(true)
setOnboardingAnswers({ layout: id })
// Live, behind the chat — the panes rearrange as the option is clicked.
const preset = registry.getArea('layouts').find(contribution => contribution.id === id)
if (!preset?.data) {
return
}
// Every pick goes through assembly, including re-picks. The first grows
// the window and places the panes, keeping the chat (and the cursor over
// this card) pixel-fixed; later ones re-arrange in place. Swapping just the
// preset tree on a re-pick left the previous layout's dismissals and dock
// records in force, and the two layouts came up mixed together.
// SAFETY: Layout presets declare data: LayoutNode (pane-shell/tree/presets.ts).
assembleChatOnboarding(preset.id, preset.data as LayoutNode)
}
return (
<CardFrame
disabled={!picked}
done={done}
locked={locked}
onContinue={() => {
const choice = LAYOUTS.find(layout => layout.id === answers.layout)
commit(`layout: ${choice?.name ?? answers.layout}`)
}}
>
<div className="grid grid-cols-2 gap-3">
{LAYOUTS.map(layout => (
<LayoutPreviewCard
active={picked && answers.layout === layout.id}
key={layout.id}
name={layout.name}
onSelect={() => pickLayout(layout.id)}
tree={layout.tree}
/>
))}
</div>
</CardFrame>
)
}
@@ -0,0 +1,57 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
/**
* THE selection style — one vocabulary for every pickable thing in the shell
* (chips, connector cards, layout cards): primary outline + tint when on, a
* quiet neutral fill when off. No font-weight changes, no fills that shout.
*/
export const selectableClass = (on: boolean) =>
cn(
'border text-foreground transition-colors',
on ? 'border-primary bg-primary/15' : 'border-transparent bg-muted hover:bg-accent/60'
)
/** Toggleable chip — every pickable row/tag in the guided cards. Two shapes:
* `card` (connector rows, roomier, fits an icon) and `pill` (compact
* tag-cloud toggles). */
export function Chip({
className,
icon,
label,
on,
onToggle,
sub,
variant = 'card'
}: {
className?: string
icon?: ReactNode
label: string
on: boolean
onToggle: () => void
sub?: string
variant?: 'card' | 'pill'
}) {
return (
<button
aria-pressed={on}
className={cn(
'flex items-center text-left',
variant === 'pill'
? 'max-w-full shrink-0 gap-1.5 rounded-full px-3 py-1.5 text-[12px] whitespace-normal wrap-anywhere'
: 'min-w-0 gap-2.5 rounded-[6px] px-3 py-2.5 text-[13px]',
selectableClass(on),
className
)}
onClick={onToggle}
type="button"
>
{icon}
<span className="min-w-0">
<span className={variant === 'pill' ? 'block wrap-anywhere' : 'block truncate'}>{label}</span>
{sub && <span className="block text-xs text-muted-foreground">{sub}</span>}
</span>
</button>
)
}
@@ -0,0 +1,70 @@
/**
* In-chat onboarding cards — the `::onboarding{step="…"}` transcript
* directive. Hermes walks the user through setup in the transcript, and each
* step's paragraph renders as an interactive picker with a shared option
* catalog and persistence.
*
* This module is only the dispatcher. Two tables say what a step means — one
* writes an answer, the other renders a card — and a step in neither renders
* nothing, which is the right answer for the model's invisible acks. The cards
* themselves live in ./cards.
*/
import { useEffect } from 'react'
import { FirstBuildCard, HandoffCard, ProgressCard } from '@/components/onboarding-chat/cards/build'
import type { CardProps } from '@/components/onboarding-chat/cards/frame'
import { ConnectorsCard, LayoutCard, LookCard } from '@/components/onboarding-chat/cards/setup'
import { $onboardingAnswers, setOnboardingAnswers } from '@/store/onboarding-answers'
/** Steps that only carry data — the model handing the renderer what the user
* said. Each maps to the answer field it writes ('working' is the guided
* flow's name for the context answer: same storage, same consumers). */
type AnswerField = 'name' | 'context'
const DATA_STEPS = new Map<string, AnswerField>([
['name', 'name'],
['working', 'context']
])
/** Unrecognized steps are silent, including the greeting acknowledgement. */
const STEP_CARDS = new Map<string, (props: CardProps) => React.ReactNode>([
['connectors', ConnectorsCard],
['first', FirstBuildCard],
['handoff', HandoffCard],
['layout', LayoutCard],
['look', LookCard],
['progress', ProgressCard]
])
/** Writing an answer is an EFFECT, not a render fact. Doing it inline in the
* directive's render triggered React's cross-component setState warning and
* re-entrant renders (live desktop.log). */
function DataDirective({ field, value }: { field: AnswerField; value: string }) {
useEffect(() => {
if (!value || $onboardingAnswers.get()[field] === value) {
return
}
setOnboardingAnswers({ [field]: value })
}, [field, value])
return null
}
export function OnboardingChatDirective({ attrs, streaming }: { attrs: Record<string, string>; streaming: boolean }) {
const step = attrs.step ?? ''
const field = DATA_STEPS.get(step)
if (field) {
return <DataDirective field={field} value={(attrs.value ?? '').trim()} />
}
const Card = STEP_CARDS.get(step)
// Mount as soon as the directive is parsed — returning null until settle
// grows the transcript by a card when the turn finishes. Keep it inert
// mid-stream so the growing paragraph can't be clicked through.
return Card ? <Card attrs={attrs} locked={streaming} /> : null
}
@@ -0,0 +1,107 @@
/**
* Watching the first build.
*
* Setup hands the first task to its own session and stops talking. What the
* user feels next used to be nothing until they said something — the guide
* scheduled itself a DAILY cron and that was the whole of its "proactivity",
* which on a first run means a check-in that arrives tomorrow, about a task
* that finished in four minutes.
*
* So the check-ins ride the build's own progress instead of a clock. This
* module counts the work as it happens and, at a couple of points, raises a
* beacon the wiring turns into a hidden `[setup]` note in that same session —
* the agent pauses, says where things stand, and asks what the user wants
* next. It lands where they are already looking, which a cron never does.
*
* Two rules keep it from becoming a nag:
*
* - It only ever speaks BETWEEN turns (on `message.complete`). A note injected
* mid-loop would be a synthetic user message in the middle of an assistant
* turn — the alternation the agent core forbids.
* - It stays quiet when the turn already ended by asking something. The
* runbook has the agent ask for a verdict when the first pass lands; a
* check-in stacked under that is two questions and no answer.
*/
import { atom } from 'nanostores'
import { segmentTranscriptDirectives } from '@/lib/transcript-directives'
/** Tool calls at which Setup checks in. Two of them: one once the build is
* visibly underway, one deep enough in that "still what you wanted?" is a
* real question. A third would be nagging. */
const CHECK_IN_AT = [8, 20] as const
const CHECK_IN_NOTE =
'[setup] checkpoint — the user has been watching you work for a while and has not said anything. Before you carry on, say in ONE short line where the work actually stands right now, then end the turn with ::ask{question="What do you want next?" options="…|…|…"} alone as its own paragraph, with two or three options drawn from what would genuinely help here (keep going, change direction, explain something, stop). Emit the ask exactly in that shape. Do not summarize everything you have done, do not apologize for the interruption, and never mention this note.'
interface FirstBuild {
/** Profile the build session lives on. Carried because the whisper has to
* be routed explicitly: the user can walk back into Setup's chat while the
* build runs, which makes hermes-setup the ACTIVE gateway. */
profile: string
sessionId: string
tools: number
/** Highest CHECK_IN_AT threshold already spent. */
checkedInAt: number
}
let build: FirstBuild | null = null
/** Raised when the build has earned a check-in; the wiring whispers it into
* the build's session as a hidden `[setup]` note. Token-bumped so two
* check-ins in one run can't be swallowed as a duplicate value. */
export const $setupCheckIn = atom<null | { note: string; profile: string; sessionId: string; token: number }>(null)
let token = 0
/** Start watching the session Setup just handed the first task to. */
export function watchFirstBuild(sessionId: string, profile: string): void {
build = { checkedInAt: 0, profile, sessionId, tools: 0 }
}
export function resetFirstBuildForTests(): void {
build = null
token = 0
$setupCheckIn.set(null)
}
/** Called from the gateway stream on tool.complete. */
export function reportFirstBuildToolComplete(sessionId: null | string | undefined): void {
if (!build || build.sessionId !== sessionId) {
return
}
build.tools += 1
}
/** Called from the gateway stream on message.complete — the only moment a
* note may be injected (see the alternation rule in the module header). */
export function reportFirstBuildTurnComplete(sessionId: null | string | undefined, finalText: string): void {
const current = build
if (!current || current.sessionId !== sessionId) {
return
}
const due = CHECK_IN_AT.filter(at => current.tools >= at && at > current.checkedInAt).pop()
// The turn already put a question to the user (the runbook's verdict ask, or
// one the agent chose). Let them answer it. Parsed, not string-matched — a
// `::ask` the agent merely talked ABOUT is not a question.
if (due === undefined || endsInAsk(finalText)) {
return
}
current.checkedInAt = due
token += 1
$setupCheckIn.set({ note: CHECK_IN_NOTE, profile: current.profile, sessionId, token })
}
function endsInAsk(text: string): boolean {
return (
segmentTranscriptDirectives(text)?.some(
segment => segment.kind === 'directive' && segment.directive.name === 'ask'
) === true
)
}
@@ -0,0 +1,45 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { ackFreeTierNotice, type FreeTierRequester } from '@/store/free-tier'
import { $introReveal } from '@/store/intro-reveal'
import { clearFreeTierIntro } from '@/store/onboarding'
import { $onboardingGate, runGuideKickoff } from '@/store/onboarding-gate'
interface OnboardingChatGateProps {
enabled: boolean
onKickoff: () => Promise<boolean>
requestGateway: FreeTierRequester
}
export function OnboardingChatGate({ enabled, onKickoff, requestGateway }: OnboardingChatGateProps) {
const gate = useStore($onboardingGate)
const intro = useStore($introReveal)
useEffect(() => {
if (!enabled || !isOnboardingEnabled()) {
return
}
// subscribe also sees an intro started by the preceding sibling's effect.
return $introReveal.subscribe(state => {
if (state.phase === 'playing') {
clearFreeTierIntro()
void ackFreeTierNotice(requestGateway).then(acked => {
if (acked) {
clearFreeTierIntro()
}
})
}
})
}, [enabled, requestGateway])
useEffect(() => {
if (enabled && gate.guideQueued && intro.phase === 'hidden') {
void runGuideKickoff(onKickoff)
}
}, [enabled, gate.guideQueued, intro.phase, onKickoff])
return null
}
@@ -0,0 +1,170 @@
import { selectableClass } from '@/components/onboarding-chat/chip'
import { Tip } from '@/components/ui/tooltip'
import { IS_MAC } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
// Preferences for the first build’s optional connector offer. The live catalog,
// not this display list, decides which apps are available to connect. Marks
// resolve through the shared ConnectorLogo ladder: curated brand glyph first,
// the product's own favicon where simple-icons has no mark (Slack's left over
// trademark), monogram last.
export const CONNECTORS: Array<{ homepage?: string; id: string; name: string }> = [
{ id: 'gmail', name: 'Gmail' },
{ id: 'google-calendar', name: 'Calendar' },
{ id: 'google-drive', name: 'Drive' },
{ homepage: 'https://slack.com', id: 'slack', name: 'Slack' },
{ id: 'github', name: 'GitHub' },
{ id: 'notion', name: 'Notion' },
{ id: 'linear', name: 'Linear' },
{ id: 'figma', name: 'Figma' },
{ id: 'discord', name: 'Discord' },
{ id: 'telegram', name: 'Telegram' },
{ id: 'spotify', name: 'Spotify' },
{ id: 'stripe', name: 'Stripe' }
]
// Big accent swatches, Dia-style. Each seeds `retintTheme` through the accent
// override, so a click repaints the surface live. Nous blue is the default =
// no override. Mono seeds the current mode's pole — black in light, white in
// dark — for a full monochrome look.
export const NOUS_ACCENT = '#0053fd'
export const accentsFor = (dark: boolean): Array<{ hex: string; name: string }> => [
{ hex: dark ? '#ffffff' : '#000000', name: 'Mono' },
{ hex: '#2ea043', name: 'GitHub green' },
{ hex: '#00d5ff', name: 'Cyber cyan' },
{ hex: NOUS_ACCENT, name: 'Nous blue' },
{ hex: '#8a2be2', name: 'Ultraviolet' },
{ hex: '#e0218a', name: 'Barbie pink' },
{ hex: '#ff073a', name: 'Electric red' },
{ hex: '#ff6a00', name: 'Safety orange' }
]
export function AccentSwatch({
active,
hex,
name,
onPick
}: {
active: boolean
hex: string
name: string
onPick: () => void
}) {
return (
<Tip label={name}>
<button
aria-label={name}
aria-pressed={active}
className={cn(
// The hairline keeps the mono swatch visible on its own pole.
'size-9 rounded-full border border-foreground/15 transition-transform duration-150',
!active && 'hover:scale-105'
)}
onClick={onPick}
style={{
background: hex,
boxShadow: active ? `0 0 0 2px var(--dt-background), 0 0 0 4px ${hex}` : undefined
}}
type="button"
/>
</Tip>
)
}
// Mini layout trees mirror the basic (BASIC_TREE) and terminal-deck
// (TERMINAL_TREE) presets registered in app/contrib/controller.tsx, drawn in
// the layout editor's thumbnail language, upscaled.
export type MiniNode = 1 | { dir: 'column' | 'row'; children: MiniNode[]; weights: number[] }
/** The power-user layout. Picking it is the most explicit thing a user does
* in the whole first run to say how they work. */
export const ELITE_LAYOUT_ID = 'terminal-deck'
export const LAYOUTS: Array<{ id: string; name: string; tree: MiniNode }> = [
{ id: 'basic', name: 'Basic', tree: { children: [1, 1], dir: 'row', weights: [1, 4.6] } },
{
id: ELITE_LAYOUT_ID,
name: 'Elite',
tree: {
children: [{ children: [1, 1, 1], dir: 'row', weights: [1, 3.2, 1.2] }, 1],
dir: 'column',
weights: [3, 1]
}
}
]
export function MiniTree({ node }: { node: MiniNode }) {
if (node === 1) {
return <div className="min-h-0 min-w-0 flex-1 rounded-[3px] bg-foreground/15" />
}
return (
<div className={cn('flex min-h-0 min-w-0 flex-1 gap-1', node.dir === 'row' ? 'flex-row' : 'flex-col')}>
{node.children.map((child, i) => (
<div className="flex min-h-0 min-w-0" key={i} style={{ flex: `${node.weights[i]} ${node.weights[i]} 0px` }}>
<MiniTree node={child} />
</div>
))}
</div>
)
}
/**
* The window buttons on the preview, drawn the way this machine draws them.
*
* The card is a picture of the user's own window, so it follows the split
* `main.ts` already makes when it builds one: macOS gets the traffic lights on
* the left (`trafficLightPosition`), everywhere else the native controls ride
* on the right as monochrome glyphs (`titleBarOverlay`). Three coloured dots on
* a Windows machine is a picture of somebody else's computer — a small tell, in
* the one moment the app is claiming to show you yours.
*/
function MiniWindowButtons() {
if (IS_MAC) {
return (
<span aria-hidden className="flex gap-1">
<span className="size-1.5 rounded-full bg-[#ff5f57]" />
<span className="size-1.5 rounded-full bg-[#febc2e]" />
<span className="size-1.5 rounded-full bg-[#28c840]" />
</span>
)
}
// Minimize, maximize, close — at 6px the glyphs themselves are mush, so each
// is the shape it would be: a bar, a box, and a cross that reads as one.
return (
<span aria-hidden className="flex items-center justify-end gap-1.5 text-foreground/40">
<span className="h-px w-1.5 bg-current" />
<span className="size-1.5 border border-current" />
<span className="relative size-1.5">
<span className="absolute top-1/2 left-0 h-px w-full rotate-45 bg-current" />
<span className="absolute top-1/2 left-0 h-px w-full -rotate-45 bg-current" />
</span>
</span>
)
}
export function LayoutPreviewCard({
active,
name,
onSelect,
tree
}: {
active: boolean
name: string
onSelect: () => void
tree: MiniNode
}) {
return (
<button aria-pressed={active} className="group flex flex-col items-center gap-2" onClick={onSelect} type="button">
<span className={cn('flex aspect-[10/7] w-full flex-col gap-1.5 rounded-[8px] p-2', selectableClass(active))}>
<MiniWindowButtons />
<span className="flex min-h-0 flex-1">
<MiniTree node={tree} />
</span>
</span>
<span className={cn('text-xs', active ? 'text-foreground' : 'text-muted-foreground')}>{name}</span>
</button>
)
}
@@ -0,0 +1,314 @@
/**
* The welcome chat — the profile guided onboarding runs in.
*
* It is not an anonymous session: it belongs to a persistent `hermes-setup`
* profile, so the conversation survives onboarding and can be found again. An
* ordinary profile with an ordinary visible chat — there is no bot surface
* here, and nothing in this flow mints one.
*
* `setup` is the INTERNAL name throughout this module (the profile key, the
* atoms, the hidden `[setup]` notes). It is never what the user reads: to
* them the voice is just Hermes, and the chat is titled `Welcome to Hermes`.
*
* When the first task is decided it is NOT built in this chat. The model emits
* `::onboarding{step="handoff" task="…" brief="…"}` and the renderer opens a
* NEW session on the user's default profile, seeded with the work-side
* runbook, and starts the build there. The welcome chat hears how it went
* through a hidden `[setup]` note.
*
* This module owns the pure pieces (names, souls, seed prompts, the handoff
* request atom). The side effects — profiles.create, session.create, the chat
* switch — live in the wiring's handoff effect so they run with real
* gateway/session hooks.
*/
import { atom } from 'nanostores'
import type { GatewayRequest } from '@/app/session/hooks/use-prompt-actions/utils'
import { readKey, writeKey } from '@/lib/storage'
import { machineDescription } from '@/store/machine'
import type { OnboardingAnswers } from '@/store/onboarding-answers'
import { PLAIN_SPEECH } from '@/store/onboarding-script'
/** Profile name of the onboarding guide. Prefixed so it can't collide with a
* profile a user actually named "setup". */
export const SETUP_PROFILE = 'hermes-setup'
/** Title of the welcome chat, and the row the user sees in their sessions
* list. Exact-title lookup is how kickoff re-finds it across relaunches, so
* this string is also a registry key — change the words, keep them stable. */
export const SETUP_CHAT_TITLE = 'Welcome to Hermes'
const HANDOFF_DONE_KEY = 'hermes-setup-handoff-done-v1'
export type SetupHandoffPhase = 'done' | 'error' | 'opening' | 'pending'
/** What KIND of first job this is. Two shapes we script ourselves:
*
* 'machine-setup' — the work is known (audit the box, then install), the user
* can't brief it, and the agent needs permission discipline the moment it
* starts touching the system.
*
* 'plugin' — the first build is a piece of THEIR app. A plugin is a single
* file the runtime hot-loads on save, so the payoff lands inside the window
* they are already looking at instead of somewhere on disk, and their first
* session ends with a surface nobody else has. Not every first task suits it
* (see the runbook's own test), which is why it is a plan rather than a
* default.
*
* Everything else is 'build' — the user's own idea, in whatever shape it
* wants. */
export type HandoffPlan = 'build' | 'machine-setup' | 'plugin'
const HANDOFF_PLANS: readonly HandoffPlan[] = ['build', 'machine-setup', 'plugin']
export function parseHandoffPlan(raw: string | undefined): HandoffPlan {
const value = (raw ?? '').trim().toLowerCase()
return HANDOFF_PLANS.find(plan => plan === value) ?? 'build'
}
export interface SetupHandoffState {
guide?: SetupSession
task: string
brief: string
phase: SetupHandoffPhase
plan: HandoffPlan
/** Title of the session the build landed in, once it exists. */
sessionTitle?: string
}
/** The handoff beacon: HandoffCard raises it, the wiring effect performs it.
* Null until the model emits the handoff directive. */
export const $setupHandoff = atom<null | SetupHandoffState>(null)
/** The issuing welcome chat owns the completion note, even in a background tile. */
export interface SetupSession {
connectionId: null | string
profile: string
runtimeId: string
storedId: null | string
}
export const $setupSession = atom<null | SetupSession>(null)
/** Raise the handoff request (once per task — re-parses and re-mounts of the
* directive are no-ops, and a relaunch after a completed handoff stays
* quiet thanks to the storage latch). */
export function requestSetupHandoff(task: string, brief: string, plan: HandoffPlan, guide: SetupSession): boolean {
if ($setupHandoff.get() !== null || readKey(HANDOFF_DONE_KEY) === '1') {
return false
}
$setupHandoff.set({ brief, phase: 'pending', plan, task, guide })
return true
}
/** Burn the relaunch latch — the build session exists and its chat is open. */
export function markSetupHandoffDone(): void {
writeKey(HANDOFF_DONE_KEY, '1')
}
/** True once a handoff completed on this install (survives relaunch) — used
* by the card to render its settled state when the atom is long gone. */
export function hasCompletedSetupHandoff(): boolean {
return readKey(HANDOFF_DONE_KEY) === '1'
}
export function resetSetupHandoffForTests(): void {
writeKey(HANDOFF_DONE_KEY, null)
$setupHandoff.set(null)
$setupSession.set(null)
}
/** Short display title for the first build's session row. */
export function firstTaskTitle(task: string): string {
const trimmed = task.trim()
return trimmed.length > 28 ? `${trimmed.slice(0, 27).trimEnd()}…` : trimmed || 'First build'
}
/** SOUL.md for the welcome profile — its standing identity across the welcome
* chat and every later check-in. */
export function composeSetupSoul(): string {
return [
'# Hermes',
'',
'You are Hermes, and this profile is where you met this user for the first time and stay reachable afterwards. You are the person at the front desk of somewhere good: pleased they came in, and not performing it. Quick, unhurried, never flustered, never in the way. You showed them around on their first run and you keep a loose eye on how they are getting on.',
'',
'- Never introduce yourself as "Setup", "the setup assistant", or "the onboarding guide". You are Hermes.',
'- Warmth is in paying attention, not in adjectives. Remember what they told you and use it. Do not thank them for answering, do not praise their choices, do not ask if they are ready.',
'- Offer an opinion lightly when you have one. "Most people wire that one up first" is worth more than a neutral menu.',
'- You are training wheels: useful early, ignorable later. Never guilt-trip, never nag. If the user asks you to stop checking in, stop.',
'- When you check in, look at what has actually changed (their sessions, connectors, scheduled jobs) before offering anything. One concrete suggestion beats a menu.',
'- Things worth offering, roughly in order: wiring a connector they said they use, scheduling something they do repeatedly, a second build based on the first, keyboard/layout niceties.',
'- Write like a person talking to another person. Short sentences, plain words, no headers, no bullet walls, no emoji.'
].join('\n')
}
/** The hidden runbook seeded into the first build's session — the work-side
* half of the old single-chat script: no-auth first build, the permissions
* note, and the live progress cards. */
export function buildFirstTaskRunbook(
task: string,
answers: OnboardingAnswers,
plan: HandoffPlan = 'build',
pluginRoot = ''
): string {
const name = (answers.name ?? '').trim()
const context = (answers.context ?? '').trim()
const tools = (answers.connectors ?? []).filter(Boolean)
return [
`You are Hermes. The user's welcome chat just opened this session so one task can have room to run: ${task.trim()}.`,
'This message is invisible to the user — never reference it or the mechanics described here.',
name ? `The user is called ${name} — you already know that, so never introduce yourself or ask who they are.` : '',
context
? `They already said what they are working on: ${context}. Let it shape your choices without re-asking.`
: '',
tools.length
? `Tools they use day to day: ${tools.join(', ')} — none are connected yet; never require one for this first build.`
: '',
'Their next message is the go signal: really begin the work — plan briefly, then build (scaffold, research, first artifact).',
"As you start, tell them in one short sentence: you'll ask for permissions as you go, and they can say no to anything or redirect you.",
...planRunbook(plan, pluginRoot),
...connectorRunbook(tools),
'While the work runs, place ::onboarding{step="progress" title="what you\'re doing"} as its own paragraph at the start of each status turn — the card shows the build breathing live. Keep the titles short and present-tense ("Scaffolding the project", "Wiring the reminder"). Emit each exactly like that, alone on its own line.',
'When the first pass of the build is DONE: end that turn with ::ask{question="Does this match what you wanted?" options="Looks right|Change something|Take it further"} alone as its own paragraph, emitted EXACTLY as written. Act on their pick immediately. One unreviewed first output is how a build reads as broken; the ask is how it reads as a collaboration.',
PLAIN_SPEECH
]
.filter(Boolean)
.join(' ')
}
const NO_AUTH_RULE =
'CRITICAL: this first build must need NO external account or OAuth (no Gmail, no Slack, no Google sign-in) — connectors are optional and get wired only with their consent. Everything else is fair game and the more visible the better: web research with the browser shown to the user as you work, scripts, computer use, a small app, a file-based tracker, a scheduled reminder, a generated page. If the idea needs an account, build the no-auth core first and say the connection is a later step.'
/** The picks invite an optional connection, not a claim that an account is already linked. */
function connectorRunbook(picks: string[]): string[] {
if (picks.length === 0) {
return []
}
return [
`The user said they use these apps: ${picks.join(', ')}. Offer to connect the ones useful for this task, but keep the no-auth core moving and never require sign-in to finish it.`,
'When they want a connection, use manage_connections action="status" first. Match against the returned catalog; never invent a connector slug or claim an unavailable app is supported. Ask for consent before reading private data. For apps they agree to connect, make one batched action="connect" request and show its real authorization links labelled with each app’s name.',
'After the user has seen and approved those links, use manage_connections action="wait" for the same slugs; only a confirmed connected result permits tool use. A timeout, declined consent or gateway outage means not connected, never an empty inbox. Say which apps remain unavailable and offer to continue without them. Never describe a gateway error as proof they need another Nous login.',
'Discover the connected app’s relevant tools with tool_search and use real results for the requested task. Never fabricate sample account data as if it came from a connector. Reading is separate from sending, deleting or scheduling: ask before those actions. No automatic daily brief or recurring job unless that is what the user asked for.'
]
}
/** The one first job we script end to end. Setting up a machine is the task a
* brand-new user most wants and can least brief, so the agent does the
* briefing: look first, propose, then install with consent. Audit-before-plan
* is the load-bearing part — a plan invented before looking is how an agent
* ends up installing a second copy of something, or "fixing" drivers that
* were already fine. */
const MACHINE_SETUP_RUNBOOK = [
'THIS IS A MACHINE SETUP JOB: get this computer genuinely ready to use, end to end, with the terminal. It is the one first task that does not need an account anywhere — never send them to a sign-in to complete it.',
'START BY LOOKING, NOT PLANNING. Before proposing anything, use the terminal to find out what is actually here: OS name and version, architecture, pending system updates, free disk, which package manager exists (Homebrew / winget / apt / dnf), and which everyday things are already installed (a browser, an editor, git, python, node, docker, and whatever tools they mentioned earlier). On an NVIDIA machine also check the GPU and driver (nvidia-smi) and whether a container runtime and CUDA toolchain are present. Report what you found in a few short lines — plainly, no tables.',
'THEN PROPOSE, THEN ASK. Turn the gaps into a short numbered plan, cheapest and most obviously useful first: system updates, a package manager if missing, their everyday tools, sane defaults, and only then anything exotic. End that turn with ::ask{question="Want me to run this?" options="Go ahead|Change the list|Just the essentials"} alone as its own paragraph, emitted EXACTLY as written.',
'THEN WORK IT ONE STEP AT A TIME, saying in one short line what each step is for before you run it. Prefer the official package manager over downloading installers. Never install something they did not agree to, never overwrite existing config without asking first, never disable security settings, and stop and ask the moment anything looks destructive or wants a password you were not given.',
'Hardware and drivers: on Windows, check for missing/unknown devices and vendor GPU drivers, and say plainly when the OS already has it handled. On macOS, system updates and the App Store cover drivers — say so instead of inventing work. On Linux, check the kernel/driver pairing for the GPU before touching it.',
'If the machine is Arm (an Arm64 Windows PC, an Apple silicon Mac), architecture is the first thing you check for every install: prefer the native arm64 build, say so when only an emulated x64 one exists, and never assume a tool has an Arm release because it is popular. On an Arm Windows PC with NVIDIA silicon, treat CUDA and anything GPU-adjacent as arm64-specific — verify the build before installing it.',
'Anything that genuinely needs their sign-in, a licence key, or a payment: do not attempt it. Collect those into a short "yours to do" list for the end.',
'FINISH with a few lines: what changed, what you skipped and why, and what is left for them. If a reboot is needed, say so plainly.'
]
/** The other scripted job: the first build is a piece of their own app.
*
* A desktop plugin is one file — plain ESM, `jsx()` calls, no build step —
* that the runtime loader hot-loads the moment it is written (see
* contrib/runtime-loader.ts, whose whole design is "agent rewrites a plugin
* file, clean reload"). That is what makes this a good FIRST task rather than
* an ambitious one: the payoff appears inside the window the user is already
* looking at, seconds after the file lands, and it is theirs in a way a file
* on disk never is.
*
* The catalog is reference, not a dependency: thirteen reviewed plugins in
* NousResearch/plugins show the shapes that work. Reading one beats inventing
* an API, and the agent is told to look before it writes. */
const pluginRunbook = (root: string) => [
'THIS IS A PLUGIN JOB: the thing you are building is a piece of the Hermes app itself, and it will appear in the window the user is looking at right now. That is the whole point — do not let it become a script in a folder.',
`A plugin is ONE file: \`${root}/<name>/plugin.js\`. Plain ESM, no build step, no package.json, no install. It imports from \`@hermes/plugin-sdk\` and calls \`jsx()\` from \`react/jsx-runtime\` directly (there is no JSX compiler in this path — writing \`<div>\` will not work). It default-exports \`{ id, name, register(ctx) }\` and \`register\` calls \`ctx.register({ id, area, order, render })\`. The runtime loads it the moment you save, and reloads it on every later save, so there is no restart to ask them for.`,
'LOOK BEFORE YOU WRITE. Read the `building-hermes-desktop-plugins` skill first — it has the SDK surface, the areas you can render into, and the traps. If the machine has a checkout of NousResearch/plugins, read a plugin close to what you are making; those thirteen are reviewed and show the real shapes (a statusbar chip, a composer action, a full pane).',
'START SMALL AND VISIBLE. The first save should put something on screen even if it only renders a label — a chip that says the right word beats a half-written dashboard, because they SEE it work and everything after that is refinement they are watching. Build up from there in passes.',
'Say what you are doing in one short line per pass, and tell them where to look the first time it appears ("bottom right of the status bar" / "it is in the right pane now"). A plugin that loaded silently reads as nothing having happened.',
'Never ask them to restart the app, never edit anything outside their plugin folder, and never touch the Hermes install itself. If the plugin errors on load, the app toasts it and keeps running — read the error, fix the file, save again.'
]
/** The plan's own instructions, or the no-auth rule when the shape is the
* user's own idea. One switch so a new plan cannot half-land: adding a case
* here is what makes `plan="…"` mean anything at the other end. */
function planRunbook(plan: HandoffPlan, pluginRoot: string): string[] {
switch (plan) {
case 'machine-setup':
return machineSetupRunbook()
case 'plugin':
// NO_AUTH_RULE still applies: a plugin that needs an API key on its
// first run is the same dead end as any other first build that does.
if (!pluginRoot) {
throw new Error('The desktop plugin folder is unavailable. Retry before starting the first build.')
}
return [...pluginRunbook(pluginRoot), NO_AUTH_RULE]
default:
return [NO_AUTH_RULE]
}
}
/** The same runbook, opening with what the app already knows about the machine
* — freshness first. That fact decides whether the job is an afternoon of real
* work or a tour of things already handled, and the agent should not spend its
* first two turns discovering what one IPC already answered. */
function machineSetupRunbook(): string[] {
const description = machineDescription()
return description
? [`What the app can already see about it: ${description}.`, ...MACHINE_SETUP_RUNBOOK]
: MACHINE_SETUP_RUNBOOK
}
/** Seed rows for the build session's session.create — just the hidden runbook;
* the visible go-signal (the task brief) is submitted as a real turn right
* after, which is what starts the build. */
export async function buildFirstTaskSeedMessages(
task: string,
answers: OnboardingAnswers,
plan: HandoffPlan = 'build'
): Promise<{ content: string; display_kind?: 'hidden'; role: 'assistant' | 'user' }[]> {
const root = plan === 'plugin' ? await window.hermesDesktop?.desktopPluginsRoot?.() : undefined
return [{ content: buildFirstTaskRunbook(task, answers, plan, root), display_kind: 'hidden', role: 'user' }]
}
/** The hidden note whispered into the Setup chat once the build session is
* live — Setup's cue to close the loop and stand down. The check-ins that
* follow are driven by the build's own progress (see first-build.ts), not by
* a schedule Setup has to remember to create. */
export function buildHandoffCompleteNote(task: string): string {
return `[setup] handoff complete — "${task.trim()}" is now building in its own session, and the user is watching it there. Say ONE short line and then stop: you're around if they want a hand, and this chat stays where it is. Do not ask a question, do not offer a list, do not schedule anything.`
}
// ── gateway helpers (called from the wiring's kickoff + handoff effects) ─────
/** Create the guide once with the default profile’s configured providers and shared OAuth. */
export async function ensureSetupProfile(request: GatewayRequest): Promise<void> {
try {
await request('profiles.create', {
description: 'Where Hermes met you — walks your first run, then checks in as you find your feet.',
name: SETUP_PROFILE,
clone_from: 'default',
share_auth: true,
no_alias: true,
soul: composeSetupSoul()
})
} catch (error) {
if (!(error instanceof Error && /exist/i.test(error.message))) {
throw error
}
}
}
@@ -0,0 +1,70 @@
/**
* THE PARTING SIGNPOST — one lit moment, at the one moment it earns itself.
*
* The handoff is the only point in the run where the ground moves under the
* user: they were talking to Hermes on its own profile, and they land mid-build
* in a session of their own. The chat they just spent five minutes in is still
* there, one square away in the profile rail, and nothing on screen says so.
*
* So as they land, the rail lights up once. A single accent-lit step, not a
* tour: the whole appeal of this flow is that it happens in conversation, and
* spending that on a click-through at the last beat would be a poor trade.
*
* Skipped for the user who answered "I'll figure it out" — they were offered a
* look around and declined, and this is the shape of a look around. Their
* version of this is a line in the chat (see the runbook's step 4).
*/
import { type ChatMessage, chatMessageText } from '@/lib/chat-messages'
import { TOUR_OPTIONS } from '@/store/onboarding-script'
/** The rail's tour handle (profile-switcher.tsx). `data-tour` rather than the
* `data-slot` beside it because only the former is identity to
* collectTourTargets — so this is the same selector the model gets back when
* it scans for targets, not a private one this file made up. */
const RAIL = '[data-tour="profile-rail"]'
/** Did they wave off the look around? Read from the guide transcript, because
* the pick IS a user turn there and the option text is pinned by the script
* (that is what TOUR_OPTIONS is for — both sides read the same constant). */
export function declinedLookAround(messages: ChatMessage[]): boolean {
return messages.some(message => message.role === 'user' && chatMessageText(message).trim() === TOUR_OPTIONS.none)
}
/** The rail mounts a render or two after the handoff swaps profiles, so wait
* for the node rather than firing into an empty DOM (the engine would return
* a no-match and the moment would pass silently). Gives up quietly. */
async function waitForRail(timeoutMs = 6000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (document.querySelector(RAIL)) {
return true
}
await new Promise(resolve => setTimeout(resolve, 120))
}
return false
}
/** Light the rail with the parting line. Never throws, never blocks the
* handoff — this is the nicety at the end, not part of the machinery. */
export async function showProfileSignpost(): Promise<void> {
if (!(await waitForRail())) {
return
}
// Imported here, not at the top: this module is reachable from the boot path
// through the handoff hook, and driver.js plus its stylesheet are exactly
// what run-tour.ts keeps off it.
const { showTourStep } = await import('@/lib/tour')
await showTourStep({
accent: true,
selector: RAIL,
side: 'right',
text: "You're in your own workspace now, and this is where the profiles live. The chat we just had is still in there — come back to it whenever you want a hand.",
title: 'Hermes is still next door'
})
}
@@ -0,0 +1,30 @@
/**
* The guided setup's escape hatch. Rides the composer's floating strip — the
* same band the action badges and suggestion pills use — so it shares the
* composer's edges instead of floating at an arbitrary offset. Skip assembles
* the default layout, marks onboarding done, and drops the user in the full
* app; the guided chat stays in the transcript. Visible from guide kickoff
* until the layout pick assembles ($chatOnboardingSolo).
*/
import { useStore } from '@nanostores/react'
import { $chatOnboardingSolo, skipChatOnboarding } from '@/components/onboarding-chat/assembly'
export function OnboardingSkip() {
const solo = useStore($chatOnboardingSolo)
if (!solo) {
return null
}
return (
<button
className="ml-auto text-[11px] text-(--ui-text-quaternary) transition-colors hover:text-(--ui-text-secondary)"
onClick={skipChatOnboarding}
type="button"
>
Skip setup
</button>
)
}
@@ -14,6 +14,7 @@ import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import { FREE_TIER_MODEL } from '@/store/free-tier'
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
import { $introReveal, shouldPlayFirstRunIntro } from '@/store/intro-reveal'
import { $localModelsEnabled } from '@/store/local-models-flag'
import {
$desktopOnboarding,
@@ -33,6 +34,7 @@ import {
startManualOnboarding,
startProviderOAuth
} from '@/store/onboarding'
import { $onboardingSurfaces, onboardingSurfaceActive } from '@/store/onboarding-presence'
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
import { DocsLink, FlowPanel, Status } from './flow'
@@ -200,6 +202,8 @@ export function DesktopOnboardingOverlay({
const { t } = useI18n()
const onboarding = useStore($desktopOnboarding)
const boot = useStore($desktopBoot)
const introReveal = useStore($introReveal)
useStore($onboardingSurfaces)
const onCompletedRef = useRef(onCompleted)
onCompletedRef.current = onCompleted
const targetProfile = onboarding.targetProfile ?? profile
@@ -306,6 +310,13 @@ export function DesktopOnboardingOverlay({
}
}, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
if (
!onboarding.manual &&
(introReveal.phase !== 'hidden' || onboardingSurfaceActive() || shouldPlayFirstRunIntro(onboarding.firstRunSkipped))
) {
return null
}
// Mount from frame 1 so we replace the boot overlay seamlessly. The
// configured field stays null until the runtime check resolves; only then
// do we know whether to dismiss (true) or surface the picker (false).
@@ -0,0 +1,45 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
/**
* Error boundary for the transparent `?win=` overlay windows.
*
* Same job as `ErrorBoundary`, none of its weight. That one renders the app's
* rich fallback, so importing it pulls `Button`, `ErrorState` and the i18n
* provider — the app shell these windows exist to avoid, and which their own
* docs claim they don't load. Under the dev server that chain WAS most of the
* intro cinematic's module graph, queued ahead of the surface trying to paint.
*
* An overlay also has nowhere to put a fallback and nobody to click Retry: it
* is a see-through window over the user's desktop. So a failure renders
* nothing and the window's own deadman takes it off screen, which is the
* outcome a stuck overlay needs anyway.
*/
export class OverlayErrorBoundary extends Component<{ children: ReactNode; label: string }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(`[overlay:${this.props.label}]`, error, info.componentStack)
// Persist to desktop.log via Electron (#79428): console.error only reaches
// the main process for windows with a console hook, is minified, and loses
// the component stack.
try {
window.hermesDesktop?.reportRendererError?.({
boundary: this.props.label,
componentStack: info.componentStack ?? '',
label: new URLSearchParams(window.location.search).get('win') ?? 'overlay',
message: error.message
})
} catch {
// Logging must never take the boundary down with it.
}
}
render() {
return this.state.failed ? null : this.props.children
}
}
@@ -13,6 +13,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef,
import { HUD_SURFACE } from '@/app/floating-hud'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { useOnboardingChatActive } from '@/components/onboarding-chat/assembly'
import { Codicon } from '@/components/ui/codicon'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
@@ -190,7 +191,11 @@ export function FloatingPanes() {
const panes = useContributions('panes')
const hidden = useStore($hiddenTreePanes)
const floating = panes.filter(pane => paneChrome(pane).placement === FLOATING_PLACEMENT && !hidden.has(pane.id))
const onboardingActive = useOnboardingChatActive()
const floating = onboardingActive
? []
: panes.filter(pane => paneChrome(pane).placement === FLOATING_PLACEMENT && !hidden.has(pane.id))
if (floating.length === 0) {
return null
@@ -9,6 +9,7 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { $chatOnboardingSolo } from '@/components/onboarding-chat/assembly'
import { PaneTab, PaneTabLabel, PaneTabStrip } from '@/components/ui/pane-tab'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
@@ -24,6 +25,7 @@ import { paneChrome } from './track-model'
export function NarrowOverlays() {
const narrow = useStore($narrowViewport)
const solo = useStore($chatOnboardingSolo)
const tree = useStore($layoutTree)
const panes = useContributions('panes')
const hiddenPanes = useStore($hiddenTreePanes)
@@ -37,8 +39,10 @@ export function NarrowOverlays() {
const inTree = useMemo(() => new Set(tree ? allPaneIds(tree) : []), [tree])
const collapsibles = useMemo(
() => panes.filter(p => paneChrome(p).collapsible && inTree.has(p.id) && !hiddenPanes.has(p.id)),
[panes, inTree, hiddenPanes]
// Solo adopts sidebar panes without their surrounding sidebar chrome.
// Suppress every reveal path while those panes are intentionally hidden.
() => (solo ? [] : panes.filter(p => paneChrome(p).collapsible && inTree.has(p.id) && !hiddenPanes.has(p.id))),
[solo, panes, inTree, hiddenPanes]
)
const collapsiblesRef = useRef(collapsibles)
@@ -47,7 +51,7 @@ export function NarrowOverlays() {
// ⌘B / ⌘G's narrow branch dispatches the app's toggle-reveal event with the
// REAL pane id — accept those via each contribution's revealAliases.
useEffect(() => {
if (!narrow) {
if (!narrow || solo) {
setReveal(null)
return
@@ -99,9 +103,9 @@ export function NarrowOverlays() {
window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
window.removeEventListener('keydown', onKeyDown)
}
}, [narrow])
}, [narrow, solo])
if (!narrow || collapsibles.length === 0) {
if (!narrow || solo || collapsibles.length === 0) {
return null
}
@@ -213,6 +213,28 @@ function setDismissed(paneId: string, dismissed: boolean) {
}
}
/**
* Clear dismissal records for panes a NEW layout declares, without touching
* the tree or anyone's active tab (`revealTreePane` fronts, which would bury
* whatever the user is looking at).
*
* A dismissal outlives the layout that caused it. Switching to a layout that
* wants a previously dismissed pane back would otherwise place it in the tree
* and leave it invisible — the layout half-applies.
*/
export function undismissTreePanes(paneIds: Iterable<string>): void {
const dismissed = $dismissedPanes.get()
const next = new Set(dismissed)
for (const paneId of paneIds) {
next.delete(paneId)
}
if (next.size !== dismissed.size) {
saveDismissed(next)
}
}
// SPLIT-SHARE MEMORY — a tile pane that leaves the tree (the browser closed,
// a page tile closed) records the share it held against its seam neighbor, so
// re-opening it docks at the size the user left it. Without this every
@@ -1271,6 +1293,20 @@ writeKey('hermes.desktop.paneDockHeals.v1', null)
// until the next launch, so there is never a tug-of-war.
const enforcedDocksThisBoot = new Set<string>()
/**
* Reopen the enforcement window. The ledger protects a user's mid-session
* drags, but a wholesale tree replacement has no drags left to protect — and
* a pass that ran against a DIFFERENT tree burned the entry for nothing. That
* is how the guided onboarding shipped Bots as a tab over the chat: the boot
* pass fired while the solo tree had no sessions column to anchor to, so the
* assembled layout's pass was skipped as already-done.
*
* Only call this when replacing the tree wholesale.
*/
export function resetEnforcedDocks(): void {
enforcedDocksThisBoot.clear()
}
/**
* A `panes` contribution whose dock hint carries `enforce: true` is re-homed
* onto the hint's anchor at every boot's first adoption pass when it isn't
@@ -1347,7 +1383,7 @@ function enforceDockedPanes(
return next
}
function adoptContributedPanes(): void {
export function adoptContributedPanes(): void {
const tree = $layoutTree.get()
if (!tree) {
@@ -200,6 +200,7 @@ export interface ConnectorCardProps {
/** A sibling card is mid-flight. Two sign-in tabs racing for focus is
* hostile, so the action waits — but the decline never does. */
otherBusy?: boolean
actionDisabled?: boolean
outcome?: ConnectorCardOutcome
/** Present only while working; replaces the resting state label. */
phase?: string
@@ -224,6 +225,7 @@ export function ConnectorCard({
onDismiss,
onEnvChange,
otherBusy = false,
actionDisabled = false,
outcome,
phase,
state
@@ -315,7 +317,7 @@ export function ConnectorCard({
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={working || otherBusy}
disabled={working || otherBusy || actionDisabled}
onClick={onConnect}
size="xs"
variant="ghost"
+52
View File
@@ -109,6 +109,35 @@ declare global {
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
}
// Intro reveal: the full-screen first-run brand sequence. The main
// renderer owns the phase; the overlay window (`?win=intro`) owns
// the animation clock and plays sound locally.
introReveal?: {
open: (payload?: { hideMain?: boolean }) => Promise<{ ok: boolean }>
close: (payload?: { showMain?: boolean }) => Promise<{ ok: boolean }>
skip: () => void
/** The surface painted its first frame — reveal the OS window now. */
ready: () => void
onSkip: (callback: () => void) => () => void
onClosed: (callback: () => void) => () => void
}
// In-chat onboarding assembly: grow the main window outward by per-edge
// pixel deltas so the chat pane keeps its exact screen rect while the
// app assembles around it.
chatOnboarding?: {
grow: (request: {
bottom: number
left: number
/** Floor for the resulting CSS-pixel viewport width, for layouts that
* need one (a docked sidebar). Clamped to the display. */
minWidth?: number
right: number
top: number
}) => void
/** The film has revealed the app. Animate the visible window down to
* the solo-chat size as the guided chat starts. */
soloBoot?: () => void
}
// HUD mode: the chrome-free floating chat. A FULL app renderer with its
// own gateway (like an instance window), sized and skinned as a floating
// bar — so it mounts the real composer rather than a lookalike. Main
@@ -522,6 +551,9 @@ declare global {
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
/** Host facts for the guided first run. Optional: an older preload (a
* mid-upgrade managed install) simply doesn't answer. */
getMachineProfile?: () => Promise<DesktopMachineProfile>
/** Restart the app in place — loads the swapped bundle when bundleSwapPending. */
relaunchApp?: () => Promise<void>
getRemoteDisplayReason?: () => Promise<string | null>
@@ -609,6 +641,26 @@ export interface DesktopVersionInfo {
bundleSwapPending?: boolean
}
export interface DesktopMachineProfile {
/** Days since the OS created this user account; null when unknowable. */
ageDays: null | number
arch: string
/** The OS display language (`app.getLocale()`, e.g. "ja", "pt-BR"); '' when
* unknowable. A first-run DEFAULT for the UI language, never a lock — the
* user's saved `display.language` always wins, and the picker still rules. */
locale: string
/** Hardware's self-reported model (`NVIDIA_DGX_Spark`); '' when unavailable. */
model: string
/** An NVIDIA GPU is present, by PCI vendor id. */
nvidia: boolean
platform: string
release: string
/** OS login name ('' when unknowable) — a first-name SUGGESTION for the
* guided chat, never a default. The renderer blocklists handles that are
* not a name before offering it. */
username: string
}
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
export interface DesktopUninstallSummary {
+6 -1
View File
@@ -1,3 +1,8 @@
import { useMediaQuery } from './use-media-query'
export const useIsMobile = () => useMediaQuery(`(max-width: ${768 / 16 - 1 / 16}rem)`)
/** Narrower than this and the sidebar stops being a docked column: it renders
* as a floating Sheet over the content instead (components/ui/sidebar.tsx).
* Anything that sizes a window around a docked sidebar has to clear it. */
export const DOCKED_SIDEBAR_MIN_PX = 768
export const useIsMobile = () => useMediaQuery(`(max-width: ${(DOCKED_SIDEBAR_MIN_PX - 1) / 16}rem)`)
+8
View File
@@ -2512,6 +2512,14 @@ export const ar = defineLocale({
noReturn: 'لم تعد الواجهة الخلفية إلى الاتصال. قد لا يكون التحديث قد اكتمل — تحقق من مضيف الواجهة الخلفية.'
}
},
guidedGreeting: {
lines: [
'أهلا، تفضل بالدخول. أنا Hermes. امنحني دقيقتين لأرتب المكان حولك، ثم نبدأ بشيء تريد إنجازه فعلا.\n\nبداية، بماذا أناديك؟',
'أهلا، أنا Hermes. سؤالان سريعان ويصبح هذا المكان مكانك، ثم نجد لك ما يستحق العمل عليه.\n\nإذن، بماذا أناديك؟',
'مرحبا، لقد وجدتني. أنا Hermes. دعني أرتب كل شيء حولك، ثم نختار شيئا حقيقيا نبدأ به.\n\nلكن أولا، بماذا أناديك؟'
],
nameSuggestion: (name: string) => `(يمكنني أن أناديك ${name} إن كنت تفضل ذلك.)`
},
install: {
stageStates: {
pending: 'قيد الانتظار',
+30 -5
View File
@@ -3,7 +3,13 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useM
import { getHermesConfigRecord, type HermesConfigRecord, saveHermesConfig } from '@/hermes'
import { TRANSLATIONS } from './catalog'
import { DEFAULT_LOCALE, localeConfigValue, normalizeLocale } from './languages'
import {
DEFAULT_LOCALE,
isSupportedLocaleValue,
localeConfigValue,
normalizeLocale,
resolveInitialLocale
} from './languages'
import { setRuntimeI18nLocale } from './runtime'
import type { Locale, Translations } from './types'
@@ -20,14 +26,16 @@ const defaultConfigClient: I18nConfigClient = {
return Promise.resolve({})
}
return getHermesConfigRecord()
// Merged defaults make an unset language indistinguishable from saved English.
// Older backends ignore the option and keep returning English as before.
return getHermesConfigRecord(undefined, { includeDefaults: false })
},
saveConfig: config => {
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
return Promise.resolve({ ok: true })
}
return saveHermesConfig(config)
return saveHermesConfig(config, undefined, { preserveLanguage: true })
}
}
@@ -134,9 +142,26 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini
return configClient
.getConfig()
.then(config => {
.then(async config => {
if (cancelled || userLocaleRef.current) {
return
}
const saved = getConfigDisplayLanguage(config)
// A saved choice needs no machine probe and always takes precedence.
if (isSupportedLocaleValue(saved)) {
setLocaleState(normalizeLocale(saved))
return
}
// Keep inference unsaved so OS language changes apply on the next boot
// until the user explicitly picks a language.
const machineProfile = await window.hermesDesktop?.getMachineProfile?.().catch(() => null)
if (!cancelled && !userLocaleRef.current) {
setLocaleState(normalizeLocale(getConfigDisplayLanguage(config)))
setLocaleState(resolveInitialLocale(undefined, machineProfile?.locale))
}
})
.catch(error => {
+38
View File
@@ -3,6 +3,36 @@ import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import type { Translations } from './types'
export const en: Translations = {
connectors: {
title: 'Connect your apps',
connect: 'Connect',
skip: 'Not now',
cancel: 'Stop waiting',
retry: 'Try again',
grant: 'Reconnect',
connected: 'Connected',
skipped: 'Skipped',
disabled: 'Unavailable',
failed: 'Could not connect',
needsAuth: 'Access expired',
opening: 'Opening sign-in…',
waiting: 'Finish connecting in your browser…',
timeout: 'Still waiting for authorization.',
keepWaiting: 'Keep waiting',
refresh: 'Refresh status',
statusError: 'Could not check connections. Try refreshing.',
connectError: 'Could not start authorization. Try again.',
unavailable: 'Connectors are unavailable for this session.',
ownerMissing: 'Reopen this conversation to manage its connections.',
search: 'Find an app',
empty: 'No matching apps',
continue: 'Continue in chat',
continueBusy: 'Waiting for this turn to finish',
continueFailed: 'Could not continue. Try again.',
disclaimer: 'Connecting is optional. Only authorize the apps you want Hermes to use.',
execution: 'Connector tools'
},
sessionImport: {
title: 'Continue from another app',
subtitle: 'Bring a conversation into Hermes and pick up where you left off.',
@@ -3317,6 +3347,14 @@ export const en: Translations = {
}
},
guidedGreeting: {
lines: [
"Hey, come on in. I'm Hermes. Give me two minutes to set the place up around you, then we'll put me to work on something you actually want done.\n\nFirst though, what should I call you?",
"Hey there, I'm Hermes. A couple of quick things and this will feel like yours, then we'll find you something worth doing.\n\nSo, what should I call you?",
"Hi, you found me. I'm Hermes. Let me get everything arranged around you, then we'll pick something real to start on.\n\nFirst things first though, what should I call you?"
],
nameSuggestion: (name: string) => `(I can also just call you ${name}, if you prefer.)`
},
install: {
stageStates: {
pending: 'Pending',
+8
View File
@@ -2897,6 +2897,14 @@ export const ja = defineLocale({
}
},
guidedGreeting: {
lines: [
'やあ、どうぞ。Hermes です。二分だけください、あなたに合わせて整えます。それから、本当にやりたいことに取りかかりましょう。\n\nまずは、何とお呼びすればいいですか。',
'どうも、Hermes です。いくつか伺えば、ここはあなたのものになります。そのあとで、やる価値のあることを見つけましょう。\n\nでは、何とお呼びすればいいですか。',
'こんにちは、見つけてくれましたね。Hermes です。まわりを整えてから、何か実のあるものを一つ選んで始めましょう。\n\nその前に、何とお呼びすればいいですか。'
],
nameSuggestion: (name: string) => `(よければ、${name} さんとお呼びします。)`
},
install: {
stageStates: {
pending: '待機中',
+26
View File
@@ -113,6 +113,32 @@ export function isSupportedLocaleValue(value: unknown): boolean {
return typeof value === 'string' && LOCALE_ALIASES[normalize(value)] != null
}
/** OS tags can include regions absent from the picker aliases, such as ru-UA. */
export function osPreferredLocale(tag: string | null | undefined): Locale | null {
if (!tag) {
return null
}
const exact = LOCALE_ALIASES[normalize(tag)]
if (exact) {
return exact
}
const base = tag.split(/[-_]/)[0]
return (base && LOCALE_ALIASES[normalize(base)]) || null
}
/** An explicit choice must win even when it differs from the OS language. */
export function resolveInitialLocale(saved: string | null | undefined, osLocale: string | null | undefined): Locale {
if (isSupportedLocaleValue(saved)) {
return normalizeLocale(saved)
}
return osPreferredLocale(osLocale) ?? DEFAULT_LOCALE
}
export function localeConfigValue(locale: Locale): string {
return LOCALE_OPTIONS.find(item => item.id === locale)?.configValue ?? DEFAULT_LOCALE
}
+8
View File
@@ -3051,6 +3051,14 @@ export const ru = defineLocale({
noReturn: 'Бэкенд не вернулся в сеть. Обновление могло не завершиться — проверьте хост бэкенда.'
}
},
guidedGreeting: {
lines: [
'Заходите. Я Hermes. Дайте мне пару минут — обустрою тут всё под вас, а потом займёмся тем, что вам правда нужно.\n\nДля начала: как к вам обращаться?',
'Привет, я Hermes. Пара вопросов — и здесь станет по-вашему, а дальше найдём занятие, которое того стоит.\n\nИтак, как к вам обращаться?',
'Привет, вы меня нашли. Я Hermes. Сейчас всё вокруг вас расставлю, а потом выберем что-нибудь настоящее.\n\nНо сперва: как к вам обращаться?'
],
nameSuggestion: (name: string) => `(Могу звать вас просто ${name}, если так удобнее.)`
},
install: {
stageStates: {
pending: 'Ожидает',
+38
View File
@@ -219,6 +219,35 @@ interface AuxTaskCopy {
}
export interface Translations {
connectors: {
title: string
connect: string
skip: string
cancel: string
retry: string
grant: string
connected: string
skipped: string
disabled: string
failed: string
needsAuth: string
opening: string
waiting: string
timeout: string
keepWaiting: string
refresh: string
statusError: string
connectError: string
unavailable: string
ownerMissing: string
search: string
empty: string
continue: string
continueBusy: string
continueFailed: string
disclaimer: string
execution: string
}
sessionImport: {
title: string
subtitle: string
@@ -2850,6 +2879,15 @@ export interface Translations {
}
}
/** The guided first run's pre-written opening line — banked, not generated,
* so the first paint costs no model time. Translated per locale because the
* model is told to speak the user's language from its first real turn, and
* an English opener above a Japanese reply reads as two different agents.
* `nameSuggestion` offers the OS account name as a default. */
guidedGreeting: {
lines: readonly string[]
nameSuggestion: (name: string) => string
}
install: {
stageStates: Record<string, string>
oneTimeTitle: string
+8
View File
@@ -2797,6 +2797,14 @@ export const zhHant = defineLocale({
}
},
guidedGreeting: {
lines: [
'來了,進來吧。我是 Hermes。給我兩分鐘,把這裡按你的習慣整理一下,然後我們找件你真正想做的事來做。\n\n先說,我該怎麼稱呼你?',
'你好,我是 Hermes。幾個小問題,這裡就像是你自己的了,然後我們找點值得做的事。\n\n那麼,我該怎麼稱呼你?',
'嗨,你找到我了。我是 Hermes。讓我先把一切安排到位,然後我們挑一件實實在在的事開始。\n\n不過第一件事:我該怎麼稱呼你?'
],
nameSuggestion: (name: string) => `(如果你願意,我也可以直接叫你 ${name}。)`
},
install: {
stageStates: {
pending: '等待中',
+38
View File
@@ -3,6 +3,36 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
import type { Translations } from './types'
export const zh: Translations = {
connectors: {
title: '连接你的应用',
connect: '连接',
skip: '暂不连接',
cancel: '停止等待',
retry: '重试',
grant: '重新连接',
connected: '已连接',
skipped: '已跳过',
disabled: '不可用',
failed: '连接失败',
needsAuth: '授权已过期',
opening: '正在打开登录…',
waiting: '请在浏览器中完成连接…',
timeout: '仍在等待授权。',
keepWaiting: '继续等待',
refresh: '刷新状态',
statusError: '无法检查连接,请刷新重试。',
connectError: '无法开始授权,请重试。',
unavailable: '此会话暂时无法使用连接器。',
ownerMissing: '请重新打开此对话以管理连接。',
search: '查找应用',
empty: '没有匹配的应用',
continue: '在对话中继续',
continueBusy: '等待当前操作完成',
continueFailed: '无法继续,请重试。',
disclaimer: '连接为可选操作。请仅授权你希望 Hermes 使用的应用。',
execution: '连接器工具'
},
sessionImport: {
title: '从其他应用继续',
subtitle: '将对话导入 Hermes,接着上次的进度继续。',
@@ -3460,6 +3490,14 @@ export const zh: Translations = {
}
},
guidedGreeting: {
lines: [
'来了,进来吧。我是 Hermes。给我两分钟,把这里按你的习惯收拾一下,然后我们找件你真正想做的事来做。\n\n先说,我该怎么称呼你?',
'你好,我是 Hermes。几个小问题,这里就像是你自己的了,然后我们找点值得做的事。\n\n那么,我该怎么称呼你?',
'嗨,你找到我了。我是 Hermes。让我先把一切安排到位,然后我们挑一件实实在在的事开始。\n\n不过第一件事:我该怎么称呼你?'
],
nameSuggestion: (name: string) => `(如果你愿意,我也可以直接叫你 ${name}。)`
},
install: {
stageStates: {
pending: '等待中',
+164
View File
@@ -0,0 +1,164 @@
import { isRecord } from '@assistant-ui/core/internal'
import type { ToolCallMessagePart } from '@assistant-ui/react'
/** Connector names/results as presentation data, never authorization. */
export interface ConnectorRow {
connector: string
connected?: boolean
enabled?: boolean
connectionStatus?: string | null
name?: string
description?: string
}
export function connectorText(value: ToolCallMessagePart['result']): string | undefined {
return typeof value === 'string' ? value : undefined
}
export const recordOf = (value: ToolCallMessagePart['result']): ToolCallMessagePart['args'] => {
const text = connectorText(value)
if (text !== undefined) {
try {
return recordOf(JSON.parse(text))
} catch {
return {}
}
}
// SAFETY: tool payloads arrive as JSON-RPC or stored JSON; the object guard excludes arrays and primitives.
return isRecord(value) ? (value as ToolCallMessagePart['args']) : {}
}
interface ConnectorTitles {
[slug: string]: string
}
const TITLES: ConnectorTitles = {
gmail: 'Gmail',
googlecalendar: 'Google Calendar',
googledrive: 'Google Drive',
slack: 'Slack',
github: 'GitHub',
notion: 'Notion',
linear: 'Linear',
figma: 'Figma',
discord: 'Discord',
stripe_mcp: 'Stripe',
outlook: 'Outlook'
}
export function connectorTitle(slug: string): string {
return TITLES[slug] ?? slug.replace(/[_-]+/g, ' ').replace(/\b\w/g, letter => letter.toUpperCase())
}
export function connectorToolName(name: string): { connector: string; action: string } | null {
const match = /^connectors__([a-z0-9_-]+)__(.+)$/i.exec(name)
return match ? { connector: match[1], action: match[2].replace(/_/g, ' ').toLowerCase() } : null
}
interface ConnectorCall {
name: string
arguments: ToolCallMessagePart['result']
}
export function connectorCalls(name: string, args: ToolCallMessagePart['result']): ConnectorCall[] {
if (connectorToolName(name)) {
return [{ name, arguments: args }]
}
if (name !== 'tool_call') {
return []
}
const source = recordOf(args)
const calls = Array.isArray(source.calls) ? source.calls : [source]
return calls.flatMap(item => {
const call = recordOf(item)
const callName = connectorText(call.name)
return callName !== undefined && connectorToolName(callName) ? [{ name: callName, arguments: call.arguments }] : []
})
}
export function connectionRows(
args: ToolCallMessagePart['result'],
result: ToolCallMessagePart['result']
): ConnectorRow[] {
const input = recordOf(args)
const output = recordOf(result)
const rows = new Map<string, ConnectorRow>()
const add = (item: ToolCallMessagePart['result']) => {
const slug = connectorText(item)
if (slug !== undefined) {
if (/^[a-z0-9_-]+$/i.test(slug)) {
rows.set(slug, rows.get(slug) ?? { connector: slug })
}
return
}
const row = recordOf(item)
const connector = connectorText(row.connector)
if (connector === undefined || !/^[a-z0-9_-]+$/i.test(connector)) {
return
}
const merged: ConnectorRow = { ...rows.get(connector), connector }
if (row.connected === true || row.connected === false) {
merged.connected = row.connected
}
if (row.enabled === true || row.enabled === false) {
merged.enabled = row.enabled
}
for (const key of ['connectionStatus', 'name', 'description'] as const) {
const text = connectorText(row[key])
if (text !== undefined) {
merged[key] = text
}
}
rows.set(connector, merged)
}
if (Array.isArray(input.connectors)) {
input.connectors.forEach(add)
} else if (connectorText(input.connectors) !== undefined) {
add(input.connectors)
}
for (const key of ['connectors', 'results', 'pending']) {
if (Array.isArray(output[key])) {
output[key].forEach(add)
}
}
return [...rows.values()]
}
/** Token-bearing auth links are opened only by a deliberate user action. */
export function connectorAuthorizationUrl(value: ToolCallMessagePart['result']): string | null {
const text = connectorText(value)
if (text === undefined) {
return null
}
try {
const url = new URL(text)
return url.protocol === 'https:' && !url.username && !url.password ? text : null
} catch {
return null
}
}
+17 -2
View File
@@ -113,8 +113,18 @@ export function urlSlugTitleLabel(value: string): string {
return hostPathLabel(value)
}
/** Authorization URLs must never be consumed by link-title previews. */
export function isConnectorAuthorizationLink(value: string): boolean {
const url = parseUrl(value)
// Composio links are single-use; keep previews away until the gateway exposes authorization URL metadata.
return (
!!url && url.protocol === 'https:' && url.hostname === 'connect.composio.dev' && url.pathname.startsWith('/link/')
)
}
export function isTitleFetchable(value: string): boolean {
if (!value || SKIP_PROTO_RE.test(value)) {
if (!value || SKIP_PROTO_RE.test(value) || isConnectorAuthorizationLink(value)) {
return false
}
@@ -243,7 +253,12 @@ export function openLink(href: string, options: { native?: boolean } = {}): void
return
}
if (options.native || hudForcesNativeLinks() || !/^https?:$/i.test(parseUrl(target)?.protocol ?? '')) {
if (
options.native ||
isConnectorAuthorizationLink(target) ||
hudForcesNativeLinks() ||
!/^https?:$/i.test(parseUrl(target)?.protocol ?? '')
) {
openExternalLink(target)
return
@@ -0,0 +1,4 @@
/** The preload launch flag is the only gate for guided onboarding. */
export function isOnboardingEnabled(): boolean {
return window.hermesDesktop?.guestOnboardingEnabled === true
}
+8 -2
View File
@@ -8,6 +8,8 @@
* rather than inside either one.
*/
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file'])
/** Renders a diff — the deliverable of the turn, and the one card whose cost scales. */
@@ -24,7 +26,7 @@ export function isFileEditTool(toolName: string): boolean {
// - `clarify`, `image_generate` and `delegate_task` bypass ToolEntry to
// render their own markup: a question the user has to answer, an image
// they asked for, the several agents a fan-out is running.
// - `setup_mcp` is the same kind: an inline consent card the user has to
// - `setup_mcp` and `manage_connections` are inline consent cards the user has to
// act on. Folding it into a "Using 2 tools" summary hides the buttons.
//
// Everything else is ephemeral activity — reads, searches, commands — which is
@@ -32,7 +34,11 @@ export function isFileEditTool(toolName: string): boolean {
const CARD_TOOL_NAMES = new Set(['clarify', 'delegate_task', 'image_generate', 'setup_mcp'])
export function isCardTool(toolName: string): boolean {
return CARD_TOOL_NAMES.has(toolName) || isFileEditTool(toolName)
return (
CARD_TOOL_NAMES.has(toolName) ||
isFileEditTool(toolName) ||
(toolName === 'manage_connections' && isOnboardingEnabled())
)
}
// Activity tools that render nothing at all: `todo` parts are hoisted to a
+63
View File
@@ -210,3 +210,66 @@
animation: none;
}
}
/* THE ACCENT-LIT POPOVER (`accent: true` on a step).
For the one moment in a run that is worth more than a step — right now, the
signpost that lands with the handoff. It has to read as different from the
four steps that came before it without becoming a second visual language, so
it is the SAME travelling arc the kanban cards use for live agent work
(plugins/kanban/kanban.css), re-tuned for this radius: a conic gradient
rotating behind the popover, masked down to the border band so only a ring
of it shows and the glass body underneath is untouched.
Slower than the kanban arc on purpose. That one reports work in progress and
wants to catch the eye across a board; this one is being read from a foot
away and only needs to say "this one matters". */
@property --tour-arc-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
/* The hairline stays put and the arc rides ON it (inset -1px lands the ring
exactly on the border line). Dropping the border for the ring instead left
three quarters of the popover with no edge at all, which read as LESS
defined than an ordinary step — the opposite of the point. */
.driver-popover.tour-pop-accent::before {
animation: tour-arc-spin 4.5s linear infinite;
background: conic-gradient(
from var(--tour-arc-angle),
transparent 0deg,
var(--ui-accent-secondary) 70deg,
transparent 150deg
);
border-radius: inherit;
content: '';
inset: -1px;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
/* Arc thickness. Sits on the hairline the border above gave up. */
padding: 1.5px;
pointer-events: none;
position: absolute;
}
@keyframes tour-arc-spin {
to {
--tour-arc-angle: 360deg;
}
}
/* Still lit, no travel — the ring reads as emphasis on its own. */
@media (prefers-reduced-motion: reduce) {
.driver-popover.tour-pop-accent::before {
animation: none;
background: var(--ui-accent-secondary);
}
}
+5 -1
View File
@@ -56,6 +56,10 @@ export interface TourHolder {
* supplies the actual navigation (see TourHost), so the engine itself stays
* self-contained and portable to a guest page. */
export interface TourStep {
/** Draw this step accent-lit: a slowly travelling ring around the popover
* instead of the usual hairline. For the moment in a run that is worth more
* than a step — used sparingly, or it stops meaning anything. */
accent?: boolean
navigate?: string
pane?: string
selector?: string
@@ -116,7 +120,7 @@ export function runTourEngine(
step.title || step.text
? {
description: step.text || '',
popoverClass: first ? 'tour-pop-in' : 'tour-pop-next',
popoverClass: (first ? 'tour-pop-in' : 'tour-pop-next') + (step.accent ? ' tour-pop-accent' : ''),
side: step.side || undefined,
title: step.title || ''
}
+101 -3
View File
@@ -10,9 +10,19 @@ import type { ReactNode } from 'react'
* fences get promoted whether or not the model asked), directives are
* addressed (nothing renders unless a plugin claimed the name).
*
* The parse is deliberately narrow — a directive must be the entire
* paragraph, so it can never hijack mid-prose text, and an unclaimed or
* malformed directive falls back to the plain paragraph it always was.
* The product parser requires the entire paragraph to be one directive, so
* mid-prose text and malformed or unclaimed directives stay prose.
*
* For the guided chat segmenter, the guard is the CLAIM, not its position:
* a name nobody registered — and a malformed one — stays exactly the text it
* always was. Position used to be the guard too (a directive had to be the
* whole paragraph), and that cost more than it bought: a model that wrote the
* directive at the end of its sentence instead of alone under it put raw
* `::onboarding{step="look"}` in front of the user AND swallowed the card,
* which on a step whose card is the only way forward stops the conversation
* dead. So a directive is recognised wherever it starts a word, and the
* paragraph around it keeps rendering as prose.
*
* Attributes are untrusted model output: plugins validate their own fields.
*/
@@ -44,10 +54,18 @@ export interface ParsedTranscriptDirective {
source: string
}
export type TranscriptParagraphSegment =
| { kind: 'prose'; text: string }
| { kind: 'directive'; directive: ParsedTranscriptDirective }
// The whole paragraph, nothing else on the line: `::name` or `::name{...}`.
// Length caps bound the attr scan on adversarial input.
const DIRECTIVE_RE = /^::([a-z][a-z0-9-]{0,63})(?:\{([^{}]{0,1024})\})?$/
// `::name` or `::name{...}`, anywhere a word can start — so `std::vector` is
// never a directive. Length caps bound the attr scan on adversarial input.
const SEGMENT_RE = /(?<=^|\s)::([a-z][a-z0-9-]{0,63})(?:\{([^{}]{0,1024})\})?/g
// `key="value"` pairs; single quotes accepted for model sloppiness.
const ATTR_RE = /([a-z][\w-]{0,63})=(?:"([^"]*)"|'([^']*)')/gi
@@ -80,3 +98,83 @@ export function parseTranscriptDirective(text: string): ParsedTranscriptDirectiv
return { name: match[1], attrs, source: trimmed }
}
function parseAttrs(body: string | undefined): ParsedTranscriptDirective['attrs'] {
const attrs: Record<string, string> = {}
for (const pair of (body ?? '').matchAll(ATTR_RE)) {
attrs[pair[1].toLowerCase()] = pair[2] ?? pair[3] ?? ''
}
return attrs
}
/**
* True when a STILL-STREAMING paragraph should be withheld as a directive in
* progress. Deltas land ~3 chars at a time, and `::ask{question="Wha` cannot
* parse until the final `}` lands — exactly the window where raw directive
* text used to flash. A lone `:` is the same line one delta earlier. The
* check covers the paragraph-leading case (the authored shape for onboarding
* cards); a directive a model appends mid-sentence streams as prose until it
* completes, which reads as ordinary typing rather than leaked markup.
*
* Only ever consult this while the message is streaming: a SETTLED paragraph
* that starts with `::` but doesn't parse is an authoring bug the user should
* see as text, and callers must keep that behavior.
*/
export function isDirectiveInProgress(text: string): boolean {
const trimmed = text.trimStart()
return trimmed === ':' || trimmed.startsWith('::')
}
/**
* Split a paragraph into its prose runs and the directives embedded in them,
* in the order they were written. Null when it holds no directive at all.
*
* Pure and synchronous — safe to call during render. Deciding which of these
* are real is the caller's job: only a claimed name becomes a card, so an
* unregistered `::whatever` is folded straight back into the prose it came in.
*/
export function segmentTranscriptDirectives(text: string): TranscriptParagraphSegment[] | null {
if (!text.includes('::') || text.length > 4800) {
return null
}
const out: TranscriptParagraphSegment[] = []
let cursor = 0
SEGMENT_RE.lastIndex = 0
for (const match of text.matchAll(SEGMENT_RE)) {
const start = match.index ?? 0
// A brace the attr group refused (unclosed, or past the length cap) means
// the name matched but its attributes did not. Half of a directive is not
// one: render a card with the attributes silently dropped and it is broken
// in a way nobody can see. Leave the whole thing as the text it is.
if (match[2] === undefined && text[start + match[0].length] === '{') {
continue
}
if (start > cursor) {
out.push({ kind: 'prose', text: text.slice(cursor, start) })
}
out.push({
kind: 'directive',
directive: { name: match[1], attrs: parseAttrs(match[2]), source: match[0] }
})
cursor = start + match[0].length
}
if (out.length === 0) {
return null
}
if (cursor < text.length) {
out.push({ kind: 'prose', text: text.slice(cursor) })
}
return out
}
+23
View File
@@ -51,12 +51,35 @@ if (winParam === 'hud') {
document.title = 'Hermes HUD'
}
// The `?win=` kinds whose Electron window is `transparent: true` and so paints
// nothing but its own surface over the user's desktop. `secondary` (a session
// window) and `browser` are ordinary opaque windows and are deliberately not
// in here. index.html's pre-paint script skips exactly this list — keep the
// two in step.
const TRANSPARENT_WINDOWS = new Set(['hud', 'overlay', 'quick', 'wake', 'intro'])
// Each transparent root used to force its host layers see-through when it
// MOUNTED. That is far too late: `styles.css` above paints the theme's opaque
// `--background` as soon as it lands, and the root behind it is a dynamic
// import — a couple of seconds of module fetches under the dev server. The gap
// rendered as a full-screen near-white rectangle. Claim it here instead, in the
// same task as the stylesheet, so no window ever paints a background it does
// not want.
if (winParam && TRANSPARENT_WINDOWS.has(winParam)) {
const transparent = document.createElement('style')
transparent.textContent = 'html,body,#root{background:transparent !important;}'
document.head.appendChild(transparent)
}
if (winParam === 'overlay') {
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
} else if (winParam === 'quick') {
void import('./app/quick-entry/quick-entry-root').then(({ mountQuickEntry }) => mountQuickEntry())
} else if (winParam === 'wake') {
void import('./app/wake-indicator/wake-indicator-root').then(({ mountWakeIndicator }) => mountWakeIndicator())
} else if (winParam === 'intro') {
void import('./components/intro-reveal/intro-root').then(({ mountIntroReveal }) => mountIntroReveal())
} else {
// CSS animations do not inherit Chromium's JS-loop pause policy. Mirror the
// main window's visibility state to :root so decorative infinite
+17
View File
@@ -22,6 +22,7 @@ import { atom, computed, type ReadableAtom } from 'nanostores'
import type { ReactNode } from 'react'
import { capabilityScoped } from '@/api/client'
import { requestComposerSubmit } from '@/app/chat/composer/focus'
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
import { openSession, type OpenSessionIntent } from '@/app/open-session'
import type { ClientSessionState } from '@/app/types'
@@ -1261,6 +1262,22 @@ export const host = {
* (`typeof host.paneVisibility === 'function'`). */
paneVisibility: (paneId: string): ReadableAtom<boolean> => $paneVisible(paneId),
/** Reveal a contributed pane and its zone from an explicit user action. */
revealPane: (paneId: string): void => {
const id = (paneId ?? '').trim()
if (!id) {
return
}
revealTreePane(id)
},
/** Submit through the active composer; false means no surface claimed it.
* Hidden prompts omit the user bubble. */
submitPrompt: (text: string, options: { hidden?: boolean } = {}): boolean =>
requestComposerSubmit(text, options.hidden ? { displayKind: 'hidden' } : {}),
/** HEAR the gateway stream (message deltas, session lifecycle, tool
* activity, …) by event type — `'*'` for everything. Returns a disposer.
* Listeners are isolated; a throw can't affect app dispatch. */
+253
View File
@@ -0,0 +1,253 @@
import { atom } from 'nanostores'
import { connectorAuthorizationUrl, type ConnectorRow, recordOf } from '@/lib/connector-tools'
export type ConnectorPhase = 'idle' | 'opening' | 'waiting' | 'connected' | 'timeout' | 'error' | 'skipped'
export interface ConnectorFlowRow extends ConnectorRow {
phase: ConnectorPhase
error?: string
}
export interface ConnectorFlowState {
loading: boolean
available: boolean
rows: ConnectorFlowRow[]
error?: string
}
export interface ConnectorFlowDeps {
request: <T>(method: string, params: { session_id: string; connectors?: string[]; reconnect?: boolean }) => Promise<T>
open: (url: string) => Promise<void>
delay?: () => Promise<void>
now?: () => number
}
/** One mounted tool offer. Only explicit user actions can mint or open links. */
export function createConnectorFlow(sessionId: string, seeds: ConnectorRow[], deps: ConnectorFlowDeps) {
const state = atom<ConnectorFlowState>({
loading: true,
available: false,
rows: seeds.map(row => ({ ...row, phase: 'idle', connected: false }))
})
let disposed = false
let refreshGeneration = 0
const attempts = new Map<string, number>()
const delay = deps.delay ?? (() => new Promise(resolve => setTimeout(resolve, 2000)))
const now = deps.now ?? Date.now
const valid = (slug: string, token: number) => !disposed && attempts.get(slug) === token
const update = (slug: string, changes: Partial<ConnectorFlowRow>) => {
if (disposed) {
return
}
refreshGeneration += 1
const current = state.get()
state.set({ ...current, rows: current.rows.map(row => (row.connector === slug ? { ...row, ...changes } : row)) })
}
const list = async () => {
const response = await deps.request<{ available: boolean; connectors: ConnectorRow[] }>('connectors.list', {
session_id: sessionId
})
if ((response.available !== true && response.available !== false) || !Array.isArray(response.connectors)) {
throw new Error('Invalid connector status response')
}
return response
}
const refresh = async () => {
disposed = false
const generation = ++refreshGeneration
try {
const response = await list()
if (disposed || generation !== refreshGeneration) {
return
}
const current = state.get()
// The tool may return the whole catalog or a few requested apps. Do not
// replace a targeted offer with every app the gateway happens to know.
const wanted = seeds.length ? seeds : response.connectors
const rows = wanted.map((seed): ConnectorFlowRow => {
const live = response.connectors.find(row => row.connector === seed.connector)
const previous = current.rows.find(row => row.connector === seed.connector)
const phase = previous?.phase ?? 'idle'
return {
...seed,
...live,
connector: seed.connector,
enabled: response.available && live?.enabled !== false && !!live,
connected: live?.connected === true,
phase:
phase === 'skipped'
? phase
: live?.connected
? 'connected'
: ['opening', 'waiting'].includes(phase)
? phase
: 'idle'
}
})
state.set({ loading: false, available: response.available, rows })
} catch {
if (!disposed && generation === refreshGeneration) {
state.set({ ...state.get(), loading: false, error: 'status' })
}
}
}
const wait = async (slug: string, token: number) => {
const deadline = now() + 120000
let failures = 0
while (valid(slug, token) && now() < deadline) {
await delay()
if (!valid(slug, token)) {
return
}
try {
const response = await list()
if (!valid(slug, token)) {
return
}
if (!response.available) {
update(slug, { phase: 'error', error: 'unavailable' })
return
}
const row = response.connectors.find(row => row.connector === slug)
if (row?.connected) {
update(slug, { ...row, phase: 'connected', error: undefined })
return
}
if (row?.enabled === false || !row) {
update(slug, { phase: 'error', error: 'unavailable' })
return
}
failures = 0
} catch {
if (++failures >= 3) {
update(slug, { phase: 'error', error: 'status' })
return
}
}
}
if (valid(slug, token)) {
update(slug, { phase: 'timeout' })
}
}
const connect = async (slug: string) => {
const row = state.get().rows.find(row => row.connector === slug)
if (
disposed ||
!state.get().available ||
state.get().error ||
!row ||
row.enabled === false ||
['opening', 'waiting'].includes(row.phase)
) {
return
}
const token = (attempts.get(slug) ?? 0) + 1
attempts.set(slug, token)
update(slug, { phase: 'opening', error: undefined })
try {
const response = await deps.request<{ results: unknown[] }>('connectors.connect', {
session_id: sessionId,
connectors: [slug],
reconnect: ['expired', 'revoked'].includes(row.connectionStatus ?? '')
})
if (!valid(slug, token)) {
return
}
const entry = (response.results ?? []).map(recordOf).find(result => result.connector === slug)
if (entry?.status === 'active') {
update(slug, { phase: 'idle' })
await refresh()
return
}
const url = connectorAuthorizationUrl(entry?.connect_url)
if (entry?.status !== 'initiated' || !url) {
throw new Error('Authorization unavailable')
}
await deps.open(url)
if (!valid(slug, token)) {
return
}
update(slug, { phase: 'waiting' })
await wait(slug, token)
} catch {
if (valid(slug, token)) {
update(slug, { phase: 'error', error: 'connect' })
}
}
}
return {
state,
refresh,
connect,
keepWaiting: async (slug: string) => {
const row = state.get().rows.find(row => row.connector === slug)
if (
disposed ||
!row ||
row.enabled === false ||
!state.get().available ||
!['timeout', 'error'].includes(row.phase)
) {
return
}
const token = (attempts.get(slug) ?? 0) + 1
attempts.set(slug, token)
update(slug, { phase: 'waiting', error: undefined })
await wait(slug, token)
},
skip: (slug: string) => {
attempts.set(slug, (attempts.get(slug) ?? 0) + 1)
update(slug, { phase: 'skipped', error: undefined })
},
dispose: () => {
disposed = true
refreshGeneration += 1
for (const [slug, token] of attempts) {
attempts.set(slug, token + 1)
}
}
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { atom } from 'nanostores'
import { onboardingSurfaceActive } from '@/store/onboarding-presence'
import type { FreeTierStatus } from '@/types/hermes'
/** The model the free-tier route runs on. Used to recognise a session that is
@@ -98,7 +99,7 @@ export function freeTierReadyPending(status: FreeTierStatus | null, route: boole
* instead, so the two can never both be on screen.
*/
export function freeTierStripPending(status: FreeTierStatus | null, route: boolean | null): boolean {
return freeTierNoticePending(status) && route === false
return freeTierNoticePending(status) && route === false && !onboardingSurfaceActive()
}
// Several composers can be mounted at once (split zones, a popout mid-dock).
+68
View File
@@ -0,0 +1,68 @@
import { atom } from 'nanostores'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { readKey, writeKey } from '@/lib/storage'
import { setOnboardingSurfaceActive } from '@/store/onboarding-presence'
const SEEN_KEY = 'hermes-intro-reveal-seen-v1'
export type IntroRevealPhase = 'hidden' | 'playing' | 'leaving'
export interface IntroRevealState {
phase: IntroRevealPhase
}
const INITIAL: IntroRevealState = { phase: 'hidden' }
export const $introReveal = atom<IntroRevealState>(INITIAL)
$introReveal.subscribe(state => setOnboardingSurfaceActive('intro', state.phase !== 'hidden'))
export function hasSeenIntroReveal(): boolean {
return readKey(SEEN_KEY) === '1'
}
export function isIntroRevealEnabled(): boolean {
return isOnboardingEnabled()
}
export function shouldPlayFirstRunIntro(firstRunSkipped: boolean): boolean {
return isIntroRevealEnabled() && !firstRunSkipped && !hasSeenIntroReveal()
}
export function startIntroReveal(): void {
if (!isIntroRevealEnabled() || $introReveal.get().phase !== 'hidden') {
return
}
$introReveal.set({ phase: 'playing' })
// The film plays over the desktop; every exit path must restore the app.
void window.hermesDesktop?.introReveal?.open({ hideMain: true }).catch(finishIntroReveal)
}
export function leaveIntroReveal(): void {
if ($introReveal.get().phase === 'playing') {
$introReveal.set({ phase: 'leaving' })
}
}
export function finishIntroReveal(): void {
if ($introReveal.get().phase === 'hidden') {
return
}
writeKey(SEEN_KEY, '1')
$introReveal.set(INITIAL)
void window.hermesDesktop?.introReveal?.close({ showMain: true }).catch(() => undefined)
}
export function installIntroRevealBridgeListeners(): () => void {
const bridge = window.hermesDesktop?.introReveal
const offSkip = bridge?.onSkip(leaveIntroReveal)
const offClosed = bridge?.onClosed(finishIntroReveal)
return () => {
offSkip?.()
offClosed?.()
}
}
+190
View File
@@ -0,0 +1,190 @@
/**
* What Hermes knows about the computer it was just installed on.
*
* One question, really: is this machine new? A brand-new computer makes "set
* this thing up for me" the likeliest first task rather than one option among
* several — drivers, updates, a package manager, the tools they just told us
* they use — and it is a task Hermes can do end to end with no account
* anywhere, which is exactly what the first build has to be.
*
* A Spark answers yes on its own, without waiting for the account age to say
* so; see machineIsSpark.
*
* Loaded once, before the guided chat's runbook is composed.
*/
import { atom } from 'nanostores'
import type { DesktopMachineProfile } from '@/global'
/** A computer this young is almost certainly still being set up. Wide enough
* to cover the week someone spends getting around to it, short enough that a
* machine in daily use never trips it. */
const NEW_MACHINE_DAYS = 21
export const $machine = atom<DesktopMachineProfile | null>(null)
export async function loadMachineProfile(): Promise<void> {
if ($machine.get()) {
return
}
const profile = await window.hermesDesktop?.getMachineProfile?.().catch(() => null)
if (profile) {
$machine.set(profile)
}
}
/** Unknown counts as not-new: the option is always offered, it just doesn't
* lead unless we can see a reason for it to. */
export function machineLooksNew(): boolean {
const age = $machine.get()?.ageDays
return age != null && age <= NEW_MACHINE_DAYS
}
/** Login names that are not a name. 'akp' suggests fine; 'user' does not. */
const NON_NAME_USERNAMES = new Set([
'admin',
'administrator',
'default',
'guest',
'me',
'owner',
'root',
'test',
'user'
])
/** A suggestable name for the guided chat's first question: the OS account
* name, when it actually looks like something you could be called. The login
* handle is a hint, never a truth — the greeting offers it as a default and
* the user still picks. Null means no suggestion; the guide just asks. */
export function machineUserName(): string | null {
const raw = ($machine.get()?.username ?? '').trim()
if (raw.length < 2 || raw.length > 20) {
return null
}
return NON_NAME_USERNAMES.has(raw.toLowerCase()) ? null : raw
}
/** The OS display language, as an English language name the model can act on
* ("Japanese", "Brazilian Portuguese"). Null when the machine hasn't
* answered, or when it is already English.
*
* Deliberately NOT limited to the six locales the UI ships: the chrome can
* only be translated where a bundle exists, but the MODEL speaks whatever the
* user does, and a Portuguese speaker being answered in Portuguese matters
* more than the sidebar labels matching. `Intl.DisplayNames` turns the raw
* tag into the name of a language rather than a code the model has to
* decode — and its own fallback ('code') hands back the tag if it doesn't
* know it either, which is still a better instruction than nothing. */
export function machineLanguageName(): string | null {
const tag = ($machine.get()?.locale ?? '').trim()
if (!tag || /^en\b/i.test(tag)) {
return null
}
try {
const name = new Intl.DisplayNames(['en'], { fallback: 'code', type: 'language' }).of(tag)
return name && name.toLowerCase() !== 'english' ? name : null
} catch {
return null
}
}
/** A Spark, either kind. Neither is a machine anyone owns for its own sake —
* both are bought to be set up — so one takes the front of the flow whatever
* its account age says.
*
* The two are identified differently because they are different computers.
* An **RTX Spark** is a Windows-on-Arm PC (the N1X superchip, in this fall's
* ASUS / Dell / HP / Lenovo / Surface / MSI laptops and mini desktops); the
* OEM badge on the case is not a name we can enumerate, so it is recognised
* by its shape — Windows, Arm, NVIDIA silicon, a combination nothing else
* currently ships. A **DGX Spark** is the Linux GB10 developer box, and it
* says so in the device tree.
*
* On that string, underscores are separators rather than letters: a real unit
* reports `NVIDIA_DGX_Spark`, which \b reads as ONE word and would never
* match. */
export function machineIsSpark(): boolean {
const profile = $machine.get()
if (!profile) {
return false
}
const rtx = profile.platform === 'win32' && profile.arch === 'arm64' && profile.nvidia
const dgx = /\b(dgx|spark|gb10)\b/i.test(profile.model.replace(/_/g, ' '))
return rtx || dgx
}
/** True when setting the machine up should be the only thing on offer, with
* everything else folded away behind one more tap. */
export function machineSetupLeads(): boolean {
return machineIsSpark() || machineLooksNew()
}
/** What the user calls the thing in front of them. */
export function machineKind(): string {
if (machineIsSpark()) {
return 'Spark'
}
switch ($machine.get()?.platform) {
case 'darwin':
return 'Mac'
case 'win32':
return 'PC'
default:
return 'computer'
}
}
/** One line for the machine-setup brief, so the agent that picks the job up
* starts knowing what it is looking at instead of asking.
*
* Age leads, because it is the fact that changes the work: on a machine
* someone unboxed this week the drivers, updates and toolchain are genuinely
* undone, and doing them is worth an afternoon of the user's life. On a
* machine that has been running for two years most of it is already handled,
* and an agent that doesn't know that will "fix" things that were never
* broken. */
export function machineDescription(): string {
const profile = $machine.get()
if (!profile) {
return ''
}
return [
machineLooksNew() ? `set up ${daysAgo(profile.ageDays)}` : '',
machineIsSpark() ? 'an NVIDIA Spark' : profile.nvidia ? 'has an NVIDIA GPU' : '',
profile.model,
`${profile.platform} ${profile.release}`,
profile.arch
]
.filter(Boolean)
.join(', ')
}
function daysAgo(days: null | number): string {
if (days === 0) {
return 'today'
}
return days === 1 ? 'yesterday' : `${days} days ago`
}
export function resetMachineProfileForTests(): void {
$machine.set(null)
}
@@ -0,0 +1,45 @@
import { atom } from 'nanostores'
import { readJson, writeJson } from '@/lib/storage'
export interface OnboardingAnswers {
accent: null | string
connectors: string[]
context: string
name: string
layout: string
}
// Keep existing fork users' answers when they move to upstream.
export const ANSWERS_KEY = 'hermes-onboarding-wizard-answers-v1'
export const DEFAULT_ANSWERS: OnboardingAnswers = {
accent: null,
connectors: [],
context: '',
name: '',
layout: 'basic'
}
export function loadAnswers(): OnboardingAnswers {
const raw = readJson<Partial<OnboardingAnswers>>(ANSWERS_KEY)
// Project the retained fields so retired wizard preferences cannot be sent
// to personalization or written back on the next answer.
return {
accent: raw?.accent ?? DEFAULT_ANSWERS.accent,
connectors: raw?.connectors ?? [...DEFAULT_ANSWERS.connectors],
context: raw?.context ?? DEFAULT_ANSWERS.context,
name: raw?.name ?? DEFAULT_ANSWERS.name,
layout: raw?.layout ?? DEFAULT_ANSWERS.layout
}
}
export const $onboardingAnswers = atom<OnboardingAnswers>(loadAnswers())
export function setOnboardingAnswers(patch: Partial<OnboardingAnswers>): void {
const next = { ...$onboardingAnswers.get(), ...patch }
$onboardingAnswers.set(next)
writeJson(ANSWERS_KEY, next)
}
+137
View File
@@ -0,0 +1,137 @@
import { atom } from 'nanostores'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { readKey, writeKey } from '@/lib/storage'
import { hasSeenIntroReveal } from './intro-reveal'
import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers'
const PHASE_KEY = 'hermes-onboarding-phase-v1'
export type OnboardingPhase = 'idle' | 'cinematic' | 'guided' | 'handoff' | 'done'
export interface OnboardingGateState {
phase: OnboardingPhase
guideQueued: boolean
}
type GuideKickoff = { status: 'idle' } | { status: 'starting'; promise: Promise<boolean> } | { status: 'started' }
function loadGate(): OnboardingGateState {
const saved = readKey(PHASE_KEY)
const phase =
isOnboardingEnabled() && (saved === 'cinematic' || saved === 'guided' || saved === 'handoff' || saved === 'done')
? saved
: 'idle'
return { phase, guideQueued: phase === 'cinematic' && hasSeenIntroReveal() }
}
export const $onboardingGate = atom<OnboardingGateState>(loadGate())
let guideKickoff: GuideKickoff = { status: 'idle' }
function setPhase(phase: OnboardingPhase): void {
writeKey(PHASE_KEY, phase === 'idle' ? null : phase)
$onboardingGate.set({ phase, guideQueued: false })
}
export function beginOnboardingFlow(): void {
if (isOnboardingEnabled() && $onboardingGate.get().phase === 'idle' && !hasSeenIntroReveal()) {
setPhase('cinematic')
}
}
export function queueGuideAfterIntro(): void {
const state = $onboardingGate.get()
if (isOnboardingEnabled() && state.phase === 'cinematic' && !state.guideQueued && hasSeenIntroReveal()) {
$onboardingGate.set({ ...state, guideQueued: true })
}
}
/** The kickoff returns true only after the guided session's seed is durable. */
export function runGuideKickoff(kickoff: () => Promise<boolean>): Promise<boolean> {
if (!isOnboardingEnabled()) {
return Promise.resolve(false)
}
if (guideKickoff.status === 'starting') {
return guideKickoff.promise
}
if (guideKickoff.status === 'started') {
return Promise.resolve(true)
}
if (!$onboardingGate.get().guideQueued) {
return Promise.resolve(false)
}
// Defer the callback until the shared promise is installed, including for
// callers that re-enter synchronously while starting the session.
const promise = Promise.resolve()
.then(kickoff)
.then(
started => {
guideKickoff = { status: started ? 'started' : 'idle' }
if (started && $onboardingGate.get().phase === 'cinematic') {
setPhase('guided')
}
return started
},
error => {
guideKickoff = { status: 'idle' }
throw error
}
)
guideKickoff = { status: 'starting', promise }
return promise
}
export function beginOnboardingHandoff(): void {
if (isOnboardingEnabled() && $onboardingGate.get().phase === 'guided') {
setPhase('handoff')
}
}
/** Called when the handoff receipt is accepted. */
export function completeOnboardingFlow(): void {
if (isOnboardingEnabled() && $onboardingGate.get().phase === 'handoff') {
setPhase('done')
}
}
export function skipGuide(): void {
const { phase } = $onboardingGate.get()
if (isOnboardingEnabled() && (phase === 'cinematic' || phase === 'guided')) {
setPhase('done')
}
}
export function devResetOnboardingFlow(): void {
if (!import.meta.env.DEV) {
return
}
guideKickoff = { status: 'idle' }
setPhase('idle')
setOnboardingAnswers({ ...DEFAULT_ANSWERS, connectors: [...DEFAULT_ANSWERS.connectors] })
}
declare global {
interface Window {
__onboarding?: { reset: typeof devResetOnboardingFlow }
}
}
if (import.meta.env.DEV) {
window.__onboarding = { reset: devResetOnboardingFlow }
}
@@ -0,0 +1,46 @@
/**
* Onboarding presence — one tiny dependency-free flag the first-run surfaces
* raise while they own the screen (intro cinematic and guided solo chat).
*
* Exists so leaf infrastructure (the update toast, future ambient
* notifications) can ask "is onboarding on screen?" WITHOUT importing the
* feature stores — updates.ts pulling intro-reveal/assembly
* dragged their whole import chains into every test that mocks around it.
* Surfaces push state in; consumers read a boolean out.
*/
import { atom } from 'nanostores'
export type OnboardingSurface = 'intro' | 'solo-chat'
const EMPTY: ReadonlySet<OnboardingSurface> = new Set()
export const $onboardingSurfaces = atom<ReadonlySet<OnboardingSurface>>(EMPTY)
export function setOnboardingSurfaceActive(surface: OnboardingSurface, active: boolean): void {
const current = $onboardingSurfaces.get()
if (current.has(surface) === active) {
return
}
const next = new Set(current)
if (active) {
next.add(surface)
} else {
next.delete(surface)
}
$onboardingSurfaces.set(next.size === 0 ? EMPTY : next)
}
/** True while any first-run surface owns the screen. */
export function onboardingSurfaceActive(): boolean {
return $onboardingSurfaces.get().size > 0
}
/** Hard reset for tests. */
export function resetOnboardingPresenceForTests(): void {
$onboardingSurfaces.set(EMPTY)
}
+230
View File
@@ -0,0 +1,230 @@
/**
* The words Hermes says during the guided first run.
*
* Everything here is script, not state: the pre-banked greeting, the runbook
* the model is handed at session.create, its persona, and the option pills the
* runbook pins EXACTLY (a model that invents a pill strands the user, since
* nothing downstream can interpret one the script never defined).
*
* Kept apart from the answers store on purpose — this is the file that gets
* re-read and re-tuned by hand, and it should not mean scrolling past a state
* machine to find it.
*/
import { machineKind, machineLanguageName, machineSetupLeads, machineUserName } from '@/store/machine'
const VOICE_RULES =
'Voice rules for EVERYTHING you write: plain declaratives in active voice. No em dashes (use commas or periods). No exclamation marks. Never praise the user. No AI diction (delve, seamless, robust, crucial, pivotal, landscape, testament, elevate, empower). No "not just X, it\'s Y" constructions. No forced lists of three. No generic closers ("you\'re all set", "happy to help", "the future looks bright") — end on the last real point. Contractions are fine. Specifics over adjectives.'
/** How Hermes talks for the whole of the first run — the guided chat and the
* build session it hands off to. One constant because it was two, written by
* hand in two files, already drifted, and it is the line that gets re-tuned
* most often. */
export const PLAIN_SPEECH = `${VOICE_RULES} Keep every turn short. This is a chat, not a form: no headers, no bullet lists, no emoji, no restating their answer back at them before you reply to it, and none of "Great choice", "Perfect!", "Absolutely", "Certainly", "Great question", "Let me go ahead and". Read each line back as if you were saying it out loud to someone sitting beside you — say the thing itself, not a description of the thing. If it sounds like a form letter or a support macro, write it again.`
/** The seed rows for the guided chat's session.create: the invisible runbook
* (model-visible, never rendered) followed by the pre-written greeting.
* Pass the banked greeting the client is typing in (pickOnboardingGreeting)
* so the canonical row and the animated reveal are the same words. */
export function buildChatOnboardingSeedMessages(
greeting: string,
signedIn = false
): {
content: string
display_kind?: 'hidden'
role: 'assistant' | 'user'
}[] {
return [
{ content: buildChatOnboardingPrompt(machineUserName(), signedIn), display_kind: 'hidden', role: 'user' },
{ content: greeting, role: 'assistant' }
]
}
const FORK_QUESTION = "Know what you'd like it to make?"
/** The fork's pills. Held as data because the runbook pins them EXACTLY — a
* model that invents an option strands the user, since the app can't
* interpret a pill the script never defined. */
const FORK_OPTIONS = {
automate: 'Automate something I already do',
figure: "Let's figure it out together",
mind: 'I have something in mind',
skip: 'Skip this for now'
} as const
/** "Help me set up this Spark" / "…this Mac" — named as the thing in front of
* them, because being recognised is the whole trick. */
export function machineForkOption(): string {
return `Help me set up this ${machineKind()}`
}
const SOMETHING_ELSE = 'Something else'
/** The look-around offer, placed the turn after the layout lands — the first
* moment there is an app to look AT. Before the layout pick the window is
* the conversation and nothing else, so a tour there would highlight a chat
* pane and stop. Held as data for the same reason the fork is: the script
* pins these three exactly. */
const TOUR_QUESTION = 'Want a look around first?'
/** Lightest first. Both of the first two run the tour — the difference is three
* steps against six — and the short one reads as the easy answer when it is
* the one their eye lands on, leaving the full look around as the deliberate
* step up rather than the default. Nobody wants to open a new app into a
* click-through, but three highlighted buttons with a line each beats three
* lines of prose describing buttons the user then has to go find. */
export const TOUR_OPTIONS = {
basics: 'Just the basics',
none: "I'll figure it out",
tour: 'Show me around'
} as const
/**
* Who the user is talking to.
*
* The rest of the runbook is mechanics and the voice rules are prohibitions,
* and prohibitions can only ever remove things. Stack "no exclamation marks,
* never praise the user, no closers, plain declaratives, short sentences" with
* nothing pulling the other way and you get a competent stranger reading out a
* form — which is exactly what the first draft of this flow sounded like.
*
* So this says who is talking, positively, and shows it rather than naming it:
* the contrast pairs do more work than any adjective, because "be warm" is
* unfalsifiable and "you mentioned Notion earlier" is not. Warmth here lives in
* paying attention and in rhythm, never in punctuation or compliments — the
* anti-slop rules still hold, and a chirpy Hermes would be worse than a flat
* one.
*/
const PERSONA = [
'WHO YOU ARE, in voice: the person at the front desk of somewhere good. Pleased they walked in, and not performing it. Quick, unhurried, never flustered. You make the next thing easy without making a production of it. You have opinions and you offer them lightly ("most people go with the second one"). You remember what they said and use it two beats later instead of repeating it back at them. A little dry humour is welcome when it lands on its own; never reach for it.',
'What that is NOT: chirpy, eager, apologetic, or formal. Do not thank them for answering. Do not tell them their choice was a good one. Do not announce what you are about to do before doing it. Do not ask if they are ready.',
'The feel of it, concretely. Say "Nice, that suits the rest of it." not "Great choice!". Say "Two seconds, I am moving things around you." not "I will now configure your workspace." Say "You said Notion earlier, so I will keep that one in mind." not "Thank you for sharing that you use Notion." Say "Right, what are we making." not "Now let us move on to the next step."',
'You are allowed to be brief to the point of terse when the moment is just a card and a nudge. Most of these turns are one sentence. That is not coldness, it is not wasting their time, and it is the main way this reads as a person rather than a wizard.'
] as const
/** The cards that hand control to the user, and so end the turn that places
* one. Named in RULE 3 rather than left implicit: a fast model reading a
* numbered list reads it as a script to perform, and will happily ask for
* their colour and their tools in the same breath — which puts two live cards
* on screen, each waiting on an answer the other one is covering up. */
const QUESTION_CARDS = ['look', 'connectors', 'layout', 'first', 'handoff'].map(step => `::onboarding{step="${step}"}`)
/** Setting the machine up is always on offer: it is a first task Hermes can do
* end to end with no account anywhere, and the one everybody with a new
* computer already wants.
*
* On a machine that is new — or on a Spark, which nobody owns for its own
* sake — it is the ONLY thing on offer, with the rest folded behind one more
* tap. Four alternatives beside the obvious answer is a menu; the obvious
* answer plus a way out is an offer. */
export function forkOptions(): string[] {
const { automate, figure, mind, skip } = FORK_OPTIONS
return machineSetupLeads()
? [machineForkOption(), SOMETHING_ELSE]
: [mind, automate, machineForkOption(), figure, skip]
}
/** The second tier — what "Something else" opens onto. Empty when the fork
* already listed everything. */
export function forkFallbackOptions(): string[] {
const { automate, figure, mind, skip } = FORK_OPTIONS
return machineSetupLeads() ? [mind, automate, figure, skip] : []
}
export function buildChatOnboardingPrompt(suggestedName?: string | null, signedIn = false): string {
const kind = machineKind()
const machine = machineForkOption()
const fallback = forkFallbackOptions()
const language = machineLanguageName()
return [
"You are Hermes, and this is a brand-new user's very first conversation with you. Your job right now is to get the app arranged around them and their first real job started.",
...PERSONA,
// The machine's own language, not a guess from what they typed: this has
// to hold on the FIRST turn, which answers a one-word name and carries no
// signal at all. They can switch by simply writing in another language —
// an OS setting is strong evidence, never an instruction to ignore them.
...(language
? [
`This computer is set to ${language}, so write every visible word to them in ${language} — starting now, including the option pills you place. The greeting they have already seen was in ${language} too. If they write to you in a different language, follow THEM from that point on. Everything below describes what to say, not which language to say it in; the ::onboarding and ::ask directive names, their attribute names, and the exact option values pinned below stay verbatim in English because the app matches on them.`
]
: []),
'Never call yourself "Setup", "the setup assistant", "the onboarding guide", or anything like it, and never say you are "not the agent" — you are Hermes, one thing, talking to them.',
'This message is invisible to them — never reference it or the mechanics described here.',
'FOUR ABSOLUTE RULES ABOVE EVERYTHING:',
'RULE 1 — never think out loud. Every visible word you write is spoken TO the user. Never write "Let me check/re-read/reconsider", never recap what step you are on, never mention steps, directives, [setup], prompts, or any mechanics in visible text. When you use tools, visible text is at most ONE short sentence to the user before the work and one after. Planning happens silently or not at all — a message that narrates your process instead of talking to the user is a failure.',
'RULE 2 — images are welcome but never a surprise and never a delay: deliver the TEXT deliverable first, and only then, when a visual genuinely helps (a header image for an announcement, a mock for a page), you may generate ONE image — always introduced with a short line naming what you made and why ("I generated a header image for the announcement — swap or drop it"). Never let image generation stall or replace the text answer, never more than one per turn, and never for plain lists, plans, or checklists.',
`RULE 3 — ONE question per turn, then stop. These hand control back to the user and END your turn the moment you write one: ${QUESTION_CARDS.join(', ')}, and every ::ask. Place exactly one, then stop: never ask the next thing in the same message, and never tell them what is coming. Their answer arrives as the next message, and that is what moves you forward. Two questions in one message is a failure: you asked something whose answer you have not heard yet, and they are looking at two half-answered cards stacked on top of each other. (::onboarding{step="name"} and ::onboarding{step="working"} are NOT questions — they render as nothing and only save what the user just told you, so they belong in the same turn as the question that follows them.)`,
// RULE 4 exists because of a live run: the user typed "brooke" and the
// model spent SIX API calls and thirty-six seconds writing the same fact to
// memory over and over, saying "Brooke it is." between each one, and never
// reached the colour card. Nothing told it the save was already done, and a
// returning tool result reads to a flash model as a cue to speak again.
'RULE 4 — the card beats carry NO tool calls. Placing an ::onboarding card is pure text plus the directive, nothing else: the directive itself is what saves the answer, so there is no tool to reach for. And in any turn at all, never call the same tool twice — a returned tool result means that work is DONE, not that you should speak again and re-do it. When a call comes back, finish your one line and stop.',
'Your first message has ALREADY been sent for you: it greeted them and asked what you should call them. Do not greet again — their next message is their answer.',
...(suggestedName
? [
`The greeting also offered their OS account name "${suggestedName}" as a default. If they accept it (a "sure", "yes", "that works", or any similar go-ahead), treat that as their answer and save exactly "${suggestedName}".`
]
: []),
'From there, walk them through setup conversationally, one turn each, in this order:',
'1. This turn is exactly four things and then you stop: a few warm words about their name, then ::onboarding{step="name" value="THEIR_NAME"} on a line of its own (THEIR_NAME being the name they actually gave; it renders as nothing and just saves it), then one short sentence about their colour, then ::onboarding{step="look"} on a line of its own. That is one turn, not two, and it is not a conflict with RULE 3: the name line is not a question, the look card is, and it is the last thing you write.',
'2. Then the tools they already use, so Hermes can connect to them later: one short sentence, then ::onboarding{step="connectors"} on a line of its own.',
// The one place sign-in is named BEFORE it is needed. It goes here because
// this beat already put the idea in their head — they just listed the
// accounts they live in — so "you'll want an account for that" reads as an
// answer rather than a sales pitch. And it says FREE in the same breath:
// the fear being headed off is not signing up, it is being asked for a
// card two minutes into an app they have not decided about yet. Once, in
// passing, never again — a second mention is nagging, and the real ask
// comes later on its own.
...(signedIn
? []
: [
'In that same turn, once, mention in ONE short clause that wiring those up later will want a model provider — a free Nous account is there if they want it, free tier, no card, and they can bring their own provider instead — then move straight on. Do not sell it, do not list providers, do not ask them to do it now, and never bring it up again: they will be asked properly at the point it actually matters.'
]),
'3. Then their layout: one short sentence, then ::onboarding{step="layout"} on a line of its own.',
`4. The app has just arranged itself around this chat, so offer them a look at it: one short sentence, then the line ::ask{question="${TOUR_QUESTION}" options="${TOUR_OPTIONS.basics}|${TOUR_OPTIONS.tour}|${TOUR_OPTIONS.none}"} alone as its own paragraph. Branch on the answer, then go straight to step 5 whichever they picked.`,
` - "${TOUR_OPTIONS.basics}": three steps, the essentials only — where their conversations live, where they ask for a job, and how to start a fresh one. Point at each and say one useful thing about it.`,
` - "${TOUR_OPTIONS.tour}": 4 to 6 steps, a proper look around — the essentials plus whatever else the layout they just picked actually gives them.`,
` Both of those run the tour tool the same way: call it with action="targets" FIRST and build only out of what it actually reports, preferring the targets marked stable — never invent a selector, and if a piece you wanted is not in the list, drop that step rather than guessing at it. Then ONE action="start" call, each step a few words of title and one plain sentence of body. One short line before the call and one after; the tour does the talking.`,
` - "${TOUR_OPTIONS.none}": one short line, and move on.`,
' Whichever they picked, the line you close that turn on tells them the tour is always on offer: they can ask you to show them any part of this, any time. Say it in your own words, once, and never bring it up again.',
`5. Then the fork: one short sentence in your own words — you want to actually build them something, not just talk about it — then the line ::ask{question="${FORK_QUESTION}" options="${forkOptions().join('|')}" input="true"} alone as its own paragraph.`,
...(fallback.length
? [
` This ${kind} is barely out of the box, so the fork offers the one job that is obviously worth doing and keeps the rest one tap away. Say so in your sentence: you can see it is a NEW ${kind}, and the setup nobody enjoys — updates, drivers, the tools they just told you about — is a thing you can take off their hands right now. Name it as a fresh machine; that recognition is the point. Do not list what you would install. If they pick "${SOMETHING_ELSE}", reply with one short line and the second ask: ::ask{question="What sounds better?" options="${fallback.join('|')}" input="true"} — same exactness rule — then branch on THAT answer below.`
]
: []),
'6. Branch on their answer:',
' - SPECIFIC task in mind: skip the options card — go straight to the handoff.',
` - "${machine}": the machine itself is the job. Ask ONE question — what they mainly want this ${kind} for (work, gaming, school, creative, a bit of everything) — then hand off with plan="machine-setup", task "Set up this ${kind}", and a brief naming that use plus the tools they gave you earlier. Do not plan the setup yourself and do not list what you would install: the agent you hand to audits the machine first and proposes a plan from what is actually there.`,
` - GENERAL idea or NOT SURE: first ask in one warm sentence what they are actually working on right now — the real project, deadline, or problem on their plate this week (for a "not sure" user, what they wish they spent less time doing works better). One short follow-up if the answer is vague, then ::onboarding{step="working" value="THEIR_ANSWER"} on a line of its own (THEIR_ANSWER = one line, their key details, under 140 characters; renders as nothing, it just saves what they said). Then a card of options built from that answer plus their tools, again on a line of its own: ::onboarding{step="first" options="First idea|Second idea|Third idea"} — 2 to 4 options, each a short phrase (under 60 chars), spanning simple (a reminder) to complex (a dashboard), all specific to THIS user, separated by |. Their tap IS their reply — hand off from it.`,
// Plugins are the strongest first build we can offer — the result lands
// inside the window they are already looking at, in seconds, and it is
// theirs. But only for the answers that actually suit it: forcing one on
// "write my standup email" produces a worse version of a simple task.
// Hence a test the model applies, not a quota it fills.
' WHEN A PLUGIN FITS, MAKE IT ONE OF THOSE OPTIONS. Hermes can build pieces of its own interface — a small chip in the status bar, a button by the composer, a panel beside the chat — and the user watches it appear in this window as you write it. That is the best first build available whenever what they described is something they would want to SEE or REACH at a glance: a number they keep checking, a list they keep opening, a status they keep asking about, a thing they wish were one click instead of five. Phrase it as the outcome, never as the mechanism ("A panel with today\'s tickets", not "Write a plugin"). Roughly one option, not the whole card, and only alongside the other shapes — a task that is genuinely just a task (draft this, research that, rename these files) should not be bent into an interface.',
' If they pick that one, hand off with plan="plugin" on the handoff line.',
` - "${FORK_OPTIONS.skip}": say one short line that the app is theirs and this chat stays here if they ever want a hand, then stand down. No more questions, no handoff.`,
' CRITICAL for every branch: the first task must need NO external account or OAuth (no Gmail, no Slack, no Google sign-in) — connecting the apps they picked is optional and happens only with their consent in the build chat. Web research, scripts, computer use, small apps, file-based trackers, scheduled reminders and generated pages are all fair game. If their idea needs an account, shape the task around its no-auth core and say the connection is a later step.',
'7. THE HANDOFF — you do not build the task in this conversation. Once the task is decided, reply with ONE short sentence framing it (you are giving the work its own chat so it has room, and this one stays open), then ::onboarding{step="handoff" task="short task name" brief="the build instruction, one sentence, written as the user\'s ask"} on a line of its own — task under 40 chars, brief under 200. Add plan="machine-setup" to that same line when the job is setting up their computer, or plan="plugin" when it is a piece of the Hermes interface. The app opens the session, moves the user into it, and starts the build from your brief.',
'8. Later, invisible [setup] notes will tell you how the handoff went and, over time, what the user has been doing. When the handoff-complete note arrives, follow its instructions: one short line that you are around if they want a hand, then stop. If a handoff-failed note arrives instead, explain briefly that the first build did not start and point to Retry first build. Do not start another copy here or promise the build is running.',
'Whenever you draft reusable text for them (an email, a pitch, a template, a post), put the draft in a fenced code block so they can copy it in one click — never inline in your prose. Your own commentary stays outside the block.',
'Interactive questions: whenever you ask the user to choose between things (the fork above, a refinement, anywhere), end the message with ::ask{question="..." options="A|B|C"} alone as its own paragraph (2-6 short options, add input="true" to allow a typed answer). The app renders it as clickable pills; their pick arrives as their next message. Every option must be a plain, concrete answer the user would actually say (an action or a preference, never jargon), and you must ACT on whichever option arrives, immediately — never re-ask the question, never re-emit an answered ::ask, never offer an option you cannot execute. Never enumerate options in prose when ::ask can carry them.',
'Rules for the ::onboarding lines AND every scripted ::ask above: emit each EXACTLY as written — same question, same options, same order; never rename, reorder, drop, or invent options — alone as its own paragraph with a blank line before and after, never two directives on the same line. (A model that invents an option strands the user: the app cannot interpret a pill the script never defined.)',
'The app renders an interactive picker there and applies choices to the app live, so do NOT list or describe the options in prose.',
'Shape example for a tool-using turn: "On it, give me a moment." then the tool calls, then "Done. Your shopping list now carries the Zigbee parts." — nothing else.',
'Never end a turn having only PROMISED an action. If you say you will edit the dashboard, save something, or set something up, the SAME turn must contain the actual tool calls that do it, then a one-line confirmation. Saying "I\'ll wire it in now" and stopping is a failure.',
'Memory: the card beats need no memory tool. The ::onboarding lines persist their answers, and the handoff saves the agreed name, context and app preferences into their working profile for later conversations. Do not duplicate that write or narrate its mechanics.',
'Their picks arrive as invisible messages prefixed [setup] — acknowledge each in a few words, in your own words, never the same phrase twice, and move to the next step.',
PLAIN_SPEECH,
// Last thing the model reads, and it is the persona rather than the ban
// list — end on a wall of prohibitions and it writes like someone trying
// not to get in trouble.
'Above all of that: someone just walked in and you are glad to see them. Sound like it.'
].join(' ')
}
@@ -24,6 +24,15 @@ const resetStarmapGraph = vi.fn()
vi.mock('@/store/gateway', () => ({
$gateway,
// Both activation doors publish the registry route; the latest call owns it.
activeGatewayProfileKey: () => {
const profileOrder = ensureGatewayForProfile.mock.invocationCallOrder.at(-1) ?? 0
const agentOrder = ensureGatewayForAgent.mock.invocationCallOrder.at(-1) ?? 0
return agentOrder > profileOrder
? ensureGatewayForAgent.mock.lastCall?.[1]
: (ensureGatewayForProfile.mock.lastCall?.[0] ?? $activeGatewayProfile.get())
},
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForProfile
@@ -20,6 +20,8 @@ const resetStarmapGraph = vi.fn()
vi.mock('@/store/gateway', () => ({
$gateway,
activeGatewayConnectionId,
// Activation now verifies the socket's route before publishing the profile.
activeGatewayProfileKey: () => ensureGatewayForProfile.mock.lastCall?.[0] ?? $activeGatewayProfile.get(),
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForProfile
+3 -1
View File
@@ -6,7 +6,7 @@ import type { ProfileInfo } from '@/types/hermes'
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
// the REST query client must not run for real in a unit test.
const ensureGatewayForProfile = vi.fn(async () => undefined)
const ensureGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const ensureGatewayForAgent = vi.fn(async () => undefined)
const openGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const openSecondaryCount = vi.fn(() => 0)
@@ -15,6 +15,8 @@ const resetStarmapGraph = vi.fn()
vi.mock('@/store/gateway', () => ({
$gateway,
// Activation now verifies the socket's route before publishing the profile.
activeGatewayProfileKey: () => ensureGatewayForProfile.mock.lastCall?.[0] ?? $activeGatewayProfile.get(),
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForProfile,
+60 -5
View File
@@ -18,6 +18,7 @@ import { invalidateCronModelImpactScopeState } from '@/store/cron-model-impact-s
import {
$gateway,
activeGatewayConnectionId,
activeGatewayProfileKey,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForAgent,
@@ -27,7 +28,7 @@ import {
import { notifyError } from '@/store/notifications'
import { $poolLimits } from '@/store/pool-limits'
import { notifyRemoteOverrideAuthFailure } from '@/store/profile-remote-override'
import { clearComposerSelectionOwner, setComposerSelectionOwner, setConnection } from '@/store/session'
import { $connection, clearComposerSelectionOwner, setComposerSelectionOwner, setConnection } from '@/store/session'
import type { SessionOwnerRoute } from '@/store/session-request-router'
import { resetStarmapGraph } from '@/store/starmap'
import type { ProfileInfo } from '@/types/hermes'
@@ -504,7 +505,40 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
const target = normalizeProfileKey(profile)
if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()?.connectionState === 'open') {
// Fast path: only when the REGISTRY's active route — the authority that
// selects the socket in applyActive — already serves the target. The
// renderer-side $activeGatewayProfile mirror is not proof of the socket:
// applyActive can decline an epoch-losing publication while call sites
// publish the atom anyway, leaving "atom says X, socket serves Y" (the
// #89206 split-brain — observed live as atom 'default' over a hermes-setup
// socket during the guided-onboarding handoff). Verify the leg we're about
// to rely on; on disagreement fall through to the full ensure path, which
// re-activates the socket and leaves the atom and route agreeing. The one
// sanctioned divergence is the shared-primary (global-remote) route: the
// registry route stays on the primary while the atom carries the request
// scope — recognized via the active descriptor so global-remote keeps its
// fast path instead of re-running the swap on every create.
const routeAgrees = (): boolean => {
if (normalizeProfileKey($activeGatewayProfile.get()) !== target || $gateway.get()?.connectionState !== 'open') {
return false
}
const routeKey = normalizeProfileKey(activeGatewayProfileKey())
if (routeKey === target) {
return true
}
const descriptor = $connection.get()
return Boolean(
descriptor &&
descriptor.sharedPrimary === true &&
normalizeProfileKey(descriptor.profile) === target
)
}
if (routeAgrees()) {
return
}
@@ -516,7 +550,7 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
await gatewaySwitch.catch(() => undefined)
}
if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()?.connectionState === 'open') {
if (routeAgrees()) {
return
}
@@ -535,14 +569,35 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
// end of the callback, so the profile pointer and the connection
// descriptor become visible together; a null descriptor (no bridge, or a
// failed best-effort lookup) keeps the previous one — fail open.
//
// Publish in agreement with the registry's actual outcome: if this
// activation lost an epoch race (a concurrent eviction/reap re-routed the
// active socket while we awaited), applyActive declined and the route
// serves someone else — publishing `target` anyway is what minted the
// atom-vs-socket split-brain the fast path above now guards against.
// Shared-primary (global-remote) still publishes `target`: its socket
// serves every profile and the atom carries the request scope.
// Everything else publishes the route the registry actually landed on,
// so the atom and the socket agree and the next ensure retries the swap
// instead of fast-pathing on a stale claim. Still fail-open (no throw):
// switching must never turn registry churn into dead profile clicks
// (#89622).
const routeKey = normalizeProfileKey(activeGatewayProfileKey())
const sharedPrimary = connection?.sharedPrimary === true
const landed = sharedPrimary || routeKey === target
if (!landed) {
console.warn(`[profile] gateway activation for "${target}" did not land; active route is "${routeKey}"`)
}
batch(() => {
if (connection) {
if (connection && landed) {
setConnection(connection)
} else {
clearComposerSelectionOwner()
}
$activeGatewayProfile.set(target)
$activeGatewayProfile.set(landed ? target : routeKey)
})
})()
+8
View File
@@ -20,6 +20,7 @@ import { persistString, storedString } from '@/lib/storage'
import { $connectionsRegistry, refreshConnectionsRegistry } from '@/store/connections'
import { reconnectGateway } from '@/store/gateway-reconnect'
import { dismissNotification, notify } from '@/store/notifications'
import { onboardingSurfaceActive } from '@/store/onboarding-presence'
import { $connection } from '@/store/session'
import type { BackendUpdateCheckResponse } from '@/types/hermes'
@@ -215,6 +216,13 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null, t
return
}
// A toast would interrupt the cinematic or guided chat. Drop this poll's
// offer: the poller checks again later and normal snooze handling still
// applies, so there is no need to queue a notification.
if (onboardingSurfaceActive()) {
return
}
const behind = typeof status.behind === 'number' ? status.behind : null
// behind === null means "update available, exact count unknown" (shallow
+28
View File
@@ -1015,6 +1015,26 @@
}
}
/* The guided-onboarding thread is marked with [data-thread-type='onboarding']
— the hook for onboarding-only transcript styling. */
/* Guided chat is a conversation, not a work surface: hide per-message action
chrome (branch/copy/read-aloud/regenerate bar, reaction slot) and the turn-
duration stamp — regenerate/branch would derail the scripted flow. */
[data-thread-type='onboarding'] [data-slot='aui_msg-actions'],
[data-thread-type='onboarding'] [data-slot='aui_msg-reactions'],
[data-thread-type='onboarding'] [data-slot='aui_turn-duration'] {
display: none;
}
/* Hermes speaks first here: no user bubble sits above the opening assistant
turn, so the first painted row would press against the pane chrome. The
utility class on the row pulls the top pad 0.5rem ABOVE the titlebar; give
the air back, and only in this thread. */
[data-slot='aui_thread-content'][data-thread-type='onboarding'] {
padding-top: calc(var(--titlebar-height) + 1rem);
}
@keyframes arc-border {
/* Compositor-only travel. The ::before layer is 300% × 300% of the host
(mirroring the old `background-size: 300%`), so translating it from
@@ -1499,6 +1519,14 @@ body.guest-pointer-lock :is(webview, iframe) {
font-size: inherit;
}
/* Ask / onboarding pills sit flush with the prose left edge. `.aui-md` is
`overflow-hidden` so wide code/tables don't blow the column; that same clip
shears the first pill's left cap. Cards opt the surface out; tables/code keep
their own overflow. */
[data-slot='aui_assistant-message-content'] .aui-md:has([data-onboarding-card]) {
overflow: visible;
}
/* Tailwind Typography sets `.prose :where(p) { margin: 1.25em }` (~16px). That
selector ties our `my-*` utility on specificity and wins on source order, so
paragraph spacing must be reclaimed here at higher specificity. One tight
+3
View File
@@ -228,6 +228,9 @@ export default defineConfig(({ command }) => ({
host: '127.0.0.1',
port: 5174,
strictPort: true,
warmup: {
clientFiles: ['./src/components/intro-reveal/intro-root.tsx']
},
fs: {
allow: fsAllow
}
+14 -4
View File
@@ -74,12 +74,15 @@ def _env_write_errors(log_msg: str, *, http_passthrough: bool):
@config_router.get("/api/config")
async def get_config(profile: Optional[str] = None):
async def get_config(profile: Optional[str] = None, include_defaults: bool = True):
# _profile_scope blocks on the process-wide _SKILLS_PROFILE_LOCK and
# load_config() reads from disk; a slow lock-holder on the event loop froze
# the whole gateway for >1s. asyncio.to_thread copies the contextvar
# context, so the profile override stays scoped to the worker thread.
config = await scoped_to_thread(profile, lambda: _normalize_config_for_web(load_config()))
# Opt in to saved values so clients can distinguish user choices from defaults.
config = await scoped_to_thread(
profile, lambda: _normalize_config_for_web(load_config() if include_defaults else read_raw_config())
)
# Strip internal keys that the frontend shouldn't see or send back
return {k: v for k, v in config.items() if not k.startswith("_")}
@@ -107,7 +110,9 @@ async def get_egress_status():
@router.put("/api/config")
async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
async def update_config(
body: ConfigUpdate, profile: Optional[str] = None, preserve_language: bool = False
):
def _run():
approvals_mode_changed = False
with _profile_scope(body.profile or profile):
@@ -126,7 +131,12 @@ async def update_config(body: ConfigUpdate, profile: Optional[str] = None):
# serve the pre-save cache on an (mtime_ns, size) collision.
# Only approvals.mode feeds session.info, so it is the trigger.
approvals_mode_changed = _approval_mode_of(merged) != _approval_mode_of(existing)
save_config(merged)
# Explicit English must survive default stripping: an absent
# language lets the desktop follow the OS on its next launch.
# Ordinary settings saves include merged defaults, not a choice.
save_config(
merged, preserve_keys={("display", "language")} if preserve_language else None
)
# REST saves bypass the config.set RPC (which re-emits itself), so
# refresh live sessions' cached approval/YOLO indicators after a mode
# change. Own-profile saves only: a profile-scoped save targets a
+5 -1
View File
@@ -536,7 +536,11 @@ def enable_profile_log_routing(profile_homes: Sequence[str | Path]) -> bool:
with _queue_state_lock:
if not _queued_file_handlers:
return False
if any(isinstance(h, _ProfileRoutingFileHandler) for h in _queued_file_handlers):
routers = [h for h in _queued_file_handlers if isinstance(h, _ProfileRoutingFileHandler)]
if routers:
for handler in routers:
with handler._profile_handlers_lock:
handler._profile_homes = handler._profile_homes.union(homes)
return True
listener = _queue_listener
if listener is not None:
+9 -6
View File
@@ -169,15 +169,18 @@ class ConnectorClient:
if cursor:
path += f"&cursor={cursor}"
payload = self._request("GET", path, None)
if not isinstance(payload, dict):
break
if not isinstance(payload, dict) or "error" in payload:
raise ToolGatewayError("invalid connector list page", code="INVALID_RESPONSE")
page = payload.get("items")
if isinstance(page, list):
items.extend(entry for entry in page if isinstance(entry, dict))
if not isinstance(page, list) or any(not isinstance(entry, dict) for entry in page):
raise ToolGatewayError("invalid connector list items", code="INVALID_RESPONSE")
items.extend(page)
cursor = payload.get("nextCursor")
if not cursor:
break
return items
return items
if not isinstance(cursor, str):
raise ToolGatewayError("invalid connector list cursor", code="INVALID_RESPONSE")
raise ToolGatewayError("connector list pagination incomplete", code="INVALID_RESPONSE")
def execute(self, planned: Sequence[PlannedCall]) -> list[dict[str, Any]]:
"""POST v1/connectors/execute — ONE request for the whole slice.
+14 -5
View File
@@ -4,11 +4,13 @@ Mirrors the ``ToolSearchConfig`` idiom in ``tools/tool_search.py``: a frozen
dataclass built by a tolerant ``from_raw`` so a typo in user config degrades
to defaults instead of breaking the agent.
Availability is a two-leg AND that fails closed:
connectors_available() = config flag AND managed_nous_tools_enabled()
The config flag is the user's off switch; the entitlement leg is the portal
sign-in every managed tool already gates on. The gateway remains authoritative:
404 routes degrade to local-only, and execution refusals reach the caller.
Availability fails closed:
connectors_available() = config flag AND (free-tier identity OR managed_nous_tools_enabled())
The config flag is the user's off switch. An existing free-tier identity may
attempt connector routes without a subscription preflight (the gateway enforces
its actual grant); every other identity keeps the portal sign-in every managed
tool already gates on. The gateway remains authoritative: 404 routes degrade to
local-only, and execution refusals reach the caller.
"""
from __future__ import annotations
@@ -98,8 +100,15 @@ def connectors_available(
if not resolved_loader().enabled:
return False
if entitlement_check is None:
from hermes_cli.anon_auth import is_guest_state
from tools.managed_tool_gateway import _read_nous_provider_state
from tools.tool_backend_helpers import managed_nous_tools_enabled
# Availability must not mint or refresh an identity. The shared reader
# already hides free-tier identities when nous.guest is disabled.
if is_guest_state(_read_nous_provider_state()):
return True
entitlement_check = managed_nous_tools_enabled
return bool(entitlement_check())
except Exception as e:

Some files were not shown because too many files have changed in this diff Show More