From 008bb307e4e8fae0a6f38b0d36be13b8646fc378 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:41:12 -0600 Subject: [PATCH] fix(app): drive the branch picker and wordmark lodger through state machines (#61986) --- .../new-session-wordmark.spec.ts | 32 ++ packages/app/src/new-session/view.tsx | 68 ++- .../src/new-session/wordmark-machine.test.ts | 236 ++++++++ .../app/src/new-session/wordmark-machine.ts | 237 +++++++++ .../app/src/new-session/wordmark.stories.tsx | 31 +- packages/app/src/new-session/wordmark.tsx | 502 ++++++++++++------ .../workspace/branch-machine.test.ts | 233 ++++++++ .../new-session/workspace/branch-machine.ts | 228 ++++++++ .../src/new-session/workspace/selector.tsx | 136 +++-- 9 files changed, 1430 insertions(+), 273 deletions(-) create mode 100644 packages/app/src/new-session/wordmark-machine.test.ts create mode 100644 packages/app/src/new-session/wordmark-machine.ts create mode 100644 packages/app/src/new-session/workspace/branch-machine.test.ts create mode 100644 packages/app/src/new-session/workspace/branch-machine.ts diff --git a/packages/app/component-tests/new-session-wordmark.spec.ts b/packages/app/component-tests/new-session-wordmark.spec.ts index e068db191d9..751bc31b01e 100644 --- a/packages/app/component-tests/new-session-wordmark.spec.ts +++ b/packages/app/component-tests/new-session-wordmark.spec.ts @@ -46,6 +46,38 @@ story("shows a static pin board with reduced motion", async ({ mount, page }) => expect(await requestedFrames(page)).toBe(0) }) +story("the lodger pops the pin board out and packs it back into the e", async ({ mount, page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }) + await mount("app-new-session-wordmark--flat") + const grip = page.locator('[data-slot="wordmark-lodger-grip"]') + const flat = page.locator('[data-slot="wordmark-flat"]') + const board = page.locator("canvas") + await expect(board).toHaveCount(0) + + // Pulling the lodger well past its slip distance pops every pin out into the board. + const hole = (await grip.boundingBox())! + await page.mouse.move(hole.x + hole.width / 2, hole.y + hole.height / 2) + await page.mouse.down() + await page.mouse.move(hole.x + hole.width / 2 + 160, hole.y + hole.height / 2 + 80, { steps: 12 }) + await page.mouse.up() + await expect(flat).toBeHidden() + await expect(board).not.toHaveCount(0) + await expect.poll(() => requestedFrames(page)).toBe(0) + + // Carrying it from its perch back into the "e" reels the pins in and tucks the lodger away. + const lodger = (await page.locator('[data-slot="wordmark-lodger"]').boundingBox())! + const unit = lodger.width / 234 + const perch = (await grip.boundingBox())! + await page.mouse.move(perch.x + perch.width / 2, perch.y + perch.height / 2) + await page.mouse.down() + await page.mouse.move(perch.x + perch.width / 2 - 24 * unit, perch.y + perch.height / 2 - 16 * unit, { steps: 12 }) + await page.mouse.up() + await expect(board).toHaveCount(0) + await expect(flat).toBeVisible() + await expect.poll(() => requestedFrames(page)).toBe(0) + await expect(grip).toBeVisible() +}) + // Reads the on-screen pixel at a grid cell. The board may be drawn with WebGL, whose canvas cannot be read // back once presented, so the pixel comes from a screenshot decoded in the page. async function pinPixel(page: Page, cell = PIN) { diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 5d44390bb0b..8bb849109f2 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -4,7 +4,7 @@ import { Icon } from "@opencode/ui/icon" import { Slash } from "@opencode/ui/slash" import { Keybind } from "@opencode/ui/keybind" import { useCommand } from "@/shell/commands/command" -import { Show, createMemo, createSignal, type JSX } from "solid-js" +import { Show, createMemo, createSignal, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" import { getFilename } from "@opencode/util/path" @@ -36,8 +36,15 @@ import { useSettings } from "@/settings/model" import type { NewSessionWorkspaceController } from "./workspace/controller" import { NewSessionBranch } from "./workspace/selector" import { NewSessionBrickBreaker } from "./brick-breaker" -import { NewSessionPinBoard, type PinBoardHandle } from "./pin-board" -import { NewSessionWordmark, type LodgerBall } from "./wordmark" +import { NewSessionPinBoard } from "./pin-board" +import { NewSessionWordmark } from "./wordmark" +import { + createWordmarkState, + selectBrickBreaker, + selectPinBoard, + transitionWordmark, + type WordmarkEvent, +} from "./wordmark-machine" const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 @@ -49,17 +56,6 @@ export const ProviderTipSchema = Persistence.struct({ dismissedAt: Schema.Finite, }) -type WordmarkState = { - box?: HTMLDivElement - mode: "flat" | "open" | "closing" - game: boolean -} - -type BrickBreakerHandles = { - board?: PinBoardHandle - ball?: LodgerBall -} - export function NewSessionView(props: { composer: ComposerModel project: PromptProjectController @@ -74,7 +70,16 @@ export function NewSessionView(props: { const settings = useSettings() - const [wordmark, setWordmark] = createStore({ mode: "flat", game: false }) + const [wordmark, setWordmark] = createSignal(createWordmarkState(false)) + const [logo, setLogo] = createSignal() + + const dispatchWordmark = (event: WordmarkEvent) => + untrack(() => { + const next = transitionWordmark(wordmark(), event) + setWordmark(next.state) + + if (next.effects.some((effect) => effect.type === "focus.composer")) props.composer.restoreFocus() + }) const location = (project?: PromptProject) => props.location({ @@ -84,8 +89,6 @@ export function NewSessionView(props: { }, }) - // Imperative handles for the brick breaker; they are not reactive state. - const handles: BrickBreakerHandles = {} let panel!: HTMLDivElement return ( @@ -102,15 +105,11 @@ export function NewSessionView(props: {
setWordmark("box", box)} + ref={setLogo} + state={wordmark} + onEvent={dispatchWordmark} + play sound={settings.sounds.interfaceEnabled} - onOpen={() => setWordmark("mode", "open")} - onClose={() => setWordmark("mode", "closing")} - onFlat={() => setWordmark("mode", "flat")} - onPlay={(ball) => { - handles.ball = ball - setWordmark("game", true) - }} />
@@ -137,28 +136,25 @@ export function NewSessionView(props: {
- - {(logo) => ( + + {(board) => ( (handles.board = board)} + entrance={board().entrance} + closing={board().closing} + ref={(handle) => dispatchWordmark({ type: "board.mounted", board: handle })} /> )} - + {(game) => ( { - setWordmark("game", false) - props.composer.restoreFocus() - }} + onDone={() => dispatchWordmark({ type: "game.done" })} /> )} diff --git a/packages/app/src/new-session/wordmark-machine.test.ts b/packages/app/src/new-session/wordmark-machine.test.ts new file mode 100644 index 00000000000..74b92e88d5f --- /dev/null +++ b/packages/app/src/new-session/wordmark-machine.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test" +import type { PinBoardHandle } from "./pin-board" +import type { LodgerBall } from "./wordmark" +import { + createWordmarkState, + selectBrickBreaker, + selectPinBoard, + selectWordmarkAnimating, + selectWordmarkCanInteract, + selectWordmarkDropOver, + selectWordmarkFlatVisible, + selectWordmarkGrip, + selectWordmarkLifted, + selectWordmarkLodger, + transitionWordmark, + type WordmarkEffect, + type WordmarkEvent, + type WordmarkState, +} from "./wordmark-machine" + +// The machine only stores and hands over these handles; it never calls them. +const board: PinBoardHandle = { + collide: () => undefined, + left: () => 800, + play: () => {}, + steer: () => {}, + restore: () => {}, +} + +const ball: LodgerBall = { + radius: () => 5, + at: () => ({ x: 0, y: 0 }), + place: () => {}, + blink: () => {}, + done: () => {}, +} + +const logo = document.createElement("div") + +function replay(events: WordmarkEvent[], state: WordmarkState = createWordmarkState(false)) { + return events.reduce<{ state: WordmarkState; effects: readonly WordmarkEffect[] }>( + (step, event) => transitionWordmark(step.state, event), + { state, effects: [] }, + ) +} + +const release = (distance: number, options: { nearHole?: boolean; reducedMotion?: boolean } = {}): WordmarkEvent => ({ + type: "release", + distance, + nearHole: options.nearHole ?? false, + ball, + reducedMotion: options.reducedMotion ?? false, +}) + +const frame = (options: { inHole?: boolean; over?: boolean; reducedMotion?: boolean }): WordmarkEvent => ({ + type: "carry.frame", + inHole: options.inHole ?? false, + over: options.over ?? false, + reducedMotion: options.reducedMotion ?? false, +}) + +const grab: WordmarkEvent = { type: "grab" } + +const mounted: WordmarkEvent = { type: "board.mounted", board } + +// The lodger pulled out, the pin board mounted, and the lodger perched beside it. +const IDLE = replay([ + grab, + { type: "drag.slip", reducedMotion: false }, + mounted, + { type: "settled", phase: "show" }, +]).state + +const GAME = replay([grab, frame({ over: true }), release(40)], IDLE).state + +describe("wordmark lodger", () => { + test("pulling the lodger out pops the pin board, which stays mounted while the lodger perches", () => { + const show = replay([grab, release(30)]) + expect(show.state).toEqual({ status: "show", board: undefined, cue: 0 }) + expect(selectWordmarkGrip(show.state)).toBe("none") + expect(selectPinBoard(show.state, logo)).toEqual({ logo, entrance: "chain", closing: false }) + expect(selectWordmarkFlatVisible(show.state)).toBe(true) + + const shown = replay([mounted, { type: "cue.step", phase: "show", cue: 2 }], show.state) + expect(selectWordmarkFlatVisible(shown.state)).toBe(false) + + expect(IDLE).toEqual({ status: "idle", board }) + expect(selectWordmarkGrip(IDLE)).toBe("idle") + expect(selectWordmarkCanInteract(IDLE)).toBe(true) + expect(selectWordmarkAnimating(IDLE)).toBe(false) + }) + + test("a short pull snaps the lodger back into the hole", () => { + const snap = replay([grab, release(10)]) + expect(snap.state.status).toBe("snap") + expect(selectWordmarkGrip(snap.state)).toBe("hidden") + expect(selectPinBoard(snap.state, logo)).toBeUndefined() + expect(replay([{ type: "settled", phase: "snap" }], snap.state).state.status).toBe("hidden") + }) + + // Bug: the board handle stayed in a plain object after the pin board unmounted. + test("packing the lodger back drops the pin board and its handle", () => { + const pack = replay([grab, frame({ inHole: true })], IDLE) + expect(pack.state).toEqual({ status: "pack", board, cue: 0 }) + expect(selectPinBoard(pack.state, logo)).toEqual({ logo, entrance: "chain", closing: true }) + + const flat = transitionWordmark(pack.state, { type: "cue.step", phase: "pack", cue: 2 }) + expect(flat.state).toEqual({ status: "pack", board: undefined, cue: 2 }) + expect(selectPinBoard(flat.state, logo)).toBeUndefined() + + expect(transitionWordmark(flat.state, { type: "settled", phase: "pack" }).state).toEqual({ status: "hidden" }) + }) + + // Bug: the lodger settled at 0.7s while Brick Breaker exited until 1s; grabbing it then left it frozen in "game". + test("the lodger stays out of reach until Brick Breaker finishes its exit", () => { + expect(GAME).toEqual({ status: "game", board, ball, ballStage: "active" }) + expect(selectBrickBreaker(GAME)).toEqual({ board, ball }) + expect(selectWordmarkGrip(GAME)).toBe("none") + + const returning = transitionWordmark(GAME, { type: "ball.done" }).state + expect(selectWordmarkAnimating(returning)).toBe(true) + expect(selectWordmarkLifted(returning)).toBe(true) + + const landed = transitionWordmark(returning, { type: "settled", phase: "return" }).state + expect(landed).toEqual({ status: "game", board, ball, ballStage: "landed" }) + expect(selectBrickBreaker(landed)).toEqual({ board, ball }) + expect(selectWordmarkGrip(landed)).toBe("none") + expect(selectWordmarkCanInteract(landed)).toBe(false) + expect(selectWordmarkLifted(landed)).toBe(false) + expect(selectWordmarkAnimating(landed)).toBe(false) + expect(transitionWordmark(landed, grab).state).toBe(landed) + expect(transitionWordmark(landed, frame({ inHole: true })).state).toBe(landed) + + const done = transitionWordmark(landed, { type: "game.done" }) + expect(done).toEqual({ state: { status: "idle", board }, effects: [{ type: "focus.composer" }] }) + expect(selectBrickBreaker(done.state)).toBeUndefined() + expect(selectWordmarkGrip(done.state)).toBe("idle") + }) + + test("Brick Breaker ending before the lodger lands hands it over to its flight home", () => { + const returning = transitionWordmark(GAME, { type: "ball.done" }).state + const done = transitionWordmark(returning, { type: "game.done" }) + expect(done).toEqual({ state: { status: "return", board }, effects: [{ type: "focus.composer" }] }) + expect(transitionWordmark(done.state, { type: "settled", phase: "return" }).state).toEqual({ + status: "idle", + board, + }) + }) + + // Bug: reaching the hole straight from the composer returned before clearing the composer's drop highlight. + test("flicking from the composer into the hole clears the drop highlight", () => { + const over = replay([grab, frame({ over: true })], IDLE).state + expect(selectWordmarkDropOver(over)).toBe(true) + expect(transitionWordmark(over, frame({ over: true })).state).toBe(over) + + const pack = transitionWordmark(over, frame({ inHole: true, over: true })).state + expect(pack.status).toBe("pack") + expect(selectWordmarkDropOver(pack)).toBe(false) + }) + + test("a carried lodger hops on a tap, flies home when let go, and packs near the hole", () => { + const carry = transitionWordmark(IDLE, grab).state + expect(carry).toEqual({ status: "carry", board, over: false }) + expect(selectWordmarkLifted(carry)).toBe(true) + + const hop = transitionWordmark(carry, release(2)).state + expect(hop).toEqual({ status: "hop", board, cue: 0 }) + expect( + replay( + [ + { type: "cue.step", phase: "hop", cue: 1 }, + { type: "settled", phase: "hop" }, + ], + hop, + ).state, + ).toEqual(IDLE) + + const home = transitionWordmark(carry, release(40)).state + expect(home).toEqual({ status: "return", board }) + expect(transitionWordmark(home, { type: "settled", phase: "return" }).state).toEqual(IDLE) + + expect(transitionWordmark(carry, release(40, { nearHole: true })).state).toEqual({ status: "pack", board, cue: 0 }) + }) + + test("dropping on the composer plays only with a pin board and motion", () => { + const over = replay([grab, frame({ over: true })], IDLE).state + expect(transitionWordmark(over, release(40, { reducedMotion: true })).state).toEqual(IDLE) + + const unmounted = replay([grab, { type: "drag.slip", reducedMotion: false }, { type: "settled", phase: "show" }]) + const noBoard = replay([grab, frame({ over: true }), release(40)], unmounted.state).state + expect(noBoard).toEqual({ status: "return", board: undefined }) + }) + + test("reduced motion skips every animation but still mounts and unmounts the pin board", () => { + const idle = replay([grab, { type: "drag.slip", reducedMotion: true }]) + expect(idle.state).toEqual({ status: "idle", board: undefined }) + expect(selectPinBoard(idle.state, logo)).toBeDefined() + expect(replay([grab, release(30, { reducedMotion: true })]).state.status).toBe("idle") + expect(replay([grab, release(10, { reducedMotion: true })]).state.status).toBe("hidden") + + const carry = transitionWordmark(IDLE, grab).state + expect(transitionWordmark(carry, release(2, { reducedMotion: true })).state).toEqual(IDLE) + expect(transitionWordmark(carry, release(40, { reducedMotion: true })).state).toEqual(IDLE) + const hidden = transitionWordmark(carry, frame({ inHole: true, reducedMotion: true })).state + expect(hidden).toEqual({ status: "hidden" }) + expect(selectPinBoard(hidden, logo)).toBeUndefined() + }) + + test("the static pin board shows no lodger and sweeps in", () => { + const state = createWordmarkState(true) + expect(selectWordmarkLodger(state)).toBe(false) + expect(selectPinBoard(state, logo)).toEqual({ logo, entrance: "sweep", closing: false }) + expect(selectPinBoard(state, undefined)).toBeUndefined() + expect(transitionWordmark(state, mounted).state).toBe(state) + }) + + test("ignores events that do not apply to the current phase", () => { + const hidden = createWordmarkState(false) + + for (const event of [ + release(40), + frame({ inHole: true }), + { type: "cue.step", phase: "show", cue: 1 }, + { type: "settled", phase: "show" }, + { type: "ball.done" }, + { type: "game.done" }, + ] satisfies WordmarkEvent[]) { + expect(transitionWordmark(hidden, event)).toEqual({ state: hidden, effects: [] }) + } + + const show = replay([grab, release(30), { type: "cue.step", phase: "show", cue: 2 }]).state + expect(transitionWordmark(show, { type: "cue.step", phase: "show", cue: 1 }).state).toBe(show) + expect(transitionWordmark(show, { type: "cue.step", phase: "pack", cue: 3 }).state).toBe(show) + expect(transitionWordmark(IDLE, mounted).state).toBe(IDLE) + }) +}) diff --git a/packages/app/src/new-session/wordmark-machine.ts b/packages/app/src/new-session/wordmark-machine.ts new file mode 100644 index 00000000000..92276572527 --- /dev/null +++ b/packages/app/src/new-session/wordmark-machine.ts @@ -0,0 +1,237 @@ +import type { PinBoardHandle } from "./pin-board" +import type { LodgerBall } from "./wordmark" + +/** Pulling the lodger further than this, in logo units, pops every pin out into the pin board. */ +export const LODGER_POP = 26 + +/** A carried lodger released closer than this to where it was picked up hops in place. */ +const LODGER_TAP = 4 + +/** + * The new session wordmark and the lodger that lives in its final "e". `board` is the static pin board (the + * `pins` story) with no lodger. Pulling the lodger out (`drag`) either snaps it back (`snap`) or pops the pin board + * out (`show`); from its perch (`idle`) it can be carried (`carry`) back into the hole (`pack`), dropped on the + * composer to play Brick Breaker (`game`), or let go to hop home (`hop`, `return`). + * + * `board` holds the pin board's handle while it is mounted. `cue` counts the timed steps of an animation already + * run, so each runs once. In `game`, `ballStage` follows the ball after Brick Breaker ends: it flies home + * (`returning`), then waits there (`landed`) until Brick Breaker finishes its exit and unmounts. + */ +export type WordmarkState = + | { readonly status: "board" } + | { readonly status: "hidden" } + | { readonly status: "drag" } + | { readonly status: "snap" } + | { readonly status: "show"; readonly board: PinBoardHandle | undefined; readonly cue: number } + | { readonly status: "idle"; readonly board: PinBoardHandle | undefined } + | { readonly status: "hop"; readonly board: PinBoardHandle | undefined; readonly cue: number } + | { readonly status: "carry"; readonly board: PinBoardHandle | undefined; readonly over: boolean } + | { + readonly status: "game" + readonly board: PinBoardHandle + readonly ball: LodgerBall + readonly ballStage: "active" | "returning" | "landed" + } + | { readonly status: "return"; readonly board: PinBoardHandle | undefined } + | { readonly status: "pack"; readonly board: PinBoardHandle | undefined; readonly cue: number } + +export type WordmarkEvent = + | { readonly type: "board.mounted"; readonly board: PinBoardHandle } + | { readonly type: "grab" } + | { readonly type: "drag.slip"; readonly reducedMotion: boolean } + /** `nearHole`: the lodger, its drop point or the pointer is within the "e" hole's pull. */ + | { + readonly type: "release" + readonly distance: number + readonly nearHole: boolean + readonly ball: LodgerBall + readonly reducedMotion: boolean + } + /** Sent only when the carried lodger reaches the hole or crosses the composer's edge. */ + | { readonly type: "carry.frame"; readonly inHole: boolean; readonly over: boolean; readonly reducedMotion: boolean } + | { readonly type: "cue.step"; readonly phase: "show" | "hop" | "pack"; readonly cue: number } + | { readonly type: "settled"; readonly phase: "snap" | "show" | "hop" | "return" | "pack" } + /** Brick Breaker ended and sent the ball home. */ + | { readonly type: "ball.done" } + /** Brick Breaker finished its exit and unmounts. */ + | { readonly type: "game.done" } + +export type WordmarkEffect = { readonly type: "focus.composer" } + +export type WordmarkTransition = { + readonly state: WordmarkState + readonly effects: readonly WordmarkEffect[] +} + +const BOARD: WordmarkState = Object.freeze({ status: "board" }) + +const HIDDEN: WordmarkState = Object.freeze({ status: "hidden" }) + +const DRAG: WordmarkState = Object.freeze({ status: "drag" }) + +const SNAP: WordmarkState = Object.freeze({ status: "snap" }) + +const NONE: readonly WordmarkEffect[] = Object.freeze([]) + +const FOCUS: readonly WordmarkEffect[] = Object.freeze([Object.freeze({ type: "focus.composer" as const })]) + +export function createWordmarkState(pins: boolean) { + return pins ? BOARD : HIDDEN +} + +export function transitionWordmark(state: WordmarkState, event: WordmarkEvent): WordmarkTransition { + return { state: next(state, event), effects: event.type === "game.done" && state.status === "game" ? FOCUS : NONE } +} + +function next(state: WordmarkState, event: WordmarkEvent): WordmarkState { + if (event.type === "board.mounted") { + if (!("board" in state) || state.board === event.board) return state + + return { ...state, board: event.board } + } + + if (event.type === "grab") { + if (state.status === "hidden") return DRAG + + if (state.status === "idle") return { status: "carry", board: state.board, over: false } + + return state + } + + if (event.type === "drag.slip") return state.status === "drag" ? pop(event.reducedMotion) : state + + if (event.type === "release") return release(state, event) + + if (event.type === "carry.frame") { + if (state.status !== "carry") return state + + if (event.inHole) return pack(state.board, event.reducedMotion) + + return state.over === event.over ? state : { ...state, over: event.over } + } + + if (event.type === "cue.step") { + if (state.status !== "show" && state.status !== "hop" && state.status !== "pack") return state + + if (state.status !== event.phase || event.cue <= state.cue) return state + + // The pin board unmounts once the last pin slots back in. + if (state.status === "pack" && event.cue >= 2) return { ...state, cue: event.cue, board: undefined } + + return { ...state, cue: event.cue } + } + + if (event.type === "settled") return settle(state, event.phase) + + if (state.status !== "game") return state + + if (event.type === "ball.done") return state.ballStage === "active" ? { ...state, ballStage: "returning" } : state + + if (state.ballStage === "returning") return { status: "return", board: state.board } + + return { status: "idle", board: state.board } +} + +/** The static pin board shows no lodger. */ +export function selectWordmarkLodger(state: WordmarkState) { + return state.status !== "board" +} + +/** The pin board is mounted from the moment the lodger pops it out until the last pin slots back into the "e". */ +export function selectPinBoard(state: WordmarkState, logo: HTMLDivElement | undefined) { + if (!logo || state.status === "hidden" || state.status === "drag" || state.status === "snap") return + + if (state.status === "pack" && state.cue >= 2) return + + return { + logo, + entrance: state.status === "board" ? ("sweep" as const) : ("chain" as const), + closing: state.status === "pack", + } +} + +export function selectBrickBreaker(state: WordmarkState) { + if (state.status !== "game") return + + return { board: state.board, ball: state.ball } +} + +export function selectWordmarkFlatVisible(state: WordmarkState) { + if (state.status === "show") return state.cue < 2 + + return state.status === "hidden" || state.status === "drag" || state.status === "snap" || state.status === "pack" +} + +/** Where the lodger can be grabbed: in the "e" counter, on its perch beside the board, or nowhere. */ +export function selectWordmarkGrip(state: WordmarkState) { + if (state.status === "hidden" || state.status === "drag" || state.status === "snap") return "hidden" + + if (state.status === "idle" || state.status === "carry" || state.status === "hop" || state.status === "return") + return "idle" + + return "none" +} + +/** Carried, returning, or in play, the lodger crosses the composer, so it is lifted above it. */ +export function selectWordmarkLifted(state: WordmarkState) { + if (state.status === "game") return state.ballStage !== "landed" + + return state.status === "carry" || state.status === "return" +} + +export function selectWordmarkDropOver(state: WordmarkState) { + return state.status === "carry" && state.over +} + +export function selectWordmarkAnimating(state: WordmarkState) { + if (state.status === "game") return state.ballStage === "returning" + + return state.status !== "board" && state.status !== "hidden" && state.status !== "idle" +} + +/** The lodger follows the pointer and can be grabbed only while it rests. */ +export function selectWordmarkCanInteract(state: WordmarkState) { + return state.status === "hidden" || state.status === "idle" +} + +function release(state: WordmarkState, event: Extract): WordmarkState { + if (state.status === "drag") { + if (event.distance > LODGER_POP) return pop(event.reducedMotion) + + return event.reducedMotion ? HIDDEN : SNAP + } + + if (state.status !== "carry") return state + + if (event.distance < LODGER_TAP) + return event.reducedMotion ? { status: "idle", board: state.board } : { status: "hop", board: state.board, cue: 0 } + + if (state.over && state.board && !event.reducedMotion) + return { status: "game", board: state.board, ball: event.ball, ballStage: "active" } + + if (event.nearHole) return pack(state.board, event.reducedMotion) + + return event.reducedMotion ? { status: "idle", board: state.board } : { status: "return", board: state.board } +} + +function pop(reducedMotion: boolean): WordmarkState { + return reducedMotion ? { status: "idle", board: undefined } : { status: "show", board: undefined, cue: 0 } +} + +function pack(board: PinBoardHandle | undefined, reducedMotion: boolean): WordmarkState { + return reducedMotion ? HIDDEN : { status: "pack", board, cue: 0 } +} + +function settle(state: WordmarkState, phase: Extract["phase"]): WordmarkState { + if (phase === "return" && state.status === "game") + return state.ballStage === "returning" ? { ...state, ballStage: "landed" } : state + + if (state.status !== phase) return state + + if (state.status === "snap" || state.status === "pack") return HIDDEN + + if (state.status === "show" || state.status === "hop" || state.status === "return") + return { status: "idle", board: state.board } + + return state +} diff --git a/packages/app/src/new-session/wordmark.stories.tsx b/packages/app/src/new-session/wordmark.stories.tsx index 2a87621b2c8..0fb0655ef49 100644 --- a/packages/app/src/new-session/wordmark.stories.tsx +++ b/packages/app/src/new-session/wordmark.stories.tsx @@ -1,7 +1,7 @@ -import { Show } from "solid-js" -import { createStore } from "solid-js/store" +import { Show, createSignal, untrack } from "solid-js" import { NewSessionPinBoard } from "./pin-board" import { NewSessionWordmark } from "./wordmark" +import { createWordmarkState, selectPinBoard, transitionWordmark, type WordmarkEvent } from "./wordmark-machine" export default { title: "App/New Session/Wordmark", @@ -10,10 +10,9 @@ export default { } function Panel(props: { pins: boolean }) { - const [state, setState] = createStore({ - box: undefined as HTMLDivElement | undefined, - mode: (props.pins ? "open" : "flat") as "flat" | "open" | "closing", - }) + const [state, setState] = createSignal(createWordmarkState(props.pins)) + const [logo, setLogo] = createSignal() + const dispatch = (event: WordmarkEvent) => untrack(() => setState(transitionWordmark(state(), event).state)) return (
- setState("box", box)} - pins={props.pins} - sound={() => true} - onOpen={() => setState("mode", "open")} - onClose={() => setState("mode", "closing")} - onFlat={() => setState("mode", "flat")} - /> + true} />
- - {(element) => ( + + {(board) => ( true} - entrance={props.pins ? "sweep" : "chain"} - closing={state.mode === "closing"} + entrance={board().entrance} + closing={board().closing} + ref={(handle) => dispatch({ type: "board.mounted", board: handle })} /> )} diff --git a/packages/app/src/new-session/wordmark.tsx b/packages/app/src/new-session/wordmark.tsx index 4874e223d9d..067bcda53c1 100644 --- a/packages/app/src/new-session/wordmark.tsx +++ b/packages/app/src/new-session/wordmark.tsx @@ -1,7 +1,19 @@ import { Logo } from "@opencode/ui/logo" import { prefersReducedMotion } from "@opencode/ui/motion" -import { For, onCleanup, onMount, Show } from "solid-js" +import { type Accessor, createEffect, For, onCleanup, onMount, Show } from "solid-js" import { PinSound } from "./pin-sound" +import { + LODGER_POP, + selectWordmarkAnimating, + selectWordmarkCanInteract, + selectWordmarkDropOver, + selectWordmarkFlatVisible, + selectWordmarkGrip, + selectWordmarkLifted, + selectWordmarkLodger, + type WordmarkEvent, + type WordmarkState, +} from "./wordmark-machine" import "./wordmark.css" // Each letter in the 234×42 logo viewBox, with its counter shadow, stem, and bottom-centre pivot. @@ -58,17 +70,36 @@ const LETTERS = [ // Lodger geometry, in the logo's 234×42 viewBox units. The lodger is drawn from its bottom centre. const W = 234 + const H = 42 + const HOLE = { x: 222, y: 15 } + const REST = { x: 222, y: 21 } + const PIN_SLOT = { x: 226.5, y: 10.5 } + const GROUND = 36 + const HOME = 246 -const POP = 26 + const SLIP = 60 + const HOLE_MAGNET = 24 -type Phase = "hidden" | "drag" | "snap" | "show" | "idle" | "hop" | "carry" | "return" | "pack" | "game" +const GRAB: WordmarkEvent = { type: "grab" } + +const BALL_DONE: WordmarkEvent = { type: "ball.done" } + +const HOP_LANDED: WordmarkEvent = { type: "cue.step", phase: "hop", cue: 1 } + +const SETTLED = { + snap: { type: "settled", phase: "snap" }, + show: { type: "settled", phase: "show" }, + hop: { type: "settled", phase: "hop" }, + return: { type: "settled", phase: "return" }, + pack: { type: "settled", phase: "pack" }, +} as const /** The lodger curled up as the brick breaker ball. Positions are the ball's centre, relative to the new session panel. */ export type LodgerBall = { @@ -92,6 +123,14 @@ type LetterPose = { opacity: number } +type DropTarget = { + el: HTMLElement | undefined + l: number + t: number + r: number + b: number +} + type Parts = { box: HTMLDivElement stage: HTMLDivElement @@ -114,15 +153,14 @@ type Parts = { */ export function NewSessionWordmark(props: { ref: (element: HTMLDivElement) => void - pins?: boolean + state: Accessor + onEvent: (event: WordmarkEvent) => void + /** Dropping the lodger on the composer starts Brick Breaker. */ + play?: boolean sound?: () => boolean - onOpen?: () => void - onClose?: () => void - onFlat?: () => void - onPlay?: (ball: LodgerBall) => void }) { // Read once: the lodger stays mounted after handing over to the pin board, and can be dragged back into the hole. - const lodger = !props.pins + const lodger = selectWordmarkLodger(props.state()) let box!: HTMLDivElement return ( @@ -143,10 +181,9 @@ export function NewSessionWordmark(props: { false)} - onOpen={() => props.onOpen?.()} - onClose={() => props.onClose?.()} - onFlat={() => props.onFlat?.()} - onPlay={props.onPlay} + state={props.state} + onEvent={props.onEvent} + play={!!props.play} />
@@ -162,10 +199,9 @@ export function NewSessionWordmark(props: { function WordmarkLodger(props: { box: HTMLDivElement sound: () => boolean - onOpen: () => void - onClose: () => void - onFlat: () => void - onPlay?: (ball: LodgerBall) => void + state: Accessor + onEvent: (event: WordmarkEvent) => void + play: boolean }) { let stage!: HTMLDivElement let svg!: SVGSVGElement @@ -180,18 +216,19 @@ function WordmarkLodger(props: { let blink!: SVGGElement const letters: SVGGElement[] = [] - onMount(() => - onCleanup( - mountLodger( - { box: props.box, stage, svg, flat, notch, letters, grip, neck, pin, critter, body, eyes, blink }, - props.sound, - props.onOpen, - props.onClose, - props.onFlat, - props.onPlay, - ), - ), - ) + onMount(() => { + const lodger = mountLodger( + { box: props.box, stage, svg, flat, notch, letters, grip, neck, pin, critter, body, eyes, blink }, + props.sound, + props.state, + props.onEvent, + props.play, + ) + + // Transitions the lodger did not start itself, such as Brick Breaker unmounting, still move its grip. + createEffect(() => lodger.sync(props.state())) + onCleanup(lodger.dispose) + }) return (
@@ -264,18 +301,25 @@ function WordmarkLodger(props: { function mountLodger( parts: Parts, sound: () => boolean, - onOpen: () => void, - onClose: () => void, - onFlat: () => void, - onPlay?: (ball: LodgerBall) => void, + state: Accessor, + onEvent: (event: WordmarkEvent) => void, + play: boolean, ) { const still = prefersReducedMotion() + + // Built once, so frames and pointer moves dispatch without allocating. + const events = { + slip: { type: "drag.slip", reducedMotion: still }, + hole: { type: "carry.frame", inHole: true, over: false, reducedMotion: still }, + over: { type: "carry.frame", inHole: false, over: true, reducedMotion: still }, + off: { type: "carry.frame", inHole: false, over: false, reducedMotion: still }, + } as const + const poses: LetterPose[] = LETTERS.map(() => ({ x: 0, y: 0, sx: 1, sy: 1, opacity: 1 })) + const c = { - phase: "hidden" as Phase, now: 0, t0: 0, - cue: 0, x: REST.x, y: REST.y, vx: 0, @@ -304,21 +348,54 @@ function mountLodger( dur: 0.34, arc: 10, } + const pointer = { x: HOLE.x, y: HOLE.y, inside: false, downX: HOLE.x, downY: HOLE.y } const impacts: { at: number; amp: number }[] = [] const timers = { raf: 0, blink: 0, last: 0 } const panel = parts.box.closest('[data-component="new-session"]') ?? document.documentElement // The lodger's screen rect, read on resize and pointer entry so pointer moves never force a layout. const rect = { left: 0, top: 0, scale: 1, px: 0, py: 0 } + // The composer, in lodger units while carrying; dropping the lodger on it starts the brick breaker. - const drop = { el: undefined as HTMLElement | undefined, l: 0, t: 0, r: 0, b: 0, over: false } + const drop: DropTarget = { el: undefined, l: 0, t: 0, r: 0, b: 0 } + // Last written DOM values, so frames and pointer moves only touch what changed. const written = new Map() + const put = (key: string, write: (value: string) => void, value: string) => { if (written.get(key) === value) return written.set(key, value) write(value) } + + const send = (event: WordmarkEvent) => { + onEvent(event) + const next = state() + sync(next) + + return next + } + + // Writes what the machine decides: the grip, the flat wordmark, the lift above the composer, and the drop target. + const sync = (s: WordmarkState) => { + put("grip", placeGrip, selectWordmarkGrip(s)) + put("flat", (value) => (parts.flat.style.display = value), selectWordmarkFlatVisible(s) ? "" : "none") + put( + "lifted", + (value) => parts.box.parentElement?.toggleAttribute("data-lifted", value === "1"), + selectWordmarkLifted(s) ? "1" : "0", + ) + + if (drop.el) + put( + "drop", + (value) => drop.el?.toggleAttribute("data-lodger-over", value === "1"), + selectWordmarkDropOver(s) ? "1" : "0", + ) + + if (s.status === "idle") scheduleBlink() + } + const measure = () => { const r = parts.svg.getBoundingClientRect() rect.left = r.left @@ -332,11 +409,19 @@ function mountLodger( // Snaps the flat wordmark stage to the exact integer-pitch footprint and rounded origin used by `NewSessionPinBoard`. const fitStage = () => { const l = parts.box.getBoundingClientRect() + if (!l.width) return const s = parts.box.closest('[data-component="new-session"]')?.getBoundingClientRect() const pitch = Math.max(3, Math.round(l.width / 78)) - const x = s ? Math.round(l.left - s.left + (l.width - pitch * 78) / 2) - (l.left - s.left) : (l.width - pitch * 78) / 2 - const y = s ? Math.round(l.top - s.top + (l.height - pitch * 14) / 2) - (l.top - s.top) : (l.height - pitch * 14) / 2 + + const x = s + ? Math.round(l.left - s.left + (l.width - pitch * 78) / 2) - (l.left - s.left) + : (l.width - pitch * 78) / 2 + + const y = s + ? Math.round(l.top - s.top + (l.height - pitch * 14) / 2) - (l.top - s.top) + : (l.height - pitch * 14) / 2 + parts.stage.style.left = `${x}px` parts.stage.style.top = `${y}px` parts.stage.style.width = `${pitch * 78}px` @@ -345,11 +430,13 @@ function mountLodger( } // Moves the interactive hit box between the "e" counter (when hidden) and HOME (when perched beside the board). - const placeGrip = (where: "hidden" | "idle" | "none") => { + const placeGrip = (where: string) => { if (where === "none") { parts.grip.style.display = "none" + return } + parts.grip.style.display = "" const box = where === "hidden" ? { x: 213, y: 8, w: 18, h: 14 } : { x: 234, y: 18, w: 24, h: 24 } parts.grip.style.left = `${(box.x / W) * 100}%` @@ -373,28 +460,44 @@ function mountLodger( c.now = ms / 1000 const dt = Math.min(1 / 30, Math.max(1 / 240, c.now - timers.last)) timers.last = c.now - if (c.phase === "drag") dragOut(dt) - if (c.phase === "snap") snapIn(dt) - if (c.phase === "show") show(c.now - c.t0) - if (c.phase === "hop") hop(c.now - c.t0) - if (c.phase === "carry") dragCarry(dt) - if (c.phase === "return") returnHome(c.now - c.t0) - if (c.phase === "pack") pack(c.now - c.t0) + + // Each step reads the state again: one step can hand over to the next within the same frame. + if (state().status === "drag") dragOut(dt) + + if (state().status === "snap") snapIn(dt) + const shown = state() + + if (shown.status === "show") show(c.now - c.t0, shown.cue) + const hopping = state() + + if (hopping.status === "hop") hop(c.now - c.t0, hopping.cue) + const carried = state() + + if (carried.status === "carry") dragCarry(dt, carried.over) + const returning = state() + + if (returning.status === "return" || (returning.status === "game" && returning.ballStage === "returning")) + returnHome(c.now - c.t0) + const packing = state() + + if (packing.status === "pack") pack(c.now - c.t0, packing.cue) render() - if (c.phase !== "hidden" && c.phase !== "idle" && c.phase !== "game") timers.raf = requestAnimationFrame(frame) + + if (!timers.raf && selectWordmarkAnimating(state())) timers.raf = requestAnimationFrame(frame) } const dragOut = (dt: number) => { const dx = pointer.x - pointer.downX const dy = pointer.y - pointer.downY const pull = Math.hypot(dx, dy) - if (pull > SLIP) return pop() + + if (pull > SLIP) return pop(send(events.slip)) const give = 1 / (1 + pull / 40) const ease = 1 - Math.exp(-dt * 30) c.x += (REST.x + dx * give - c.x) * ease c.y += (REST.y + dy * give - c.y) * ease const out = Math.hypot(c.x - REST.x, c.y - REST.y) - const stress = clamp01(pull / POP) + const stress = clamp01(pull / LODGER_POP) c.body = clamp01(out / 4.5) c.neck = c.body c.eyes = 1 @@ -429,24 +532,25 @@ function mountLodger( c.rot *= fade c.crouch *= fade c.wide = 1 + (c.wide - 1) * fade + for (const p of poses) { p.x *= fade p.y *= fade p.sx = 1 + (p.sx - 1) * fade p.sy = 1 + (p.sy - 1) * fade } + if (out > 0.05 || Math.hypot(c.vx, c.vy) > 0.5) return + send(SETTLED.snap) restHidden() + if (sound()) PinSound.slot(70, 78, c.x, W) blinkOnce() } const restHidden = () => { resetPoses(1) - parts.flat.style.display = "" - placeGrip("hidden") Object.assign(c, { - phase: "hidden", x: REST.x, y: REST.y, vx: 0, @@ -466,31 +570,26 @@ function mountLodger( look() } - const pop = () => { + // The machine has popped the pin board out: `show`, or `idle` with reduced motion. + const pop = (s: WordmarkState) => { c.now = performance.now() / 1000 Object.assign(c, { - phase: "show", t0: c.now, - cue: 0, fromX: c.x, fromY: c.y, neck: 0, }) - placeGrip("none") - onOpen() + if (sound()) PinSound.knock(c.x, W) - if (still) return settleIdle() + + if (s.status === "idle") return settleIdle() wake() } const showCues = [ { at: 0.45, run: () => land(0.38) }, - { - at: 0.95, - run: () => { - parts.flat.style.display = "none" - }, - }, + // The flat wordmark hides once this cue has run (`selectWordmarkFlatVisible`). + { at: 0.95, run: () => {} }, { at: 1.35, run: () => { @@ -503,9 +602,17 @@ function mountLodger( // Pulling one single pin out of the "e" triggers an elastic 3D pull-wave that pops every pin out toward // the viewer from right to left, then the lodger hops up to slot its starter pin back in. - const show = (t: number) => { - while (c.cue < showCues.length && t >= showCues[c.cue].at) showCues[c.cue++].run() - if (t >= 2.15) return settleIdle() + const show = (t: number, cue: number) => { + const reached = runCues(showCues, cue, t) + + if (reached !== cue) send({ type: "cue.step", phase: "show", cue: reached }) + + if (t >= 2.15) { + send(SETTLED.show) + + return settleIdle() + } + Object.assign(c, { rot: 0, crouch: 0, wide: 1, squint: 1, lookX: -1, lookY: 0, body: 1, eyes: 1 }) c.notch = t < 1.35 ? 1 : 0 c.pinOpacity = t < 1.35 ? 1 : 0 @@ -528,8 +635,10 @@ function mountLodger( c.pinY = c.y - 13 c.pinRot = u * 360 c.pinScale = 1.4 + return } + if (t < 1.05) { c.x = HOME c.y = GROUND @@ -538,8 +647,10 @@ function mountLodger( c.pinY = c.y - 12.5 - Math.sin(t * 8) * 0.8 c.pinRot = Math.sin(t * 5) * 10 c.pinScale = 1.3 + return } + if (t < 1.35) { const u = seg(t, 1.05, 1.35) c.x = lerp(HOME, 233, easeInOut(u)) @@ -548,8 +659,10 @@ function mountLodger( c.pinY = lerp(GROUND - 12.5, PIN_SLOT.y, easeInOut(u)) c.pinRot = 0 c.pinScale = lerp(1.3, 1, u) + return } + const u = seg(t, 1.35, 1.72) c.x = lerp(233, HOME, easeInOut(u)) c.y = lerp(18, GROUND, u) - Math.sin(Math.PI * u) * 10 @@ -558,10 +671,7 @@ function mountLodger( const settleIdle = () => { resetPoses(0) - parts.flat.style.display = "none" - placeGrip("idle") Object.assign(c, { - phase: "idle", x: HOME, y: GROUND, rot: 0, @@ -582,13 +692,14 @@ function mountLodger( } // Carrying the lodger from HOME: dangles under the pointer and looks toward the "e" hole. - const dragCarry = (dt: number) => { + const dragCarry = (dt: number, wasOver: boolean) => { const dx = pointer.x - pointer.downX const dy = pointer.y - pointer.downY const rawX = HOME + dx const rawY = GROUND + dy const dist = Math.hypot(rawX - HOLE.x, rawY - 5 - HOLE.y) - if (dist < 9) return startPack() + + if (dist < 9) return startPack(send(events.hole)) const magnet = clamp01((HOLE_MAGNET - dist) / HOLE_MAGNET) * 0.45 const targetX = lerp(rawX, REST.x, magnet) const targetY = lerp(rawY, REST.y, magnet) @@ -610,27 +721,21 @@ function mountLodger( c.pinY = PIN_SLOT.y c.pinRot = 0 c.pinScale = 1 + magnet * 0.25 + // Over the composer it curls up and peers down at it, and the composer sinks a little under it. - drop.over = - !!onPlay && !!drop.el && pointer.x > drop.l && pointer.x < drop.r && pointer.y > drop.t && pointer.y < drop.b - c.ball += ((drop.over ? 0.85 : 0) - c.ball) * (1 - Math.exp(-dt * 14)) - if (!drop.over) return + const over = + play && !!drop.el && pointer.x > drop.l && pointer.x < drop.r && pointer.y > drop.t && pointer.y < drop.b + + if (over !== wasOver) send(over ? events.over : events.off) + c.ball += ((over ? 0.85 : 0) - c.ball) * (1 - Math.exp(-dt * 14)) + + if (!over) return c.lookX = 0 c.lookY = 0.6 c.squint = 0.8 c.rot *= 0.4 } - const play = () => { - drop.over = false - render() - if (!onPlay) return - Object.assign(c, { phase: "game", rot: 0, crouch: 0, wide: 1, squint: 1 }) - placeGrip("none") - measure() - onPlay(ball) - } - const ball: LodgerBall = { radius: () => 5 * rect.scale, at: () => ({ x: rect.px + c.x * rect.scale, y: rect.py + (c.y - 5) * rect.scale }), @@ -650,7 +755,6 @@ function mountLodger( done: () => { measure() Object.assign(c, { - phase: "return", fromX: c.x, fromY: c.y, t0: performance.now() / 1000, @@ -659,6 +763,7 @@ function mountLodger( dur: 0.7, arc: 36, }) + send(BALL_DONE) wake() }, } @@ -672,30 +777,26 @@ function mountLodger( c.wide = 1 c.pinOpacity = 0 c.ball = Math.min(c.ball, 1 - easeInOut(u)) + if (t < c.dur) return tap(0.25) + send(SETTLED.return) settleIdle() } - const startPack = () => { + // The machine has sent the lodger back into the hole: `pack`, or `hidden` with reduced motion. + const startPack = (s: WordmarkState) => { c.now = performance.now() / 1000 resetPoses(0) - parts.flat.style.display = "" - placeGrip("none") Object.assign(c, { - phase: "pack", t0: c.now, - cue: 0, fromX: c.x, fromY: c.y, }) - onClose() + if (sound()) PinSound.knock(c.x, W) - if (still) { - onFlat() - restHidden() - return - } + + if (s.status === "hidden") return restHidden() wake() } @@ -706,20 +807,22 @@ function mountLodger( if (sound()) PinSound.slot(75, 78, HOLE.x, W) }, }, - { - at: 1.14, - run: () => { - onFlat() - blinkOnce() - }, - }, + // The pin board unmounts once this cue has run (`selectPinBoard`). + { at: 1.14, run: () => blinkOnce() }, ] // Reverse sequence: the lodger hops into the "e" hole, grabs the corner pin, and reels all 800 pins // from left ("o") back into the "e" before tucking itself back inside. - const pack = (t: number) => { - while (c.cue < packCues.length && t >= packCues[c.cue].at) packCues[c.cue++].run() - if (t >= 1.2) return restHidden() + const pack = (t: number, cue: number) => { + const reached = runCues(packCues, cue, t) + + if (reached !== cue) send({ type: "cue.step", phase: "pack", cue: reached }) + + if (t >= 1.2) { + send(SETTLED.pack) + + return restHidden() + } poses.forEach((p, i) => { const start = 0.32 + (i / 7) * 0.48 @@ -746,8 +849,10 @@ function mountLodger( c.pinY = lerp(PIN_SLOT.y, REST.y - 10.5, u) c.pinRot = u * 18 c.pinScale = 1.3 + return } + // Reels the wave in from "o" -> "e" while holding the corner pin. if (t < 0.9) { const u = seg(t, 0.3, 0.9) @@ -766,8 +871,10 @@ function mountLodger( c.pinY = lerp(REST.y - 10.5, PIN_SLOT.y, u) c.pinRot = (1 - u) * 18 c.pinScale = lerp(1.3, 1, u) + return } + // Slots the corner pin flush and sinks back into the "e" hole. const u = seg(t, 0.9, 1.14) c.x = REST.x @@ -783,17 +890,20 @@ function mountLodger( c.pinOpacity = 1 - u } - const hop = (t: number) => { + const hop = (t: number, cue: number) => { const u = seg(t, 0.08, 0.43) c.crouch = t < 0.08 ? 0.15 * (t / 0.08) : 0 c.y = GROUND - Math.sin(Math.PI * u) * 8 c.squint = 0.35 - if (t >= 0.43 && c.cue === 0) { - c.cue = 1 + + if (t >= 0.43 && cue === 0) { + send(HOP_LANDED) tap(0.3) } + if (t < 0.8) return - Object.assign(c, { phase: "idle", y: GROUND, crouch: 0, squint: 1 }) + send(SETTLED.hop) + Object.assign(c, { y: GROUND, crouch: 0, squint: 1 }) look() scheduleBlink() } @@ -802,8 +912,10 @@ function mountLodger( if (!pointer.inside) { c.lookX = 0 c.lookY = 0 + return } + const dx = pointer.x - c.x const dy = pointer.y - (c.y - 6) const d = Math.hypot(dx, dy) || 1 @@ -815,16 +927,22 @@ function mountLodger( const impact = (amp: number) => { impacts.push({ at: c.now, amp }) + while (impacts.length && c.now - impacts[0].at > 1) impacts.shift() } + const land = (amp: number) => { impact(amp) + if (sound()) PinSound.knock(c.x, W) } + const tap = (amp: number) => { if (amp) impact(amp) + if (sound()) PinSound.bounce(c.x, W, 3000) } + const blinkOnce = () => { if (still) return parts.blink.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0.1)" }, { transform: "scaleY(1)" }], { @@ -832,13 +950,20 @@ function mountLodger( easing: "ease-in-out", }) } + // Blinks are only scheduled while the eyes are showing, so the hidden lodger holds no timers. - const visible = () => c.phase === "idle" || (c.phase === "hidden" && c.eyes > 0.1) + const visible = () => { + const status = state().status + + return status === "idle" || (status === "hidden" && c.eyes > 0.1) + } + const scheduleBlink = () => { if (timers.blink || still || !visible()) return timers.blink = window.setTimeout( () => { timers.blink = 0 + if (!visible()) return blinkOnce() scheduleBlink() @@ -850,18 +975,23 @@ function mountLodger( const render = () => { const squash = impacts.reduce((sum, i) => { const t = c.now - i.at + return t < 0 ? sum : sum + i.amp * Math.exp(-t * 10) * Math.cos(t * 30) }, 0) + const sy = (1 - squash) * (1 - c.crouch) const sx = (1 + squash * 0.7) * (1 + c.crouch * 0.8) const attr = (el: Element, name: string) => (value: string) => el.setAttribute(name, value) + const opacity = (el: SVGElement) => (value: string) => { el.style.opacity = value } + // Stretching along an angle and rotating back keeps the eyes upright while the ball squashes on impact. const stretch = c.stretch ? ` rotate(${c.angle} 0 -5) scale(${1 + c.stretch} ${1 - c.stretch * 0.8}) rotate(${-c.angle} 0 -5)` : "" + put( "critter", attr(parts.critter, "transform"), @@ -878,14 +1008,6 @@ function mountLodger( }, `${Math.round(c.ball * 100) / 100}`, ) - if (drop.el) - put("drop", (value) => drop.el?.toggleAttribute("data-lodger-over", value === "1"), drop.over ? "1" : "0") - // Carried, returning, or in play, the lodger crosses the composer, so it is lifted above it. - put( - "lifted", - (value) => parts.box.parentElement?.toggleAttribute("data-lifted", value === "1"), - c.phase === "carry" || c.phase === "return" || c.phase === "game" ? "1" : "0", - ) put("eyes", opacity(parts.eyes), `${c.eyes}`) put( "look", @@ -893,6 +1015,7 @@ function mountLodger( `translate(${c.lookX} ${c.lookY - 6}) scale(${c.wide} ${c.wide * c.squint}) translate(0 6)`, ) put("neck", opacity(parts.neck), `${c.neck}`) + if (c.neck) { const len = Math.hypot(c.x - HOLE.x, c.y - 5 - HOLE.y) put("neck-x1", attr(parts.neck, "x1"), `${HOLE.x}`) @@ -901,8 +1024,10 @@ function mountLodger( put("neck-y2", attr(parts.neck, "y2"), `${c.y - 5}`) put("neck-w", attr(parts.neck, "stroke-width"), `${Math.max(2, Math.min(6, 40 / Math.max(1, len)))}`) } + put("notch", opacity(parts.notch), `${c.notch}`) put("pin", opacity(parts.pin), `${c.pinOpacity}`) + if (c.pinOpacity) put( "pin-at", @@ -912,6 +1037,7 @@ function mountLodger( poses.forEach((p, i) => { const cx = LETTERS[i].cx const el = parts.letters[i] + if (!el) return put(`letter-${i}`, (value) => (el.style.opacity = value), `${p.opacity}`) put( @@ -927,40 +1053,58 @@ function mountLodger( pointer.y = (event.clientY - rect.top) / rect.scale pointer.inside = true } + // Pointer moves only schedule a frame when the lodger visibly changes; far from the "e" they do nothing. const onMove = (event: PointerEvent) => { toUnits(event) c.near = clamp01((24 - Math.hypot(pointer.x - HOLE.x, pointer.y - HOLE.y)) / 14) - if (c.phase !== "hidden" && c.phase !== "idle") return - if (c.phase === "hidden") { + const s = state() + + if (!selectWordmarkCanInteract(s)) return + + if (s.status === "hidden") { const eyes = c.near * 0.55 + if (eyes === c.eyes) return c.eyes = eyes } - const gaze = { x: c.lookX, y: c.lookY } + + const gazeX = c.lookX + const gazeY = c.lookY look() - if (c.phase === "idle" && gaze.x === c.lookX && gaze.y === c.lookY) return + + if (s.status === "idle" && gazeX === c.lookX && gazeY === c.lookY) return scheduleBlink() wake() } + const onEnter = () => measure() + const onLeave = () => { pointer.inside = false c.near = 0 - if (c.phase !== "hidden" && c.phase !== "idle") return - if (c.phase === "hidden") c.eyes = 0 + const s = state() + + if (!selectWordmarkCanInteract(s)) return + + if (s.status === "hidden") c.eyes = 0 look() wake() } + const onGrab = (event: PointerEvent) => { - if (event.button !== 0 || (c.phase !== "hidden" && c.phase !== "idle")) return + const s = state() + + if (event.button !== 0 || !selectWordmarkCanInteract(s)) return event.preventDefault() event.stopPropagation() parts.grip.setPointerCapture(event.pointerId) measure() - if (c.phase === "idle") { + + if (s.status === "idle") { drop.el = panel.querySelector('[data-slot="new-session-composer"]') ?? undefined const r = drop.el?.querySelector('[data-component="composer"]')?.getBoundingClientRect() + if (r) { const p = panel.getBoundingClientRect() drop.l = (r.left - p.left - rect.px) / rect.scale @@ -969,39 +1113,58 @@ function mountLodger( drop.b = (r.bottom - p.top - rect.py) / rect.scale } } + toUnits(event) Object.assign(pointer, { downX: pointer.x, downY: pointer.y }) - c.phase = c.phase === "hidden" ? "drag" : "carry" + send(GRAB) wake() } + const onRelease = () => { - if (c.phase === "drag") { - if (Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY) > POP) return pop() - Object.assign(c, { phase: "snap", vx: 0, vy: 0 }) - if (still) restHidden() + const s = state() + + if (s.status !== "drag" && s.status !== "carry") return + const distance = Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY) + + if (s.status === "drag") { + const next = send({ type: "release", distance, nearHole: false, ball, reducedMotion: still }) + + if (next.status === "show" || next.status === "idle") return pop(next) + Object.assign(c, { vx: 0, vy: 0 }) + + if (next.status === "hidden") restHidden() render() + return } - if (c.phase !== "carry") return - const moved = Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY) - if (moved < 4) { - if (still) return settleIdle() - Object.assign(c, { phase: "hop", x: HOME, y: GROUND, rot: 0, t0: performance.now() / 1000, cue: 0 }) - wake() - return - } - if (drop.over && !still) return play() - drop.over = false + const dropX = HOME + (pointer.x - pointer.downX) const dropY = GROUND + (pointer.y - pointer.downY) - 5 - if ( + + const nearHole = Math.hypot(c.x - HOLE.x, c.y - 5 - HOLE.y) < HOLE_MAGNET || Math.hypot(dropX - HOLE.x, dropY - HOLE.y) < HOLE_MAGNET || Math.hypot(pointer.x - HOLE.x, pointer.y - HOLE.y) < HOLE_MAGNET - ) - return startPack() - if (still) return settleIdle() - Object.assign(c, { phase: "return", fromX: c.x, fromY: c.y, t0: performance.now() / 1000, dur: 0.34, arc: 10 }) + + // Brick Breaker mounts during the dispatch and places the ball from this rect. + measure() + const next = send({ type: "release", distance, nearHole, ball, reducedMotion: still }) + + if (next.status === "idle") return settleIdle() + + if (next.status === "pack" || next.status === "hidden") return startPack(next) + + if (next.status === "game") { + Object.assign(c, { rot: 0, crouch: 0, wide: 1, squint: 1 }) + render() + + return + } + + if (next.status === "hop") Object.assign(c, { x: HOME, y: GROUND, rot: 0, t0: performance.now() / 1000 }) + + if (next.status === "return") + Object.assign(c, { fromX: c.x, fromY: c.y, t0: performance.now() / 1000, dur: 0.34, arc: 10 }) wake() } @@ -1010,7 +1173,7 @@ function mountLodger( // The panel can resize while the clamped wordmark keeps its size but moves, e.g. when the sidebar toggles. resize.observe(panel) fitStage() - placeGrip("hidden") + sync(state()) panel.addEventListener("pointermove", onMove, { passive: true }) panel.addEventListener("pointerenter", onEnter) panel.addEventListener("pointerleave", onLeave) @@ -1020,23 +1183,40 @@ function mountLodger( parts.grip.addEventListener("lostpointercapture", onRelease) render() - return () => { - cancelAnimationFrame(timers.raf) - clearTimeout(timers.blink) - resize.disconnect() - panel.removeEventListener("pointermove", onMove) - panel.removeEventListener("pointerenter", onEnter) - panel.removeEventListener("pointerleave", onLeave) - parts.grip.removeEventListener("pointerdown", onGrab) - parts.grip.removeEventListener("pointerup", onRelease) - parts.grip.removeEventListener("pointercancel", onRelease) - parts.grip.removeEventListener("lostpointercapture", onRelease) + return { + sync, + dispose: () => { + cancelAnimationFrame(timers.raf) + clearTimeout(timers.blink) + resize.disconnect() + panel.removeEventListener("pointermove", onMove) + panel.removeEventListener("pointerenter", onEnter) + panel.removeEventListener("pointerleave", onLeave) + parts.grip.removeEventListener("pointerdown", onGrab) + parts.grip.removeEventListener("pointerup", onRelease) + parts.grip.removeEventListener("pointercancel", onRelease) + parts.grip.removeEventListener("lostpointercapture", onRelease) + }, } } +// Runs each cue whose time has come, from the first one not yet run, and returns how many have run. +function runCues(cues: readonly { at: number; run: () => void }[], from: number, t: number) { + let cue = from + + while (cue < cues.length && t >= cues[cue].at) cues[cue++].run() + + return cue +} + const lerp = (a: number, b: number, t: number) => a + (b - a) * t + const clamp = (v: number, limit: number) => Math.max(-limit, Math.min(limit, v)) + const clamp01 = (v: number) => Math.max(0, Math.min(1, v)) + const seg = (t: number, from: number, to: number) => clamp01((t - from) / (to - from)) + const easeOut = (t: number) => 1 - Math.pow(1 - t, 3) + const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2) diff --git a/packages/app/src/new-session/workspace/branch-machine.test.ts b/packages/app/src/new-session/workspace/branch-machine.test.ts new file mode 100644 index 00000000000..882b89e6381 --- /dev/null +++ b/packages/app/src/new-session/workspace/branch-machine.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test" +import { + INITIAL_BRANCH_PICKER_STATE, + selectBranchBusy, + selectBranchInitial, + selectBranchPending, + selectBranchPopoverOpen, + transitionBranchPicker, + type BranchPickerEffect, + type BranchPickerEvent, + type BranchPickerState, +} from "./branch-machine" + +function replay(events: BranchPickerEvent[], state = INITIAL_BRANCH_PICKER_STATE) { + return events.reduce<{ state: BranchPickerState; effects: readonly BranchPickerEffect[] }>( + (step, event) => transitionBranchPicker(step.state, event), + { state, effects: [] }, + ) +} + +const target = (directory: string | undefined): BranchPickerEvent => ({ type: "target.changed", directory }) + +const open: BranchPickerEvent = { type: "open.change", open: true } + +const close: BranchPickerEvent = { type: "open.change", open: false } + +const loaded = (attempt: number, items = ["dev", "main"]): BranchPickerEvent => ({ + type: "branches.loaded", + attempt, + items, +}) + +const focus: BranchPickerEffect = { type: "focus.composer" } + +describe("branch picker", () => { + test("preloads branches for the project root and opens on them", () => { + const start = replay([target("/repo-a")]) + expect(start.state).toEqual({ status: "loading", directory: "/repo-a", attempt: 1, open: false }) + expect(start.effects).toEqual([{ type: "branches.fetch", attempt: 1, directory: "/repo-a" }]) + expect(selectBranchBusy(start.state)).toBe(true) + + const ready = replay([loaded(1)], start.state) + expect(selectBranchInitial(ready.state)).toEqual({ items: ["dev", "main"], failed: false }) + expect(selectBranchPopoverOpen(ready.state)).toBe(false) + + // Every open revalidates while the loaded list stays on screen. + const opened = transitionBranchPicker(ready.state, open) + expect(selectBranchPopoverOpen(opened.state)).toBe(true) + expect(selectBranchBusy(opened.state)).toBe(true) + expect(opened.effects).toEqual([{ type: "branches.fetch", attempt: 2, directory: "/repo-a" }]) + expect(selectBranchInitial(opened.state)).toEqual({ items: ["dev", "main"], failed: false }) + + const refreshed = transitionBranchPicker(opened.state, loaded(2, ["dev", "feature/new"])) + expect(selectBranchInitial(refreshed.state)).toEqual({ items: ["dev", "feature/new"], failed: false }) + expect(selectBranchBusy(refreshed.state)).toBe(false) + }) + + test("a pending open opens once branches arrive, and can be dismissed first", () => { + const pending = replay([target("/repo-a"), open]) + expect(selectBranchPending(pending.state)).toBe(true) + expect(selectBranchPopoverOpen(pending.state)).toBe(true) + expect(selectBranchInitial(pending.state)).toBeUndefined() + + expect(selectBranchPopoverOpen(transitionBranchPicker(pending.state, loaded(1)).state)).toBe(true) + + const dismissed = transitionBranchPicker(pending.state, { type: "pending.dismiss" }) + expect(dismissed.effects).toEqual([]) + expect(selectBranchPopoverOpen(transitionBranchPicker(dismissed.state, loaded(1)).state)).toBe(false) + }) + + // Bug: `open` stayed true across a project root change, so the new root's preload popped the picker open. + test("a project root change closes a pending picker and preloads the new root closed", () => { + const pending = replay([target("/repo-a"), open]) + const moved = transitionBranchPicker(pending.state, target("/repo-b")) + expect(moved.state).toEqual({ status: "loading", directory: "/repo-b", attempt: 2, open: false }) + expect(moved.effects).toEqual([ + { type: "branches.abort", attempt: 1 }, + { type: "branches.fetch", attempt: 2, directory: "/repo-b" }, + ]) + expect(selectBranchPopoverOpen(moved.state)).toBe(false) + + expect(transitionBranchPicker(moved.state, loaded(1)).state).toBe(moved.state) + expect(selectBranchPopoverOpen(transitionBranchPicker(moved.state, loaded(2)).state)).toBe(false) + }) + + test("a project root change closes an open picker and drops its revalidation", () => { + const opened = replay([target("/repo-a"), loaded(1), open]) + const moved = transitionBranchPicker(opened.state, target("/repo-b")) + expect(moved.effects).toEqual([ + { type: "branches.abort", attempt: 2 }, + { type: "branches.fetch", attempt: 3, directory: "/repo-b" }, + ]) + expect(selectBranchPopoverOpen(moved.state)).toBe(false) + expect(selectBranchInitial(moved.state)).toBeUndefined() + expect(selectBranchPopoverOpen(transitionBranchPicker(moved.state, loaded(3)).state)).toBe(false) + }) + + // Bug: an outside interaction that never reached `onCloseAutoFocus` left `outside` set, so the next open skipped + // returning focus to the composer. + test("an outside interaction does not suppress focus on a later open", () => { + const outside = replay([target("/repo-a"), loaded(1), open, { type: "interact.outside" }]) + expect(transitionBranchPicker(outside.state, close).effects).toEqual([]) + + // The picker unmounts before Kobalte closes it, then the draft returns to the same root. + const reopened = replay([target(undefined), target("/repo-a"), loaded(3), open], outside.state) + expect(selectBranchPopoverOpen(reopened.state)).toBe(true) + expect(transitionBranchPicker(reopened.state, { type: "branch.selected", branch: "dev" }).effects).toEqual([ + { type: "branch.choose", branch: "dev" }, + focus, + ]) + expect(transitionBranchPicker(reopened.state, close).effects).toEqual([focus]) + + // Closing an outside-dismissed picker and opening it again also starts clean. + const again = replay([close, open], outside.state) + expect(transitionBranchPicker(again.state, close).effects).toEqual([focus]) + }) + + // Bug: a slow revalidation that failed after the picker's own search answered replaced the list with an error. + test("the picker's own empty search supersedes a revalidation in flight", () => { + const failed = replay([target("/repo-a"), { type: "branches.failed", attempt: 1 }]) + expect(selectBranchInitial(failed.state)).toEqual({ items: [], failed: true }) + + const opened = transitionBranchPicker(failed.state, open) + expect(opened.effects).toEqual([{ type: "branches.fetch", attempt: 2, directory: "/repo-a" }]) + + const synced = transitionBranchPicker(opened.state, { + type: "branches.synced", + directory: "/repo-a", + items: ["main", "dev"], + }) + + expect(synced.effects).toEqual([{ type: "branches.abort", attempt: 2 }]) + expect(selectBranchInitial(synced.state)).toEqual({ items: ["main", "dev"], failed: false }) + expect(selectBranchBusy(synced.state)).toBe(false) + + expect(transitionBranchPicker(synced.state, { type: "branches.failed", attempt: 2 }).state).toBe(synced.state) + expect(transitionBranchPicker(synced.state, loaded(2, ["old"])).state).toBe(synced.state) + expect(transitionBranchPicker(synced.state, { type: "branches.timed-out", attempt: 2 }).state).toBe(synced.state) + + const elsewhere: BranchPickerEvent = { type: "branches.synced", directory: "/repo-b", items: ["x"] } + expect(transitionBranchPicker(opened.state, elsewhere).state).toBe(opened.state) + }) + + // Bug: the preload had no signal or deadline, so a hung request kept the trigger busy and the picker unopenable. + test("a hung preload times out into a picker that offers Retry", () => { + const pending = replay([target("/repo-a"), open]) + const timedOut = transitionBranchPicker(pending.state, { type: "branches.timed-out", attempt: 1 }) + expect(timedOut.effects).toEqual([{ type: "branches.abort", attempt: 1 }]) + expect(selectBranchPopoverOpen(timedOut.state)).toBe(true) + expect(selectBranchBusy(timedOut.state)).toBe(false) + expect(selectBranchInitial(timedOut.state)).toEqual({ items: [], failed: true }) + }) + + test("a failed or timed-out revalidation keeps the list on screen", () => { + const opened = replay([target("/repo-a"), loaded(1), open]) + const failed = transitionBranchPicker(opened.state, { type: "branches.failed", attempt: 2 }) + expect(failed.effects).toEqual([]) + expect(selectBranchInitial(failed.state)).toEqual({ items: ["dev", "main"], failed: false }) + expect(selectBranchBusy(failed.state)).toBe(false) + + const timedOut = transitionBranchPicker(opened.state, { type: "branches.timed-out", attempt: 2 }) + expect(timedOut.effects).toEqual([{ type: "branches.abort", attempt: 2 }]) + expect(selectBranchInitial(timedOut.state)).toEqual({ items: ["dev", "main"], failed: false }) + }) + + test("ignores results from superseded attempts", () => { + const opened = replay([target("/repo-a"), loaded(1), open]) + + for (const event of [ + loaded(1), + { type: "branches.failed", attempt: 1 }, + { type: "branches.timed-out", attempt: 1 }, + ] satisfies BranchPickerEvent[]) { + expect(transitionBranchPicker(opened.state, event).state).toBe(opened.state) + } + + const loading = replay([target("/repo-a"), target("/repo-b")]) + expect(transitionBranchPicker(loading.state, { type: "branches.timed-out", attempt: 1 })).toEqual({ + state: loading.state, + effects: [], + }) + }) + + test("the shortcut toggles the picker and returns focus; the palette only opens it", () => { + const keybind: BranchPickerEvent = { type: "command.select", source: "keybind" } + const palette: BranchPickerEvent = { type: "command.select", source: "palette" } + + const pending = replay([target("/repo-a"), keybind]) + expect(selectBranchPending(pending.state)).toBe(true) + expect(transitionBranchPicker(pending.state, palette).state).toBe(pending.state) + const unpended = transitionBranchPicker(pending.state, keybind) + expect(selectBranchPopoverOpen(unpended.state)).toBe(false) + expect(unpended.effects).toEqual([focus]) + + const opened = replay([loaded(1), keybind], unpended.state) + expect(selectBranchPopoverOpen(opened.state)).toBe(true) + expect(opened.effects).toEqual([{ type: "branches.fetch", attempt: 2, directory: "/repo-a" }]) + expect(transitionBranchPicker(opened.state, palette).state).toBe(opened.state) + const closed = transitionBranchPicker(opened.state, keybind) + expect(selectBranchPopoverOpen(closed.state)).toBe(false) + expect(closed.effects).toEqual([focus]) + }) + + test("Escape or the trigger returns focus; an outside click keeps it where the user put it", () => { + const opened = replay([target("/repo-a"), loaded(1), open]) + expect(transitionBranchPicker(opened.state, close).effects).toEqual([focus]) + + const outside = replay([{ type: "interact.outside" }, close], opened.state) + expect(outside.effects).toEqual([]) + expect(selectBranchPopoverOpen(outside.state)).toBe(false) + }) + + test("leaving worktree creation drops the request and stays inactive", () => { + const pending = replay([target("/repo-a"), open]) + const inactive = transitionBranchPicker(pending.state, target(undefined)) + expect(inactive.state).toEqual({ status: "inactive", attempt: 1 }) + expect(inactive.effects).toEqual([{ type: "branches.abort", attempt: 1 }]) + expect(transitionBranchPicker(inactive.state, open).state).toBe(inactive.state) + expect(transitionBranchPicker(inactive.state, target(undefined)).state).toBe(inactive.state) + }) + + test("dispose aborts the request in flight and ignores everything after", () => { + const opened = replay([target("/repo-a"), loaded(1), open]) + const disposed = transitionBranchPicker(opened.state, { type: "disposed" }) + expect(disposed.effects).toEqual([{ type: "branches.abort", attempt: 2 }]) + expect(transitionBranchPicker(disposed.state, loaded(2)).state).toBe(disposed.state) + expect(transitionBranchPicker(disposed.state, target("/repo-b")).effects).toEqual([]) + + expect(transitionBranchPicker(replay([target("/repo-a"), loaded(1)]).state, { type: "disposed" }).effects).toEqual( + [], + ) + }) +}) diff --git a/packages/app/src/new-session/workspace/branch-machine.ts b/packages/app/src/new-session/workspace/branch-machine.ts new file mode 100644 index 00000000000..b67cf4e1a08 --- /dev/null +++ b/packages/app/src/new-session/workspace/branch-machine.ts @@ -0,0 +1,228 @@ +/** A preload or revalidation that has not answered by then fails, so the picker can offer Retry. */ +export const BRANCH_PRELOAD_TIMEOUT_MS = 10_000 + +export type BranchList = { + readonly items: string[] + readonly failed: boolean +} + +/** + * The base-branch picker of a "New worktree" draft. Branches preload before the popover opens, so it never shows a + * smaller loading panel: opening during `loading` holds the popover open (`open: true`) until they arrive. In `ready`, + * every open revalidates while the loaded list stays on screen (`refreshing`). `popover: "outside"` is an open picker + * the user clicked or focused away from; it closes without moving focus back to the composer. + */ +export type BranchPickerState = + | { readonly status: "inactive"; readonly attempt: number } + | { readonly status: "loading"; readonly directory: string; readonly attempt: number; readonly open: boolean } + | { + readonly status: "ready" + readonly directory: string + readonly attempt: number + readonly initial: BranchList + readonly refreshing: boolean + readonly popover: "closed" | "open" | "outside" + } + | { readonly status: "disposed" } + +export type BranchPickerEvent = + /** The project root while the draft creates a worktree, otherwise `undefined`. */ + | { readonly type: "target.changed"; readonly directory: string | undefined } + | { readonly type: "open.change"; readonly open: boolean } + | { readonly type: "command.select"; readonly source: string | undefined } + /** A pointer, focus, blur or Escape outside the trigger while the popover waits for branches. */ + | { readonly type: "pending.dismiss" } + | { readonly type: "interact.outside" } + | { readonly type: "branch.selected"; readonly branch: string } + | { readonly type: "branches.loaded"; readonly attempt: number; readonly items: string[] } + | { readonly type: "branches.failed"; readonly attempt: number } + | { readonly type: "branches.timed-out"; readonly attempt: number } + /** The open picker's own empty-query search answered; it supersedes a revalidation still in flight. */ + | { readonly type: "branches.synced"; readonly directory: string; readonly items: string[] } + | { readonly type: "disposed" } + +export type BranchPickerEffect = + | { readonly type: "branches.fetch"; readonly attempt: number; readonly directory: string } + | { readonly type: "branches.abort"; readonly attempt: number } + | { readonly type: "branch.choose"; readonly branch: string } + | { readonly type: "focus.composer" } + +export type BranchPickerTransition = { + readonly state: BranchPickerState + readonly effects: readonly BranchPickerEffect[] +} + +type LiveState = Exclude + +type ReadyState = Extract + +export const INITIAL_BRANCH_PICKER_STATE: BranchPickerState = Object.freeze({ status: "inactive", attempt: 0 }) + +const DISPOSED: BranchPickerState = Object.freeze({ status: "disposed" }) + +const NONE: readonly BranchPickerEffect[] = Object.freeze([]) + +const FOCUS: BranchPickerEffect = Object.freeze({ type: "focus.composer" }) + +export function transitionBranchPicker(state: BranchPickerState, event: BranchPickerEvent): BranchPickerTransition { + if (state.status === "disposed") return unchanged(state) + + if (event.type === "disposed") return { state: DISPOSED, effects: abort(state) } + + if (event.type === "target.changed") return retarget(state, event.directory) + + if (state.status === "inactive") return unchanged(state) + + if (event.type === "open.change") return event.open ? open(state) : close(state) + + if (event.type === "command.select") { + if (!selectBranchPopoverOpen(state)) return open(state) + + // The palette reopens the picker it was opened over; the shortcut toggles it. + if (event.source === "palette") return unchanged(state) + + return closed(state, [FOCUS]) + } + + if (event.type === "pending.dismiss") return state.status === "loading" ? close(state) : unchanged(state) + + if (event.type === "branches.loaded") return settle(state, event.attempt, { items: event.items, failed: false }) + + if (event.type === "branches.failed") return settle(state, event.attempt, undefined) + + if (event.type === "branches.timed-out") { + const next = settle(state, event.attempt, undefined) + + return next.state === state + ? next + : { state: next.state, effects: [{ type: "branches.abort", attempt: event.attempt }] } + } + + if (state.status !== "ready") return unchanged(state) + + if (event.type === "interact.outside") + return state.popover === "open" ? { state: { ...state, popover: "outside" }, effects: NONE } : unchanged(state) + + if (event.type === "branch.selected") { + if (state.popover === "closed") return unchanged(state) + + return closed(state, [{ type: "branch.choose", branch: event.branch }, FOCUS]) + } + + if (event.directory !== state.directory) return unchanged(state) + + return { + state: { ...state, initial: { items: event.items, failed: false }, refreshing: false }, + effects: abort(state), + } +} + +/** Kobalte's `open`: the popover is open, or held open until branches arrive. */ +export function selectBranchPopoverOpen(state: BranchPickerState) { + if (state.status === "loading") return state.open + + return state.status === "ready" && state.popover !== "closed" +} + +/** The popover is held open without content, so Kobalte has no dismissable layer yet. */ +export function selectBranchPending(state: BranchPickerState) { + return state.status === "loading" && state.open +} + +export function selectBranchBusy(state: BranchPickerState) { + return state.status === "loading" || (state.status === "ready" && state.refreshing) +} + +/** The list the popover opens on; kept through a close so the content can animate out. */ +export function selectBranchInitial(state: BranchPickerState) { + return state.status === "ready" ? state.initial : undefined +} + +// A new target drops the previous root's request and list, and closes the picker. +function retarget(state: LiveState, directory: string | undefined): BranchPickerTransition { + if (directory === undefined) { + if (state.status === "inactive") return unchanged(state) + + return { state: { status: "inactive", attempt: state.attempt }, effects: abort(state) } + } + + if (state.status !== "inactive" && state.directory === directory) return unchanged(state) + + const attempt = state.attempt + 1 + + return { + state: { status: "loading", directory, attempt, open: false }, + effects: [...abort(state), { type: "branches.fetch", attempt, directory }], + } +} + +// Every open revalidates the loaded list. +function open(state: Exclude): BranchPickerTransition { + if (state.status === "loading") + return state.open ? unchanged(state) : { state: { ...state, open: true }, effects: NONE } + + if (state.popover !== "closed") return unchanged(state) + + const attempt = state.attempt + 1 + + return { + state: { ...state, attempt, refreshing: true, popover: "open" }, + effects: [...abort(state), { type: "branches.fetch", attempt, directory: state.directory }], + } +} + +function close(state: Exclude): BranchPickerTransition { + if (state.status === "loading") + return state.open ? { state: { ...state, open: false }, effects: NONE } : unchanged(state) + + if (state.popover === "closed") return unchanged(state) + + // An outside click or focus keeps focus where the user put it. + return closed(state, state.popover === "outside" ? NONE : [FOCUS]) +} + +function closed( + state: Exclude, + effects: readonly BranchPickerEffect[], +): BranchPickerTransition { + if (state.status === "loading") return { state: { ...state, open: false }, effects } + + return { state: { ...state, popover: "closed" }, effects } +} + +// A failed revalidation keeps the list on screen; only a failed preload offers Retry. +function settle( + state: Exclude, + attempt: number, + list: BranchList | undefined, +): BranchPickerTransition { + if (state.attempt !== attempt) return unchanged(state) + + if (state.status === "loading") { + const next: ReadyState = { + status: "ready", + directory: state.directory, + attempt, + initial: list ?? { items: [], failed: true }, + refreshing: false, + popover: state.open ? "open" : "closed", + } + + return { state: next, effects: NONE } + } + + if (!state.refreshing) return unchanged(state) + + return { state: { ...state, initial: list ?? state.initial, refreshing: false }, effects: NONE } +} + +function abort(state: LiveState): BranchPickerEffect[] { + if (state.status === "loading" || (state.status === "ready" && state.refreshing)) + return [{ type: "branches.abort", attempt: state.attempt }] + + return [] +} + +function unchanged(state: BranchPickerState): BranchPickerTransition { + return { state, effects: NONE } +} diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index 98e2202256b..be9cc481fca 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -1,4 +1,4 @@ -import { createEffect, createResource, Show } from "solid-js" +import { createComputed, createEffect, createSignal, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Popover } from "@kobalte/core/popover" import { makeEventListener } from "@solid-primitives/event-listener" @@ -9,6 +9,17 @@ import { Tooltip } from "@opencode/ui/tooltip" import { Typeahead } from "@opencode/ui/typeahead" import { useLanguage } from "@/runtime/i18n/language" import { useCommand } from "@/shell/commands/command" +import { + BRANCH_PRELOAD_TIMEOUT_MS, + INITIAL_BRANCH_PICKER_STATE, + selectBranchBusy, + selectBranchInitial, + selectBranchPending, + selectBranchPopoverOpen, + transitionBranchPicker, + type BranchPickerEffect, + type BranchPickerEvent, +} from "./branch-machine" import type { NewSessionWorkspaceController } from "./controller" export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) { @@ -16,39 +27,60 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll const command = useCommand() - const [state, setState] = createStore({ open: false, truncated: false }) + const [layout, setLayout] = createStore({ truncated: false }) - let outside = false + const [picker, setPicker] = createSignal(INITIAL_BRANCH_PICKER_STATE) + + const requests = new Map }>() let trigger: HTMLButtonElement | undefined const creating = () => props.workspace.selection.value() === "create" - // Fetch before opening and retain the result across closes, rather than showing a smaller loading panel. - const [branches, branchActions] = createResource( - () => (creating() ? props.workspace.project.root() : undefined), - async (directory) => ({ - directory, - ...(await props.workspace.branches("").then( - (items) => ({ items, failed: false }), - () => ({ items: [], failed: true }), - )), - }), + const dispatch = (event: BranchPickerEvent) => + untrack(() => { + const next = transitionBranchPicker(picker(), event) + setPicker(next.state) + next.effects.forEach(run) + }) + + const run = (effect: BranchPickerEffect) => { + if (effect.type === "focus.composer") return props.onDone() + + if (effect.type === "branch.choose") return props.workspace.selection.branch(effect.branch) + + if (effect.type === "branches.abort") { + const request = requests.get(effect.attempt) + requests.delete(effect.attempt) + clearTimeout(request?.timer) + request?.controller.abort() + + return + } + + const attempt = effect.attempt + const controller = new AbortController() + const timer = setTimeout(() => dispatch({ type: "branches.timed-out", attempt }), BRANCH_PRELOAD_TIMEOUT_MS) + requests.set(attempt, { controller, timer }) + props.workspace.branches("", controller.signal).then( + (items) => settle(attempt, controller, { type: "branches.loaded", attempt, items }), + () => settle(attempt, controller, { type: "branches.failed", attempt }), + ) + } + + const settle = (attempt: number, controller: AbortController, event: BranchPickerEvent) => { + if (controller.signal.aborted) return + clearTimeout(requests.get(attempt)?.timer) + requests.delete(attempt) + dispatch(event) + } + + // Branches load before the picker opens, so it never shows a smaller loading panel. + createComputed(() => + dispatch({ type: "target.changed", directory: creating() ? props.workspace.project.root() : undefined }), ) - const initial = () => { - if (branches.state !== "ready" && branches.state !== "refreshing") return - const result = branches.latest - - return result?.directory === props.workspace.project.root() ? result : undefined - } - - const changeOpen = (open: boolean) => { - setState("open", open) - - // Revalidate on every open; the loaded list stays on screen meanwhile. - if (open && initial()) branchActions.refetch() - } + onCleanup(() => dispatch({ type: "disposed" })) command.register("new-session-branch", () => [ { @@ -57,26 +89,17 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll category: language.t("command.category.workspace"), keybind: "mod+alt+b", disabled: !creating() || !props.workspace.bar.branch(), - onSelect: (source) => { - changeOpen(source === "palette" || !state.open) - - // Pending opens have no close-autofocus handler. - if (!state.open && !initial()) props.onDone() - }, + onSelect: (source) => dispatch({ type: "command.select", source }), }, ]) - createEffect(() => { - if (!creating()) setState("open", false) - }) - // Until content mounts, Kobalte has no dismissable layer to cancel a pending open. createEffect(() => { - if (!state.open || initial()) return + if (!selectBranchPending(picker())) return const close = (event: Event) => { if (event.target instanceof Node && trigger?.contains(event.target)) return - setState("open", false) + dispatch({ type: "pending.dismiss" }) } makeEventListener(document, "pointerdown", close, { capture: true }) @@ -86,7 +109,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll window, "keydown", (event) => { - if (event.key === "Escape") setState("open", false) + if (event.key === "Escape") dispatch({ type: "pending.dismiss" }) }, { capture: true }, ) @@ -98,7 +121,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll : props.workspace.bar.branch() const observe = (element: HTMLSpanElement) => - createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth)) + createResizeObserver(element, () => setLayout("truncated", element.scrollWidth > element.clientWidth)) return ( @@ -114,11 +137,11 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll - {label()} + {label()}
} - disabled={!creating() && !state.truncated} + disabled={!creating() && !layout.truncated} class="flex min-w-0 max-w-[220px]" contentClass="max-w-[calc(100vw-32px)] break-all" > @@ -133,10 +156,15 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
} > - + dispatch({ type: "open.change", open })} + placement="bottom-start" + gutter={4} + > @@ -147,17 +175,15 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll - + {(initial) => ( (outside = true)} - onCloseAutoFocus={(event) => { - // An outside click keeps focus where the user put it. - if (event.defaultPrevented || outside) return (outside = false) - event.preventDefault() - props.onDone() + onInteractOutside={(event) => { + if (!event.defaultPrevented) dispatch({ type: "interact.outside" }) }} + // The machine returns focus to the composer, unless the user moved it outside. + onCloseAutoFocus={(event) => event.preventDefault()} > branch} selected={props.workspace.bar.branch()} - onSelect={(branch) => { - props.workspace.selection.branch(branch) - setState("open", false) - }} + onSelect={(branch) => dispatch({ type: "branch.selected", branch })} > {(branch) => (