diff --git a/web/src/components/HomeToggle.tsx b/web/src/components/HomeToggle.tsx new file mode 100644 index 0000000..2b8afe8 --- /dev/null +++ b/web/src/components/HomeToggle.tsx @@ -0,0 +1,109 @@ +/** + * The Conductor ⇄ Sessions toggle (conductor-frontends-design §3.A). + * + * Two co-equal homes, one control. Neither is modal — this changes which + * session you are looking at and nothing else, and every session stays + * reachable from the list in both homes. That is §3's load-bearing constraint: + * the conductor is a lens over the same sessions, never a wall, and there must + * be no state a user can get stuck in. + * + * The resolution rules live in `lib/home.ts`; this is the control plus the one + * effect that acts on the choice. + */ + +import { Component, createEffect, createMemo, createSignal } from "solid-js"; + +import { findConductor, homeTarget, isOrdinarySession, type Home } from "../lib/home"; +import { activeHome, setHome } from "../state/layout"; +import { focusedSessionId, focusSession, sessionList } from "../state/sessions"; + +/** + * The last ordinary session focused before switching to the conductor, so + * switching back returns you to your work rather than an arbitrary first row. + * + * Module-level, not persisted: it is a within-visit convenience, and a + * remembered id from days ago is more likely to name a destroyed session than + * to be useful. + */ +const [lastSessionId, setLastSessionId] = createSignal(null); + +const HomeToggle: Component = () => { + const conductor = createMemo(() => findConductor(sessionList())); + + // Track where the user was in Sessions, so Conductor → Sessions can return + // them there. Recorded on every focus change that is not the conductor. + createEffect(() => { + const id = focusedSessionId(); + if (!id) return; + const s = sessionList().find((x) => x.id === id); + if (s && isOrdinarySession(s)) setLastSessionId(id); + }); + + // Acting on the choice is an effect rather than click handling, so the two + // stay consistent when the population changes underneath — e.g. the conductor + // is created while Conductor home is already selected. + createEffect(() => { + const target = homeTarget( + sessionList(), + activeHome(), + focusedSessionId() ?? null, + lastSessionId(), + ); + if (target) focusSession(target); + }); + + return ( +
+ + +
+ ); +}; + +const HomeButton: Component<{ + home: Home; + label: string; + title: string; + muted?: boolean; +}> = (props) => { + const active = () => activeHome() === props.home; + return ( + + ); +}; + +/** Reset the remembered session — for tests. */ +export function _resetHomeMemoryForTest(): void { + setLastSessionId(null); +} + +export default HomeToggle; diff --git a/web/src/components/StatusBar.tsx b/web/src/components/StatusBar.tsx index 8cfcc6e..990b768 100644 --- a/web/src/components/StatusBar.tsx +++ b/web/src/components/StatusBar.tsx @@ -10,6 +10,8 @@ import { Component, Show } from "solid-js"; +import HomeToggle from "./HomeToggle"; + import { ctxWindowColorClass, elapsedSince, @@ -44,6 +46,9 @@ const StatusBar: Component = () => { codeoid + {/* Two co-equal homes, always both available (§3.A) — never a mode. */} + + diff --git a/web/src/lib/home.test.ts b/web/src/lib/home.test.ts new file mode 100644 index 0000000..67c6f46 --- /dev/null +++ b/web/src/lib/home.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; + +import { + DEFAULT_HOME, + findConductor, + homeTarget, + isHome, + isOrdinarySession, +} from "./home"; +import type { SessionInfo } from "../protocol/types"; + +const s = (id: string, role?: "conductor" | "worker"): SessionInfo => + ({ id, name: id, ...(role ? { role } : {}) }) as SessionInfo; + +const CONDUCTOR = s("cond", "conductor"); +const WORKER = s("worker-scout-a", "worker"); +const WORK = s("api"); +const OTHER = s("web"); + +describe("isHome / DEFAULT_HOME", () => { + it("defaults to Sessions even once a conductor exists", () => { + // Silently relocating someone's home the first time they spawn a conductor + // is the "trapped in an orchestrated mode" feeling §3 exists to prevent. + expect(DEFAULT_HOME).toBe("sessions"); + }); + + it("rejects anything that is not a home, so stored junk falls back", () => { + expect(isHome("conductor")).toBe(true); + expect(isHome("sessions")).toBe(true); + expect(isHome("fleet")).toBe(false); + expect(isHome(undefined)).toBe(false); + expect(isHome(null)).toBe(false); + }); +}); + +describe("findConductor", () => { + it("finds it, and reports null rather than guessing when absent", () => { + expect(findConductor([WORK, CONDUCTOR, WORKER])?.id).toBe("cond"); + expect(findConductor([WORK, WORKER])).toBeNull(); + }); +}); + +describe("isOrdinarySession", () => { + it("accepts only a session with no role", () => { + expect(isOrdinarySession(WORK)).toBe(true); + expect(isOrdinarySession(CONDUCTOR)).toBe(false); + expect(isOrdinarySession(WORKER)).toBe(false); + }); + + it("EXCLUDES a role this client has never heard of", () => { + // The fail-safe, and the reason this is `role === undefined` rather than + // `role !== "conductor" && role !== "worker"`. The protocol deliberately + // allows roles a client does not know (session.create types role as an open + // string "so a future role from a newer client still type-checks"), and the + // negative form would silently opt every future kind into being a landing + // target. Workers are excluded because they vanish; inheriting that risk + // for kinds we know nothing about is the wrong default. + const future = { id: "x", name: "x", role: "sandbox" } as unknown as SessionInfo; + expect(isOrdinarySession(future)).toBe(false); + }); +}); + +describe("homeTarget — Conductor home", () => { + const all = [WORK, CONDUCTOR, WORKER]; + + it("focuses the conductor", () => { + expect(homeTarget(all, "conductor", "api", null)).toBe("cond"); + }); + + it("leaves focus alone when already on the conductor", () => { + // null means "don't touch it" — re-focusing would reset scroll for nothing. + expect(homeTarget(all, "conductor", "cond", null)).toBeNull(); + }); + + it("leaves focus alone when no conductor exists yet", () => { + // A normal state, not an error: the toggle offers to create one. + expect(homeTarget([WORK, OTHER], "conductor", "api", null)).toBeNull(); + }); +}); + +describe("homeTarget — Sessions home", () => { + const all = [WORK, OTHER, CONDUCTOR, WORKER]; + + it("returns to the session you came from", () => { + expect(homeTarget(all, "sessions", "cond", "web")).toBe("web"); + }); + + it("does nothing when you are not on the conductor", () => { + // You are already somewhere in Sessions; moving you would be the surprise + // this design avoids. + expect(homeTarget(all, "sessions", "api", "web")).toBeNull(); + }); + + it("falls back to an ordinary session when the remembered one is gone", () => { + expect(homeTarget(all, "sessions", "cond", "destroyed")).toBe("api"); + expect(homeTarget(all, "sessions", "cond", null)).toBe("api"); + }); + + it("never falls back onto a worker", () => { + // Workers are disposable and die with their task — landing on one is + // landing somewhere that is about to disappear. + expect(homeTarget([CONDUCTOR, WORKER], "sessions", "cond", null)).toBeNull(); + expect(homeTarget([CONDUCTOR, WORKER], "sessions", "cond", "worker-scout-a")).toBeNull(); + }); + + it("leaves focus alone when the conductor is the only session", () => { + expect(homeTarget([CONDUCTOR], "sessions", "cond", null)).toBeNull(); + }); + + it("never lands on an unknown future role, remembered or not", () => { + // Same fail-safe as isOrdinarySession, asserted through the real entry + // point: a new session kind must not become a landing target for free. + const future = { id: "fut", name: "fut", role: "sandbox" } as unknown as SessionInfo; + expect(homeTarget([CONDUCTOR, future], "sessions", "cond", null)).toBeNull(); + expect(homeTarget([CONDUCTOR, future], "sessions", "cond", "fut")).toBeNull(); + }); +}); diff --git a/web/src/lib/home.ts b/web/src/lib/home.ts new file mode 100644 index 0000000..fc66794 --- /dev/null +++ b/web/src/lib/home.ts @@ -0,0 +1,108 @@ +/** + * The two top-level homes — Conductor and Sessions (conductor-frontends-design + * §3.A) — and the rule for what each one focuses. + * + * §3 is the constraint the whole feature rests on: the conductor is a LENS over + * the same sessions, never a wall. So this is a navigation preference, not a + * mode — switching home changes which session you are looking at and nothing + * else. Every session stays reachable from the list in both homes, and there is + * no state a user can get stuck in. + * + * Pure functions: which session a home resolves to is the decision worth + * testing, and it needs no reactive root. + */ + +import type { SessionInfo } from "../protocol/types"; + +export type Home = "sessions" | "conductor"; + +/** + * Default home for a user who has never chosen. + * + * Sessions, deliberately — even once a conductor exists. Silently relocating + * someone's home the first time they spawn a conductor is exactly the "trapped + * in an orchestrated mode" feeling §3 exists to prevent, and a user who wants + * the conductor is one click (and one remembered preference) away. + */ +export const DEFAULT_HOME: Home = "sessions"; + +export function isHome(v: unknown): v is Home { + return v === "sessions" || v === "conductor"; +} + +/** The tenant's conductor, or null when none has been created yet. */ +export function findConductor(sessions: readonly SessionInfo[]): SessionInfo | null { + return sessions.find((s) => s.role === "conductor") ?? null; +} + +/** + * An ordinary coding session — one you own and drive, as opposed to the + * conductor or a disposable dispatch worker. The only kind this module will + * ever move focus TO. + * + * Tested as "has no role" rather than "is not conductor and not worker", and + * the difference is a fail-safe, not a style choice. `SessionInfo.role` is + * documented as *"Absent = normal session"*, and the protocol deliberately + * anticipates roles this client has not heard of — `session.create` types its + * role as an open string precisely "so a future role from a newer client still + * type-checks on the wire". + * + * So the two forms differ exactly when a new role appears: + * + * role === undefined → an unknown role is NOT ordinary (excluded) + * role !== "conductor" && ... → an unknown role IS ordinary (included) + * + * The second reads as more explicit and is the more dangerous of the two: it + * silently opts every future session kind into being a landing target. Since + * the whole reason workers are excluded is "do not send someone to a session + * that is about to disappear", inheriting that risk for kinds we know nothing + * about is the wrong default. An unknown role stays excluded until somebody + * deliberately adds it here. + */ +export function isOrdinarySession(s: SessionInfo): boolean { + return s.role === undefined; +} + +/** + * Which session a home should focus. + * + * Returns null to mean "leave the focus alone" — a distinct outcome from "focus + * nothing", and the right answer whenever the home has no better candidate than + * whatever the user is already reading. + * + * `lastSessionId` is the session the user was on before switching to the + * conductor, so switching back returns them to their work rather than to an + * arbitrary first row. It is ignored when that session has since been + * destroyed. + */ +export function homeTarget( + sessions: readonly SessionInfo[], + home: Home, + currentId: string | null, + lastSessionId: string | null, +): string | null { + if (home === "conductor") { + const conductor = findConductor(sessions); + // No conductor yet is a normal state, not an error: the toggle offers to + // create one, and until then the current session stays put. + return conductor && conductor.id !== currentId ? conductor.id : null; + } + + // Sessions home. Only act when the user is actually sitting on the conductor + // — otherwise they are already somewhere in Sessions and moving them would be + // the surprise this design is trying to avoid. + const current = sessions.find((s) => s.id === currentId) ?? null; + if (current?.role !== "conductor") return null; + + // Ordinary sessions only, on BOTH paths — see isOrdinarySession. Workers are + // disposable and die with their task, so landing on one is landing somewhere + // about to disappear, and a worker can legitimately be the last thing you + // looked at, having drilled into it from the fleet rail. + const remembered = lastSessionId + ? (sessions.find((s) => s.id === lastSessionId) ?? null) + : null; + if (remembered && isOrdinarySession(remembered)) return remembered.id; + + // Nothing remembered, or it was destroyed, or it was not an ordinary session. + return sessions.find(isOrdinarySession)?.id ?? null; +} diff --git a/web/src/state/layout.ts b/web/src/state/layout.ts index 116ea1d..8e32329 100644 --- a/web/src/state/layout.ts +++ b/web/src/state/layout.ts @@ -8,6 +8,8 @@ import { batch, createEffect, createSignal } from "solid-js"; +import { DEFAULT_HOME, isHome, type Home } from "../lib/home"; + const STORAGE_KEY = "codeoid.layout.v1"; interface LayoutState { @@ -16,6 +18,12 @@ interface LayoutState { rightPanePx: number; /** Session header collapse — when true, only a 1-line summary shows. */ headerCollapsed: boolean; + /** + * Which top-level home the user last chose (§3.A). A navigation preference, + * not a mode — see lib/home.ts. Persisted so the toggle is remembered rather + * than re-decided on every reload. + */ + home: Home; } const DEFAULTS: LayoutState = { @@ -23,6 +31,7 @@ const DEFAULTS: LayoutState = { leftSidebarCollapsed: false, rightPanePx: 576, // 36rem-ish headerCollapsed: false, + home: DEFAULT_HOME, }; const LIMITS = { @@ -56,6 +65,9 @@ function load(): LayoutState { typeof parsed.headerCollapsed === "boolean" ? parsed.headerCollapsed : DEFAULTS.headerCollapsed, + // Validated rather than cast: a stored value from a future build (or a + // hand-edited one) must fall back, not select a home that does not exist. + home: isHome(parsed.home) ? parsed.home : DEFAULTS.home, }; } catch { return DEFAULTS; @@ -72,6 +84,7 @@ const [rightPanePx, setRightPanePx] = createSignal(initial.rightPanePx); const [headerCollapsed, setHeaderCollapsedSig] = createSignal( initial.headerCollapsed, ); +const [home, setHomeSig] = createSignal(initial.home); /** Effective width for the left sidebar accounting for collapse. */ export function leftSidebarEffectivePx(): number { @@ -83,6 +96,13 @@ export const isLeftCollapsed = leftSidebarCollapsed; export const rightWidth = rightPanePx; export const isHeaderCollapsed = headerCollapsed; +/** The user's chosen top-level home (§3.A). */ +export const activeHome = home; + +export function setHome(next: Home): void { + setHomeSig(next); +} + // ── Mobile / narrow-viewport (Telegram Mini App) ────────────────────────── // Reactive viewport-width breakpoint. Below 768px the 3-pane grid is too @@ -144,6 +164,7 @@ createEffect(() => { leftSidebarCollapsed: leftSidebarCollapsed(), rightPanePx: rightPanePx(), headerCollapsed: headerCollapsed(), + home: home(), }; if (typeof localStorage === "undefined") return; if (persistTimer !== null) clearTimeout(persistTimer);