diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index a297bf22a..8b5ae596d 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -131,6 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { return ( + {circle()} @@ -155,6 +156,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { + ) diff --git a/packages/app/src/components/session/panel-menu.tsx b/packages/app/src/components/session/panel-menu.tsx index d6f8f4f26..cb338755f 100644 --- a/packages/app/src/components/session/panel-menu.tsx +++ b/packages/app/src/components/session/panel-menu.tsx @@ -38,6 +38,7 @@ export function PanelMenu(props: { diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 659a0dea3..dae97e3c9 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -583,11 +583,15 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
{/* amicode#274: Session Chats Dropdown — chat navigation from within a session */} - + + + - - - + + + + + } > - } - /> + + } + /> +
diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 48a485347..5c14f9c73 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -285,7 +285,7 @@ export function TitlebarTabStrip(props: { }) return ( -
+
{ - const sessionID = params.id - void import("@/components/settings-v2").then((module) => { - setSettingsOpen(true) - void dialog.show( - () => , - () => setSettingsOpen(false), - ) - }) - } command.register("titlebar-home", () => []) @@ -390,34 +378,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl - - - - - {language.t("command.settings.open")} - - - } - class="shrink-0" - > - } - state={settingsOpen() ? "pressed" : undefined} - onClick={showSettings} - aria-label={language.t("command.settings.open")} - /> - + {/* Profile and Settings live at the trailing edge with the + other account/status controls (Sessions, Status, Side + Panel) — see TitlebarV2Right. */} {/* Removed: sidebar-left toggle button (harmoniqs/amicode#265). The button called layout.sidebar.toggle() but no component in NewLayout observes that signal — WorkbenchPanel only mounts in @@ -437,26 +400,28 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl onOverflowChange={setTabsAreOverflowing} /> - - {language.t("command.session.new")} - - - } - class="shrink-0" - > - + + {language.t("command.session.new")} + + + } class="shrink-0" - icon={} - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - + > + } + onClick={openNewTab} + aria-label={language.t("command.session.new")} + /> + +
@@ -614,12 +579,56 @@ type TitlebarV2RightState = { } function TitlebarV2Right(props: { state: TitlebarV2RightState }) { + const language = useLanguage() + const command = useCommand() + const dialog = useDialog() + const params = useParams() + const [settingsOpen, setSettingsOpen] = createSignal(false) + const showSettings = () => { + const sessionID = params.id + void import("@/components/settings-v2").then((module) => { + setSettingsOpen(true) + void dialog.show( + () => , + () => setSettingsOpen(false), + ) + }) + } return (
+ {/* Session-scoped controls (Sessions / Status / Side Panel) portal in here. */}
+ + + + + + + + {language.t("command.settings.open")} + + + } + class="shrink-0" + > + } + state={settingsOpen() ? "pressed" : undefined} + onClick={showSettings} + aria-label={language.t("command.settings.open")} + /> + +
) } diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 19fbcf786..a9487f974 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -420,3 +420,85 @@ span[data-component="tag"][data-variant="accent"] { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } + +/* ── Onboarding walkthrough spotlight (session-tour.tsx) ─────────────────── + A ring traced on the chrome element the current "Tour · X" stop names. The + ring is the whole marker — there is no label, because the stop's own text + already names the surface. Decorative only (aria-hidden, pointer-events + none); sits above the shell but below dialogs (10000+). */ +[data-component="amc-tour-spotlight"] { + position: fixed; + inset: 0; + z-index: 9000; + pointer-events: none; +} +/* The scrim: everything except the stop's element and the walkthrough card is + softened, so the eye has one place to go. Blur is deliberately shallow (3px) + and paired with only a light dim — enough to push the rest of the window back + without hiding what the reader is being taught to recognise. The holes are + punched with an even-odd clip-path, recomputed as the ring moves. */ +[data-component="amc-tour-spotlight"] .amc-tour-scrim { + position: absolute; + inset: 0; + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + background: color-mix(in srgb, var(--v2-background-bg-base) 22%, transparent); + transition: opacity 0.2s ease; +} +[data-component="amc-tour-spotlight"] .amc-tour-ring { + position: absolute; + /* No glow — nothing else in this UI uses one, and the scrim already says + "look here". Just the accent edge, the way state is marked everywhere else. + No fill either: this sits ON TOP of a live control, and a wash would tint + the very thing the reader is being taught to recognise. */ + border: 1px solid var(--accent); + border-radius: var(--radius-md); + /* Eases between elements rather than teleporting. */ + transition: top 0.22s cubic-bezier(0.2, 0, 0, 1), left 0.22s cubic-bezier(0.2, 0, 0, 1), + width 0.22s cubic-bezier(0.2, 0, 0, 1), height 0.22s cubic-bezier(0.2, 0, 0, 1); + /* Fades in once as the highlight lands. Keyed on the stop, so it plays per + stop and not on the measure tick. */ + animation: amc-tour-arrive 0.25s ease-out both; +} +@keyframes amc-tour-arrive { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@media (prefers-reduced-motion: reduce) { + [data-component="amc-tour-spotlight"] .amc-tour-ring { + animation: none; + } +} + +/* ── composer ⇄ dock card morph ── + The question / walkthrough card and the composer swap places in the same + dock. Without a transition the swap is an instant pop; these ease the card + in and out so the region reads as one surface changing shape rather than + two components replacing each other. Motion-reduced users get the swap with + no movement, which is the honest fallback for a positional animation. */ +[data-component="session-prompt-dock"] [data-component="session-question-dock"] { + animation: amc-dock-morph-in 0.22s cubic-bezier(0.2, 0, 0, 1) both; +} +@keyframes amc-dock-morph-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.995); + } + to { + opacity: 1; + transform: none; + } +} +[data-component="session-prompt-dock"] [data-component="prompt-input-v2"] { + animation: amc-dock-morph-in 0.22s cubic-bezier(0.2, 0, 0, 1) both; +} +@media (prefers-reduced-motion: reduce) { + [data-component="session-prompt-dock"] [data-component="session-question-dock"], + [data-component="session-prompt-dock"] [data-component="prompt-input-v2"] { + animation: none; + } +} diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts index c79de29ca..18af3ff62 100644 --- a/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -3,6 +3,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring" import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import type { PromptInputState } from "@/components/prompt-input" +import { isTourRequest } from "@/pages/session/composer/session-tour" import { useSync } from "@/context/sync" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import type { SessionComposerController } from "./session-composer-state" @@ -138,7 +139,11 @@ export function createSessionComposerRegionController(input: { parentID, child: () => !!parentID(), archived, - showComposer: () => !input.state.blocked() || !!parentID() || archived(), + // A tour stop narrates rather than asks, so it leaves the composer up — + // both because nothing is actually being demanded of the reader, and + // because the Composer stop needs a composer on screen to point at. + showComposer: () => + !input.state.blocked() || isTourRequest(input.state.questionRequest()) || !!parentID() || archived(), handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt, promptReady: () => input.prompt.ready() || promptReady(), dock: () => (store.ready && input.state.dock()) || value() > 0.001, diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 730783120..41a385c07 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -5,6 +5,7 @@ import { bugReportEnabled } from "@/utils/amicode-bug-report" import { SessionBugDock } from "@/pages/session/composer/session-bug-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" +import { SessionTourSpotlight, isTourRequest } from "@/pages/session/composer/session-tour" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" @@ -33,6 +34,7 @@ export function SessionComposerRegion(props: { }} >
)} + {/* Overture Stage-7 tour: rings the chrome element the active + "Tour · X" question card names; renders nothing off-tour. */} + {(request) => ( @@ -154,7 +159,15 @@ export function SessionComposerRegion(props: { fallback={ {props.promptInput}} + fallback={ + + {props.promptInput} + + } >
() @@ -85,6 +86,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit optionsHeight: 180, }) + // Publish which question is showing so the onboarding tour's spotlight can + // follow a multi-question card as the reader steps through it. + createEffect(() => setTourQuestionIndex(store.tab)) + let root: HTMLDivElement | undefined let optionsRef: HTMLDivElement | undefined let customRef: HTMLButtonElement | undefined @@ -104,8 +109,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customRow = createMemo(() => questionCustomRow(question())) const count = createMemo(() => (text() ? 0 : options().length + 1)) + // A walkthrough stop narrates; it is not a question being put to the reader. + // It gets prose, a Next button and a way out — no options, no answer hint, + // no "N questions" framing. + const isTour = createMemo(() => isTourRequest(props.request)) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) + if (isTour()) return `Stop ${n} of ${total()}` return language.t("session.question.progress", { current: n, total: total() }) }) const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") @@ -517,7 +527,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit · {" "} - Question + {isTour() ? "Walkthrough" : "Question"}
{summary()} @@ -559,7 +569,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit footer={ <>
0}> @@ -568,13 +578,19 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
@@ -591,11 +607,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit > {question()?.question}
- + {language.t("ui.question.singleHint")}
}>
{language.t("ui.question.multiHint")}
+
(optionsRef = el)} data-slot="question-options" @@ -730,6 +747,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
+
) diff --git a/packages/app/src/pages/session/composer/session-tour.test.ts b/packages/app/src/pages/session/composer/session-tour.test.ts new file mode 100644 index 000000000..7bc4985bd --- /dev/null +++ b/packages/app/src/pages/session/composer/session-tour.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { TOUR_HEADER_PREFIX, isTourRequest, tourTargetKeys } from "./session-tour" + +// The overture score (amicode scores/overture, v3) emits Stage-7 beats as +// question cards with verbatim "Tour · " headers; the spotlight keys +// on those strings. These tests pin the header contract from the app side. + +const request = (header: string) => ({ + questions: [{ question: "Look here.", header, options: [] }], +}) + +describe("tourTargetKeys", () => { + test("maps every scored beat to an always-present anchor", () => { + // The Composer stop names the real input first, then its column. + expect(tourTargetKeys(request("Tour · Composer"))).toEqual([ + '[data-component="prompt-input-v2"]', + '[data-tour-target="composer"]', + ]) + expect(tourTargetKeys(request("Tour · Tabs"))).toEqual(['[data-tour-target="tabs"]']) + expect(tourTargetKeys(request("Tour · New chat"))).toEqual(['[data-tour-target="new-chat"]']) + expect(tourTargetKeys(request("Tour · Sessions"))).toEqual(['[data-tour-target="sessions"]']) + expect(tourTargetKeys(request("Tour · Context"))).toEqual(['[data-tour-target="context-ring"]']) + expect(tourTargetKeys(request("Tour · Side panel"))).toEqual(['[data-tour-target="side-panel"]']) + expect(tourTargetKeys(request("Tour · Status"))).toEqual(['[data-tour-target="status"]']) + expect(tourTargetKeys(request("Tour · Profile"))).toEqual(['[data-tour-target="profile"]']) + expect(tourTargetKeys(request("Tour · Settings"))).toEqual(['[data-tour-target="settings"]']) + }) + + // The Pulse Inspector, Preview and the file list all live behind the one + // panel button, so the score sends a single stop for them. + test("the panel is one stop, and its old sub-stops are gone", () => { + expect(tourTargetKeys(request("Tour · Side panel"))).toEqual(['[data-tour-target="side-panel"]']) + expect(tourTargetKeys(request("Tour · Pulse Inspector"))).toBeUndefined() + expect(tourTargetKeys(request("Tour · Preview"))).toBeUndefined() + }) + + // Mirrors the 9 stops the score sends in its single tour card, in order. + // That order is the READING ORDER of the window — the top bar left to right, + // then the row beneath it, then the composer — so the highlight walks the + // screen instead of hopping across it. + const SCORED_STOPS = [ + "Tabs", + "New chat", + "Sessions", + "Status", + "Side panel", + "Profile", + "Settings", + "Context", + "Composer", + ] + + test("every stop the score sends resolves to at least one candidate", () => { + for (const beat of SCORED_STOPS) { + const keys = tourTargetKeys(request(`${TOUR_HEADER_PREFIX}${beat}`)) + expect(keys && keys.length).toBeGreaterThan(0) + } + }) + + // The whole tour rides in ONE card, so the spotlight has to track which + // question inside it is showing — not just the first. + test("follows the active question index within a multi-question card", () => { + const wholeTour = { questions: SCORED_STOPS.map((b) => ({ question: b, header: `Tour · ${b}`, options: [] })) } + expect(tourTargetKeys(wholeTour, 0)).toEqual(['[data-tour-target="tabs"]']) + expect(tourTargetKeys(wholeTour, 2)).toEqual(['[data-tour-target="sessions"]']) + expect(tourTargetKeys(wholeTour, 3)).toEqual(['[data-tour-target="status"]']) + expect(tourTargetKeys(wholeTour, 7)).toEqual(['[data-tour-target="context-ring"]']) + expect(tourTargetKeys(wholeTour, 8)?.[0]).toBe('[data-component="prompt-input-v2"]') + expect(tourTargetKeys(wholeTour, 99)).toBeUndefined() + }) + + test("non-tour questions and unknown beats resolve to nothing", () => { + expect(tourTargetKeys(request("Profile exists"))).toBeUndefined() + expect(tourTargetKeys(request(`${TOUR_HEADER_PREFIX}Rail`))).toBeUndefined() + expect(tourTargetKeys(request(""))).toBeUndefined() + expect(tourTargetKeys({ questions: [] })).toBeUndefined() + expect(tourTargetKeys(undefined)).toBeUndefined() + }) + + test("defaults to the first question when no index is given", () => { + expect( + tourTargetKeys({ + questions: [ + { question: "x", header: "Tour · Settings", options: [] }, + { question: "y", header: "Tour · Composer", options: [] }, + ], + }), + ).toEqual(['[data-tour-target="settings"]']) + }) +}) + + +describe("isTourRequest", () => { + test("is true for a tour card and false for a real question", () => { + expect(isTourRequest(request("Tour · Composer"))).toBe(true) + expect(isTourRequest(request("Your role"))).toBe(false) + expect(isTourRequest(undefined)).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-tour.tsx b/packages/app/src/pages/session/composer/session-tour.tsx new file mode 100644 index 000000000..e51e6b57c --- /dev/null +++ b/packages/app/src/pages/session/composer/session-tour.tsx @@ -0,0 +1,168 @@ +import { Show, createEffect, createSignal, onCleanup } from "solid-js" +import { Portal } from "solid-js/web" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" + +// AMICODE: overture Stage 7 — the studio walkthrough. The onboarding score +// emits one question card per tour beat with a verbatim "Tour · " +// header; this module maps those headers onto whichever chrome element carries +// the matching data-tour-target attribute and rings it while the card is up. +// Degrades to nothing when the header is unknown or the element is absent — +// the card in the chat still reads on its own. + +export const TOUR_HEADER_PREFIX = "Tour · " + +/** Which question inside the current card is showing. The whole tour rides in a + * SINGLE multi-question card so stepping between beats costs no model round + * trip — the dock publishes its active tab here and the spotlight follows. */ +const [tourQuestionIndex, setTourQuestionIndex] = createSignal(0) +export { setTourQuestionIndex } + +/** Header suffix → the data-tour-target candidates for that beat, best first. + * A beat may name a precise control that only exists in some states: the + * Pulse Inspector and Preview live in the side panel's own menu, which is not + * mounted until the panel is open, so they fall back to the panel toggle that + * is always there. The first candidate actually on screen wins. */ +const anchor = (key: string) => `[data-tour-target="${key}"]` + +const TOUR_TARGETS: Record = { + // The real input first, so the ring traces the composer itself; the dock + // column is the fallback for layouts where the input is not mounted. + Composer: ['[data-component="prompt-input-v2"]', anchor("composer")], + Tabs: [anchor("tabs")], + "New chat": [anchor("new-chat")], + Sessions: [anchor("sessions")], + Context: [anchor("context-ring")], + // One stop for the whole panel: the Pulse Inspector, Preview and the file + // list all live behind it, and separate stops re-lit the identical button. + "Side panel": [anchor("side-panel")], + Status: [anchor("status")], + Profile: [anchor("profile")], + Settings: [anchor("settings")], +} + +/** The data-tour-target candidates for a question request, or undefined when + * the request is not a recognized tour beat. Pure — unit-tested. */ +export function tourTargetKeys( + request: Pick | undefined, + index = 0, +): readonly string[] | undefined { + const header = request?.questions?.[index]?.header + if (!header?.startsWith(TOUR_HEADER_PREFIX)) return undefined + return TOUR_TARGETS[header.slice(TOUR_HEADER_PREFIX.length)] +} + +/** True when this card is a tour stop. The tour narrates; it does not ask, so + * it must not block the composer the way a real question does — the Composer + * stop has to have a composer on screen to ring. Pure. */ +export function isTourRequest(request: Pick | undefined): boolean { + return !!request?.questions?.some((q) => q.header?.startsWith(TOUR_HEADER_PREFIX)) +} + +/** First candidate element that is actually rendered with a size. A surface can + * be in the DOM more than once (a compact and a full variant, say) with only + * one of them showing, so size — not mere presence — decides. */ +function resolveTourElement(selectors: readonly string[]): Element | undefined { + for (const selector of selectors) { + for (const el of document.querySelectorAll(selector)) { + const r = el.getBoundingClientRect() + if (r.width > 0 && r.height > 0) return el + } + } + return undefined +} + +// The ring traces the element's own edges — no inset, no halo of dead space, +// so it reads as the component itself being lit. +const RING_PAD = 0 + +type SpotRect = { top: number; left: number; width: number; height: number } + +/** The spotlight overlay: a ring around the element the current stop names. + * Purely decorative (aria-hidden, pointer-events none) — the narration lives + * in the walkthrough card in the chat. */ +export function SessionTourSpotlight(props: { request: QuestionRequest | undefined }) { + // Compare by VALUE. The measure poll runs a few times a second and would + // otherwise hand back a fresh object every tick, retriggering everything + // downstream — which restarted the ring's pulse on every tick and read as a + // rapid blink rather than a slow breath. + const sameRect = (a: SpotRect | undefined, b: SpotRect | undefined) => + a === b || + (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height) + + const [rect, setRect] = createSignal(undefined, { equals: sameRect }) + // The walkthrough card's own box — the second hole in the scrim, so the thing + // doing the explaining stays as sharp as the thing being explained. + const [cardRect, setCardRect] = createSignal(undefined, { equals: sameRect }) + + /** Full-viewport rect minus the two holes, even-odd so the inner subpaths + * punch through. Plain rectangles: the ring's 4px corners sit on top of the + * hole's corners, so the difference is not visible. */ + const scrimPath = (target: SpotRect, card: SpotRect | undefined) => { + const hole = (r: SpotRect) => + ` M${r.left},${r.top} H${r.left + r.width} V${r.top + r.height} H${r.left} Z` + const outer = `M0,0 H${window.innerWidth} V${window.innerHeight} H0 Z` + return `path(evenodd, "${outer}${hole(target)}${card ? hole(card) : ""}")` + } + + createEffect(() => { + const keys = tourTargetKeys(props.request, tourQuestionIndex()) + setRect(undefined) + setCardRect(undefined) + if (!keys) return + const measure = () => { + const card = document.querySelector('[data-component="session-question-dock"]')?.getBoundingClientRect() + setCardRect(card ? { top: card.top, left: card.left, width: card.width, height: card.height } : undefined) + const r = resolveTourElement(keys)?.getBoundingClientRect() + if (!r) { + setRect(undefined) + return + } + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }) + } + measure() + // The chrome can move under the overlay (tab churn, panel opens, window + // resize); a slow poll plus the cheap listeners keeps the ring honest + // without observing every layout container. + const interval = setInterval(measure, 300) + window.addEventListener("resize", measure) + window.addEventListener("scroll", measure, true) + onCleanup(() => { + clearInterval(interval) + window.removeEventListener("resize", measure) + window.removeEventListener("scroll", measure, true) + }) + }) + + // NOT keyed: a keyed Show tears the overlay down and builds it again whenever + // the rect changes, which restarts the CSS animation. Un-keyed, the nodes + // persist for the whole stop and only their style updates, so the pulse runs + // its full slow cycle uninterrupted. + // + // The ring is the whole marker. There is no label: the stop's own text names + // the surface, so a floating name would only repeat it and cover whatever it + // was placed over. + return ( + + +