diff --git a/packages/webapp/src/lody/MobileSessionStack.tsx b/packages/webapp/src/lody/MobileSessionStack.tsx new file mode 100644 index 00000000..09fd0b63 --- /dev/null +++ b/packages/webapp/src/lody/MobileSessionStack.tsx @@ -0,0 +1,282 @@ +/** + * Lody's phone experience, mounted for real. + * + * WHAT THIS IS. Upstream owns two mobile layouts — `ChatLanding` renders + * `MobileHomeScreen`/`MobileProjectScreen` below 768px, and `SessionDetail` + * renders its own floating-header layout with a tab sheet and a menu sheet. + * Neither one is reachable as upstream intends it unless a STACK holds them: + * `vendor/lody/packages/components/src/components/mobile/mobile-workspace-stack.tsx` + * keeps the landing mounted as a base and layers the session over it in a Vaul + * right-drawer, so back reveals the landing live and an edge swipe pops it. + * + * WHY IT IS REPRODUCED HERE AND NOT MOUNTED. Their stack is mounted by + * `MobileWorkspaceLayout`, inside `MainLayout` — the tree that also builds + * `LoroAppSidebar`, `MobileSidebarDrawer`, `TerminalDockHost`, the bug-report + * dialog and the desktop settings modal. BlitzOS mounts none of those (the + * scope record calls that area 3, and `plans/LODY-V1-SCOPE.md` §1 keeps it + * KILL), and their stack accepts one prop — `workspaceName`. It has no way to + * take the seven BlitzOS props the two pages need: seam patch 4's `readOnly`, + * seam patch 5's host tabs, seam patch 6's Side Chat rule, and seam patch 7's + * four v1 suppressions. Widening it would be a large vendor patch that upstream + * could not take, so the repo rule applies (`CLAUDE.md`: integration code lives + * in `packages/webapp/src/lody/`) and this file composes their components the + * way `TerminalTabsStrip.tsx` composes `SessionTabBar`. + * + * WHAT IS COPIED VERBATIM, AND WHY EACH ONE MATTERS. Their four decisions are + * reproduced with their reasons, because each one is a bug if it is dropped: + * + * 1. **The base stays mounted.** `ChatLanding` is never torn down when a + * session opens, so its scroll position and its unsent draft survive, and it + * is genuinely visible under the drawer during the slide. + * 2. **The last session is sticky.** The route — and so `sessionId` — is gone + * the instant back is pressed, but Vaul keeps the content mounted for the + * close animation. Without the ref the panel animates out blank. + * 3. **Close NAVIGATES, it does not pop history.** A PR or browser drawer can + * push then replace a same-session entry; popping one keeps `sessionId` + * present, so Vaul has written a half-open transform that controlled `open` + * never clears. + * 4. **`repositionInputs` follows the shell.** On mobile web the keyboard + * shrinks the layout viewport and Vaul captures the shrunk height as the + * initial one, so the composer stays lifted after the keyboard closes. + * BlitzOS is always mobile WEB, so `isNativeAppShell()` is always false + * here; the call is kept rather than folded to `false` so a merge that + * changes their rule reaches us. + * + * WHAT IS DELIBERATELY NOT COPIED. Their `AppThemeShell` wrapper around the + * session is kept (the page paints `bg-background`), and their `useTranslation` + * drawer title is kept. Their `mobileWorkspaceBaseContextAtom` write stays in + * `router.tsx`'s `ChatRoute`, exactly where their own chat route does it. + */ +import { useCallback, useRef } from "react"; +import { useAtomValue } from "jotai"; +import { useParams, useRouter, useSearch } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { ChatLanding } from "@lody/components/components/chat/chat-landing"; +import SessionDetail from "@lody/components/components/sessions/session-detail"; +import { AppThemeShell } from "@lody/components/components/app-theme-shell"; +import { Drawer, DrawerContent, DrawerTitle } from "@lody/components/ui/drawer"; +import { VaulDrawerBody } from "@lody/components/components/mobile/vaul-drawer-edge-back-zone"; +import { mobileWorkspaceBaseContextAtom } from "@lody/components/atoms"; +import { isNativeAppShell } from "@lody/components/lib/native-platform"; +import { + getMobileMainLayoutContentClassName, + getMobileMainLayoutRootClassName, +} from "@lody/components/components/workspace-layout-utils"; +import { LODY_CHAT_ROUTE, LODY_SESSION_ROUTE } from "./route-ids.js"; +import { TerminalTabsHost } from "./TerminalTabsStrip.js"; +import { useSurfaceTabs } from "./surface-tabs.js"; +import { lodyV1SuppressionProps } from "./v1-scope.js"; + +/** Seam patches 7 and 15's props, built once — the same constant `router.tsx` + * builds, for the same reason: the scope is a build-time decision. */ +const V1 = lodyV1SuppressionProps(); + +/** + * The drag zone's top offset, their constant and their arithmetic: the header + * (`3.5rem` + the safe area) plus the tab strip (`2.25rem`). It must clear the + * chrome above the conversation, or the back button and the tab strip stop + * being tappable because the edge zone covers them. + */ +const SESSION_DRAWER_BODY_TOP_INSET = "calc(3.5rem + 2.25rem + var(--safe-area-top, 0px))"; + +/** The shape their `mobileWorkspaceBaseContextAtom` holds, and the shape the + * chat route's search carries. Stated here because the vendor seam is `any`. */ +export interface MobileBaseContext { + context?: string; + machine?: string; + project?: string; + repo?: string; + /** + * OURS, and upstream's stack drops it. + * + * The BlitzOS rail's "New session" clears the landing's draft by writing a + * fresh key into the address (`use-lody-rail.ts`). Upstream has no such + * control, so their stack's search type omits the field and a phone would + * take a rail press and do nothing visible. It is read only while the + * address IS the landing, which is the only time the landing is on screen. + */ + resetDraftKey?: string; +} + +/** Their `getMobileBaseChatSearch`: the search that re-opens the base page the + * session was opened from. A context we do not recognise resolves to the plain + * landing rather than to a half-built address. */ +export function mobileBaseChatSearch(base: MobileBaseContext): MobileBaseContext { + if (base.context === "local") { + return base.machine !== undefined && base.project !== undefined + ? { context: "local", machine: base.machine, project: base.project } + : { context: "local" }; + } + if (base.context === "github") { + return base.repo !== undefined ? { context: "github", repo: base.repo } : { context: "github" }; + } + if (base.context === "chat") return { context: "chat" }; + return {}; +} + +export interface MobileSessionStackProps { + workspaceName: string; + /** Seam patch 4, fixed per router. See `LodySessionRouterOptions.readOnly`. */ + readOnly: boolean; +} + +export function MobileSessionStack({ workspaceName, readOnly }: MobileSessionStackProps) { + const { t } = useTranslation(); + const router = useRouter(); + const rememberedBase = useAtomValue(mobileWorkspaceBaseContextAtom); + const surfaceTabs = useSurfaceTabs(); + + // Live while the address IS the landing; absent on a session route, where the + // remembered context is what keeps the right page under the drawer. + const liveChatSearch = useSearch({ from: LODY_CHAT_ROUTE, shouldThrow: false }); + const base: MobileBaseContext = liveChatSearch ?? rememberedBase; + + const sessionParams = useParams({ from: LODY_SESSION_ROUTE, shouldThrow: false }); + const sessionSearch = useSearch({ from: LODY_SESSION_ROUTE, shouldThrow: false }); + const sessionId: string | undefined = sessionParams?.sessionId; + const open = sessionId !== undefined; + + // Their sticky ref. See the header comment, point 2. + const lastSessionRef = useRef<{ + id: string; + tab?: string; + pr?: number; + browser?: boolean; + } | null>(null); + if (sessionId !== undefined) { + lastSessionRef.current = { + id: sessionId, + tab: sessionSearch?.tab, + pr: sessionSearch?.pr, + browser: sessionSearch?.browser, + }; + } + const rendered = lastSessionRef.current; + + const handleClose = useCallback(() => { + void router.navigate({ + to: "/$workspaceName/chat", + params: { workspaceName }, + search: mobileBaseChatSearch(base), + replace: true, + }); + }, [base, router, workspaceName]); + + // The six props of seam patch 5. Seam patch 16 adds no prop of its own for + // the mobile tab sheet — it makes these same six reach it. + // `onSessionMissing` is the landing's own recovery and is raised by the + // session host alone. + const hostTabs = surfaceTabs === null + ? {} + : { + surfaceTabs: surfaceTabs.tabs, + activeSurfaceTabId: surfaceTabs.activeTabId, + onSurfaceTabSelect: surfaceTabs.onSelect, + onSurfaceTabClose: surfaceTabs.onClose, + onSessionTabSelect: surfaceTabs.onDeselect, + onSessionMissing: surfaceTabs.onSessionMissing, + }; + + const landing = ( + + ); + + // The landing keeps the host tabs' CONTENT and loses their STRIP on a phone; + // see `TerminalTabsHost`'s `showStrip`. + const landingHost = surfaceTabs === null + ? landing + : ; + + /* Upstream's own containers, reproduced. Their layout wraps the stack in + `getMobileMainLayoutRootClassName()` (a full-height flex row that carries + the native-keyboard offset), then a content column, then + `relative min-h-0 flex-1 overflow-hidden`. The mobile pages size + themselves against that chain — a `h-full` page inside an auto-height + parent collapses — and BlitzOS does not mount the layout that supplies it, + so this mount supplies it instead. */ + return ( +
+
+
+ {landingHost} + { + if (!next) handleClose(); + }} + > + + + {t("sessions.prTab.conversation", "Conversation")} + + + {rendered && ( + + + + )} + + + +
+
+
+ ); +} diff --git a/packages/webapp/src/lody/TerminalTabsStrip.tsx b/packages/webapp/src/lody/TerminalTabsStrip.tsx index 15798ed1..94135b5b 100644 --- a/packages/webapp/src/lody/TerminalTabsStrip.tsx +++ b/packages/webapp/src/lody/TerminalTabsStrip.tsx @@ -69,8 +69,22 @@ export function TerminalTabsStrip({ surfaceTabs }: TerminalTabsStripProps) { export function TerminalTabsHost(props: { surfaceTabs: SurfaceTabsBinding; landing: ReactNode; + /** + * Draw the strip above the landing. TRUE on desktop, FALSE on a phone. + * + * The strip is the DESKTOP tab affordance. Lody's phone layout carries its + * own — a bottom tab bar on the landing, a tab sheet inside a session — and a + * strip above them would be a second tab system on one screen, which is the + * thing `plans/LODY-TERMINAL-TABS.md` exists to prevent. A phone reaches a + * terminal from the BlitzOS drawer rail and from the mobile tab sheet + * (seam patch 16), so nothing is lost with the strip off. + * + * The CONTENT half is unchanged either way: a selected host tab still covers + * the landing, and every tab stays mounted. + */ + showStrip?: boolean; }) { - const { surfaceTabs, landing } = props; + const { surfaceTabs, landing, showStrip = true } = props; const activeTabId = surfaceTabs.activeTabId !== null && surfaceTabs.tabs.some((tab) => tab.id === surfaceTabs.activeTabId) @@ -78,7 +92,7 @@ export function TerminalTabsHost(props: { : null; return (
- + {showStrip ? : null}
` (`components/mobile/mobile-workspace-layout.tsx:83-88`). + * + * WHY THE OUTLET STILL RENDERS ON A PHONE. Their comment on the same lines says + * it, and it is not decoration: the leaf components return `null` on mobile but + * still RUN, and `ChatRoute`'s effect is what publishes the base context the + * stack reads to keep the right page under an open session. + * + * `matchRoute` is asked with the PATH form, `/$workspaceName/chat`, because that + * is what upstream asks and `_auth` contributes no URL segment. + */ +function authRouteComponent(readOnly: boolean) { + return function AuthRoute() { + const isMobile = useIsMobile(); + // SAFETY: `strict: false` returns the params of every active match; this + // component is a descendant of the `$workspaceName` route, which declares + // the parameter. `undefined` covers the render before a match resolves. + const params = useParams({ strict: false }) as { workspaceName?: string }; + const matchRoute = useMatchRoute(); + const workspaceName = params.workspaceName; + const onStackRoute = + workspaceName !== undefined + && (matchRoute({ to: "/$workspaceName/chat" }) !== false + || matchRoute({ to: "/$workspaceName/sessions/$sessionId" }) !== false); + return ( + <> + {isMobile && onStackRoute && workspaceName !== undefined ? ( + + ) : null} + + + ); + }; +} + +/** Their `routes/$workspaceName/_auth/chat.tsx`, mobile branch and all. + * + * ON A PHONE THIS ROUTE DRAWS NOTHING AND IS STILL LOAD-BEARING. The landing a + * phone sees is the stack's, so this component returns `null` — but its effect + * publishes the machine/project/repo the member was looking at, and the stack + * reads that atom to keep the right page beneath an open session. Their route + * does the same thing in the same place (`routes/$workspaceName/_auth/chat.tsx:39`). + */ function ChatRoute() { const { workspaceName } = useParams({ from: "/$workspaceName" }); - const search = useSearch({ from: "/$workspaceName/_auth/chat" }); + const search = useSearch({ from: LODY_CHAT_ROUTE }); const navigate = useNavigate(); const surfaceTabs = useSurfaceTabs(); + const isMobile = useIsMobile(); + const setMobileBaseContext = useSetAtom(mobileWorkspaceBaseContextAtom); // Selection steering corrects the current address in place; it is not a visit // to a new page, so the mirror always replaces (their comment, their rule). const onSelectionUrlSync = useCallback( @@ -195,6 +254,16 @@ function ChatRoute() { }, [navigate], ); + useEffect(() => { + if (!isMobile) return; + setMobileBaseContext({ + context: search.context, + machine: search.machine, + project: search.project, + repo: search.repo, + }); + }, [isMobile, search.context, search.machine, search.project, search.repo, setMobileBaseContext]); + if (isMobile) return null; const landing = ( ; } -/** Their `routes/$workspaceName/_auth/sessions/$sessionId.tsx`, minus mobile. +/** Their `routes/$workspaceName/_auth/sessions/$sessionId.tsx`, mobile branch + * and all. * * `SessionDetail` is deliberately NOT wrapped in `useDeferredValue`: it IS the * session identity boundary, and deferring it lets a message typed during a * switch be written to the session the member just left. Their comment on that - * route says so at length; this mount inherits the rule. */ + * route says so at length; this mount inherits the rule. + * + * ON A PHONE IT RENDERS NOTHING. `MobileSessionStack` draws the session as a + * drawer over the landing, from this route's own params and search, so drawing + * it here as well would mount `SessionDetail` twice. Their route returns `null` + * for the same reason (`:36`). */ function sessionDetailRouteComponent(readOnly: boolean) { return function SessionDetailRoute() { - const { sessionId } = useParams({ from: "/$workspaceName/_auth/sessions/$sessionId" }); - const search = useSearch({ from: "/$workspaceName/_auth/sessions/$sessionId" }); + const { sessionId } = useParams({ from: LODY_SESSION_ROUTE }); + const search = useSearch({ from: LODY_SESSION_ROUTE }); const surfaceTabs = useSurfaceTabs(); + const isMobile = useIsMobile(); // The six props of seam patch 5. Absent when no shell contributes tabs, // which is the render every upstream call site does and the one the // inertness test pins. @@ -261,6 +337,7 @@ function sessionDetailRouteComponent(readOnly: boolean) { onSessionTabSelect: surfaceTabs.onDeselect, onSessionMissing: surfaceTabs.onSessionMissing, }; + if (isMobile) return null; return ( workspaceRoute, id: "_auth", - component: RouteOutlet, + component: authRouteComponent(options.readOnly === true), }); const chatRoute = createRoute({ diff --git a/packages/webapp/src/lody/v1-scope.ts b/packages/webapp/src/lody/v1-scope.ts index 2f0474e8..691521b3 100644 --- a/packages/webapp/src/lody/v1-scope.ts +++ b/packages/webapp/src/lody/v1-scope.ts @@ -27,6 +27,15 @@ * boundary: flipping it on is what a host that stopped reporting connectivity * would do, not what BlitzOS does when it grows a feature. Seam patch 15. * + * MOBILE IS IN, AND IT MOVED THREE OF THESE (2026-09-02). Both real routes used + * to drop Lody's mobile branch, so area 23 was KILL and no flag had to reach a + * phone. The branch is mounted now (`MobileSessionStack.tsx`), and seam patch 16 + * is what makes the flags above answer there as well as on a desktop. Three of + * them reached NOTHING on that branch: `cloudSurfaces`, `agentRolesAndMcp` and + * `connectionStatus` all travel through `getSharedChatSurfaceProps`, which + * `session-detail.tsx` defines 952 lines BELOW its own `if (isMobile)` return. + * `plans/LODY-V1-SCOPE.md` §5 records the amendment. + * * HOW A FLAG REACHES THE VENDORED RENDERER. Two ways, and which one applies is * a property of the flag, not a preference: * @@ -139,6 +148,17 @@ export interface LodyV1SuppressionProps { * so there is no prop for it and nothing here to flip. */ readonly hideTeamScope: boolean; + /** + /** + * The settings gear in the mobile home header (seam patch 16). + * + * The same species as S9, the hint band's Go-to-settings button, so it reads + * the same flag: BlitzOS serves settings from its own chrome, and every Lody + * settings address in our tree is a stub that renders nothing + * (`router.tsx`, `SETTINGS_STUB_PATHS`). The desktop landing draws no gear, so + * this prop has no desktop twin. + */ + readonly hideSettingsEntry: boolean; /** * Every connection and sync surface Lody draws (IC64, IC65, seam patch 15). * Passed to `SessionDetail` and to `ChatLanding`; the session page rides it on @@ -159,5 +179,6 @@ export const lodyV1SuppressionProps = (): LodyV1SuppressionProps => ({ keyboardShortcutsAvailable: LODY_V1_SCOPE.keyboardShortcuts, hideLanguageServiceActions: !LODY_V1_SCOPE.languageService, hideTeamScope: !LODY_V1_SCOPE.cloudSurfaces, + hideSettingsEntry: !LODY_V1_SCOPE.cloudSurfaces, hideConnectionStatus: !LODY_V1_SCOPE.connectionStatus, }); diff --git a/packages/webapp/test/lody-mobile-mount.test.tsx b/packages/webapp/test/lody-mobile-mount.test.tsx new file mode 100644 index 00000000..49f8d905 --- /dev/null +++ b/packages/webapp/test/lody-mobile-mount.test.tsx @@ -0,0 +1,349 @@ +/** + * The mobile mount, pinned: Lody's phone experience, scrubbed to the v1 scope. + * + * Both real routes used to drop the mobile branch, so `plans/LODY-V1-SCOPE.md` + * called area 23 KILL and no scope flag had to reach a phone. The branch is + * mounted now, and the amendment is recorded in §5 of that file. + * + * THREE KINDS OF ASSERTION, AND THEY ANSWER DIFFERENT QUESTIONS. + * + * 1. **A host tab is reachable on a phone.** The mobile tab sheet is where a + * phone switches tabs — there is no `SessionTabBar` on that branch at all — + * and seam patch 5 deliberately left it unpatched. The real vendored sheet + * is mounted here and driven. + * 2. **Each scrubbed surface renders dark, and would render without the + * suppression.** The second half is what makes the first mean something: a + * test that only checks "the button is absent" also passes when the + * component stopped rendering at all. This is `lody-v1-scope.test.tsx`'s own + * rule, applied to the surfaces only a phone reaches. + * 3. **The wiring is what it claims to be.** That the routes mount the stack, + * that the stack passes the props, and that the vendored files carry seam + * patch 16's ADDITIONS. The subsequence pin in `lody-surface-tabs.test.tsx` + * proves the patch removed nothing undeclared; it cannot prove what the + * patch added, and these are that half. + * + * WHAT IS NOT HERE. Mounting `SessionSurface` at 390px needs a runtime, a Loro + * document and a daemon, so the whole-surface answer lives in the daemon-backed + * suites that skip wherever the bundle is absent. What that mount would show is + * decided by the three claims above plus `useIsMobile`, which is upstream's. + */ +import { I18nextProvider } from "react-i18next"; +import { Provider as JotaiProvider, createStore } from "jotai"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { MobileSessionTabSheet } from "@lody/components/components/mobile/mobile-session-tab-sheet"; +import { MobileHomeScreen } from "@lody/components/components/mobile/mobile-home-screen"; +import { initLodyI18n } from "../src/lody/i18n.js"; +import { LODY_V1_SCOPE, lodyV1SuppressionProps } from "../src/lody/v1-scope.js"; +import { TerminalTabsHost } from "../src/lody/TerminalTabsStrip.js"; +import type { SurfaceTabsBinding } from "../src/lody/surface-tabs.js"; +import { installLodyDomStubs } from "./lody-dom-stubs.js"; +import { readVendoredSource } from "./upstream-seam-pin.js"; +import { render, settle } from "./dom.js"; + +installLodyDomStubs(); + +const here = dirname(fileURLToPath(import.meta.url)); +const srcDir = join(here, "..", "src", "lody"); +const readOurs = (file: string): string => readFileSync(join(srcDir, file), "utf8"); + +const V1 = lodyV1SuppressionProps(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the vendor seam is untyped; see vendor-modules.d.ts +type AnyProps = Record; + +async function renderVendored(element: React.ReactNode) { + const i18n = initLodyI18n(); + const view = await render( + + {element} + , + ); + await settle(); + return view; +} + +// ── 1. A host tab is reachable through the mobile tab sheet ───────────────── + +/** One conversation and one host tab: the shape `session-detail.tsx` hands the + * sheet once seam patch 16's `mobileViewers` hunk appends the host's list. */ +const CONVERSATION: AnyProps = { + id: "session-1", + title: "The conversation", + active: true, + main: true, + running: false, + unread: false, + lastActivityAt: null, +}; + +function sheetProps(viewers: readonly AnyProps[], onSelectViewer: () => void): AnyProps { + return { + open: true, + onOpenChange: () => {}, + conversations: [CONVERSATION], + viewers, + onSelectConversation: () => {}, + onNewConversation: () => {}, + onSelectViewer, + }; +} + +describe("a workspace terminal is reachable through the mobile tab sheet", () => { + it("lists a host tab, with the host's own glyph, and reports the tap", async () => { + const onSelectViewer = vi.fn(); + const view = await renderVendored( + , + }, + ], + onSelectViewer, + )} + />, + ); + // The sheet is a Vaul drawer and portals to the body, so the assertion + // reads the document rather than the container. + const row = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "bash", + ); + expect(row, "the host tab is a row in the Viewers group").toBeDefined(); + expect(document.querySelector("[data-testid='host-glyph']")).not.toBeNull(); + row?.click(); + expect(onSelectViewer).toHaveBeenCalledWith("blitz-tab:7"); + await view.unmount(); + }); + + it("draws the fallback glyph for a host tab that brought none", async () => { + const view = await renderVendored( + {}, + )} + />, + ); + // Before seam patch 16 the kind was not in `VIEWER_ICON`, so the lookup + // answered `undefined` and rendering the row threw. Reaching this line at + // all is the assertion; the row is the visible half. + const row = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "logs", + ); + expect(row?.getAttribute("aria-current")).toBe("true"); + await view.unmount(); + }); +}); + +// ── 2. The mobile-only surfaces the v1 scope cuts ─────────────────────────── + +const HOME_LABELS: AnyProps = { + localTab: "Local", + githubTab: "GitHub", + projectsTab: "Projects", + onboarding: { title: "Lody runs on your computer", action: "Download Lody" }, + settingsTab: "Settings", +}; + +function homeProps(overrides: AnyProps): AnyProps { + return { + workspace: { id: "lw_1", name: "workspace" }, + machines: [], + selectedTab: "projects", + localProjects: [], + githubRepositories: [], + chats: [], + labels: HOME_LABELS, + ...overrides, + }; +} + +describe("the mobile home screen, scrubbed to the v1 scope", () => { + it("offers no GitHub sub-tab without the capability, and offers one with it", async () => { + const off = await renderVendored( + , + ); + expect(off.container.textContent).not.toContain("GitHub"); + await off.unmount(); + + const on = await renderVendored( + , + ); + expect(on.container.textContent, "the control renders without the suppression").toContain( + "GitHub", + ); + await on.unmount(); + }); + + it("draws no download-the-client takeover on an empty workspace, and draws one without the flag", async () => { + // The takeover's headline is the assertion: its action button falls back to + // the shipped i18n string when the caller supplies no label, so the title + // is the part that is the same in every locale this mount can be in. + const takeover = "Lody runs on your computer"; + const empty = homeProps({ selectedTab: "chat" }); + const off = await renderVendored(); + expect(off.container.textContent).not.toContain(takeover); + await off.unmount(); + + const on = await renderVendored(); + expect(on.container.textContent, "the takeover renders without the suppression").toContain( + takeover, + ); + await on.unmount(); + }); + + it("draws no settings gear when the host withholds the handler", async () => { + const off = await renderVendored(); + expect(off.container.querySelector("[aria-label='Settings']")).toBeNull(); + await off.unmount(); + + const on = await renderVendored( + {} })} />, + ); + expect( + on.container.querySelector("[aria-label='Settings']"), + "the gear renders without the suppression", + ).not.toBeNull(); + await on.unmount(); + }); +}); + +// ── 3. The landing host loses its strip on a phone and keeps its content ──── + +function binding(tabs: readonly AnyProps[]): SurfaceTabsBinding { + return { + // SAFETY: `SessionSurfaceTab` is the shape these literals are written in; + // the cast is only because the vendor seam types every prop as `any`. + tabs: tabs as never, + activeTabId: null, + onSelect: () => {}, + onClose: () => {}, + onDeselect: () => {}, + onSessionMissing: () => {}, + }; +} + +describe("the landing host on a phone", () => { + const TABS = [ + { id: "blitz-tab:7", label: "bash", content:
}, + ]; + + it("draws no strip, and still mounts every host tab", async () => { + const view = await render( + } + showStrip={false} + />, + ); + expect(view.container.querySelector(".lody-terminal-tabs-strip")).toBeNull(); + expect(view.container.querySelector("[data-testid='terminal-content']")).not.toBeNull(); + expect(view.container.querySelector("[data-testid='landing']")).not.toBeNull(); + await view.unmount(); + }); + + it("keeps the strip on a desktop, which is the default", async () => { + const view = await render( + } />, + ); + expect(view.container.querySelector(".lody-terminal-tabs-strip")).not.toBeNull(); + await view.unmount(); + }); +}); + +// ── 4. The wiring, read off our own sources and the vendored seam ─────────── + +describe("the mount is wired the way BLITZ-PATCHES.md and the scope record say", () => { + it("carries the mobile stack on the `_auth` layout and nothing lower", () => { + const router = readOurs("router.tsx"); + // The stack must outlive the chat -> session route change, so it hangs off + // the route both leaves share. Mounted on a leaf it would be torn down by + // the navigation it exists to animate. + expect(router).toContain("component: authRouteComponent(options.readOnly === true)"); + expect(router).toContain(" { + // If either `to` were not an address our tree holds, `matchRoute` would + // answer `false` for the life of the surface, the stack would never mount, + // and a phone would show a blank pane — a failure with no error in it. + // `lody-router-targets.test.ts` proves both addresses exist; this proves we + // ask for them by upstream's own spelling, so a merge that renames one + // fails here rather than at a member's first tap. + const theirs = readVendoredSource("components/mobile/mobile-workspace-layout.tsx"); + const ours = readOurs("router.tsx"); + for (const address of ["/$workspaceName/chat", "/$workspaceName/sessions/$sessionId"]) { + expect(theirs, `upstream matches on ${address}`).toContain(`matchRoute({ to: '${address}' })`); + expect(ours, `our layout matches on ${address}`).toContain(`matchRoute({ to: "${address}" })`); + } + }); + + it("passes every v1 suppression the two mounts need", () => { + const stack = readOurs("MobileSessionStack.tsx"); + for (const prop of [ + "hideProductHints={V1.hideProductHints}", + "hideAgentRoles={V1.hideAgentRoles}", + "hideSettingsEntry={V1.hideSettingsEntry}", + "hideConnectionStatus={V1.hideConnectionStatus}", + "hideCloudMenuItems={V1.hideCloudMenuItems}", + "hideNotificationPrompt={V1.hideNotificationPrompt}", + "keyboardShortcutsAvailable={V1.keyboardShortcutsAvailable}", + "hideLanguageServiceActions={V1.hideLanguageServiceActions}", + "readOnly={readOnly}", + "sideChatRequiresAssistantTurn", + ]) { + expect(stack, `MobileSessionStack passes ${prop}`).toContain(prop); + } + // The strip is the desktop affordance; the phone has Lody's own. + expect(stack).toContain("showStrip={false}"); + }); + + it("keeps every v1 flag off, and both new props derived from one", () => { + expect(LODY_V1_SCOPE.connectionStatus).toBe(false); + expect(V1.hideConnectionStatus).toBe(true); + // Settings shares `cloudSurfaces` with the hint band's Go-to-settings + // button, which is the same species of affordance. + expect(V1.hideSettingsEntry).toBe(!LODY_V1_SCOPE.cloudSurfaces); + }); + + it("carries seam patch 16's additions in the vendored tree", () => { + const sheet = readVendoredSource("components/mobile/mobile-session-tab-sheet.tsx"); + expect(sheet).toContain("'files' | 'custom'"); + expect(sheet, "the kind record stays total").toContain("custom: SquareDashed"); + expect(sheet, "the host's glyph wins").toContain("v.icon ?? ("); + + const detail = readVendoredSource("components/sessions/session-detail.tsx"); + // The mobile chat surface forwards what `getSharedChatSurfaceProps` gives + // the desktop one, which is defined below the mobile return. + expect(detail).toContain("hideNotificationPrompt={hideNotificationPrompt}"); + expect(detail).toContain("hideAgentRoles={hideAgentRoles}"); + expect(detail).toContain("gitHubAvailable={githubIntegrationAvailable}"); + // Connectivity is seam patch 15's, and its hunk 13 forwards the prop + // through the builder the mobile branch never reaches. Hunk 11 here is + // that one surface: the composer chip on a phone. + expect(detail).toContain("hideConnectionStatus={hideConnectionStatus}"); + // The host tab: listed, selectable, and mounted on the mobile branch. + expect(detail).toContain("for (const v of surfaceTabItems)"); + expect(detail).toContain("onSurfaceTabSelect?.(id)"); + expect(detail).toContain("!hasActiveViewerTab && !hasActiveSurfaceTab"); + + const landing = readVendoredSource("components/chat/chat-landing.tsx"); + expect(landing).toContain("hideConnectionStatus ? undefined : mobileHomeConnectionUiState"); + expect(landing).toContain("githubIntegrationAvailable ? handleConnectGitRepo : undefined"); + expect(landing).toContain("showGitHubProjects={githubIntegrationAvailable}"); + expect(landing).toContain("hideOnboarding={hideProductHints}"); + }); +}); diff --git a/packages/webapp/test/lody-seam-pin.test.ts b/packages/webapp/test/lody-seam-pin.test.ts new file mode 100644 index 00000000..706b94aa --- /dev/null +++ b/packages/webapp/test/lody-seam-pin.test.ts @@ -0,0 +1,271 @@ +/** + * The seam pin: every vendored file BlitzOS patches, against pristine upstream. + * + * WHY THIS IS ITS OWN FILE, AND IT IS NOT TIDINESS. These assertions read four + * text files off disk and import nothing. They used to live in + * `lody-surface-tabs.test.tsx`, whose file-level `beforeAll` imports the route + * tree — Monaco, shiki, three, the Loro WASM — and that import is the slowest + * thing in the suite. When the machine is loaded it exceeds the hook budget, + * and vitest then reports EVERY test in the file as skipped, the pin included. + * A check that is meant to fail loudly on an undeclared vendor edit must not be + * the first thing a slow machine turns off, so it now costs four `readFileSync` + * calls and nothing else. + * + * TWO KINDS OF CLAIM. + * + * 1. THE PATCHES ARE INERT. With every new prop absent, each patched file + * renders byte-for-byte what upstream renders. `expectSeam` proves it: each + * declared anchor is the line upstream really has at that number, and + * upstream MINUS those lines is still a subsequence of the patched file. See + * `upstream-seam-pin.ts` for the mechanics and `upstream-baseline/README.md` + * for the baselines' provenance. + * 2. THE DECLARATIONS AGREE. The props seam patch 5 states on both sides of the + * vendor boundary, and the writers seam patch 5 hunk 17 routes through the + * announcing setter. + * + * The BEHAVIOUR of each patch is pinned where it can be driven: + * `lody-surface-tabs.test.tsx` mounts the real `SessionTabBar` through both + * hosts, and `lody-mobile-mount.test.tsx` mounts the real mobile tab sheet and + * the real mobile home screen. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + expectSeam as expectSeamAgainstBaseline, + type SeamAnchor, +} from "./upstream-seam-pin.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, "..", "..", ".."); +const vendorDir = join(repoRoot, "vendor/lody/packages/components/src/components/sessions"); +const baselineDir = join(here, "upstream-baseline"); + +/** Paths in `upstream-seam-pin.ts` are relative to the components `src`; the + * two files this suite anchors by name live under `components/sessions`. */ +const expectSeam = (file: string, anchors: readonly SeamAnchor[]): void => + expectSeamAgainstBaseline(`components/sessions/${file}`, anchors); + +describe("the vendored seam is exactly what BLITZ-PATCHES.md declares", () => { + it("removes nothing from session-tab-bar.tsx but the declared anchors", () => { + expectSeam("session-tab-bar.tsx", [ + // hunk 1: the `react` import gains `type ReactNode` + [1, "import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';"], + // hunk 2: `ViewerTabItem` gains `'custom'` and `icon` + [42, "/** A viewer tab item (file or diff) displayed in the tab bar. */"], + [45, " type: 'file' | 'diff';"], + // hunk 4: `parentSession` becomes optional + [58, " parentSession: SessionMeta;"], + // hunk 3: `ViewerTabContent` draws the host's glyph + [466, " {tab.type === 'file' && tab.filePath ? ("], + [470, " )}"], + // hunk 5: `visibleTabIds` reads the parent id only when there is one + [726, " () => (showSessionTabs ? [parentSession.id, ...sortableIds] : sortableIds),"], + [727, " [parentSession.id, showSessionTabs, sortableIds]"], + // hunk 6: the parent strip item is guarded on the same thing + [765, " {showSessionTabs && ("], + ]); + }); + + it("removes nothing from session-detail.tsx but seam patches 4, 5, 6, 7, 15 and 16's anchors", () => { + expectSeam("session-detail.tsx", [ + // Seam patch 4's hunks are additive and remove nothing, which is why they + // are absent from this list and still covered by the subsequence check. + // So are three of seam patch 6's four; its fourth is the last anchor here. + // hunk 7: the `react` import gains `type ReactNode` + [ + 90, + "import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';", + ], + // hunk 11: the strip's variant follows the host's list + [5505, ' variant="session"'], + // hunk 14: an active host tab deselects the conversation surfaces + [5587, " const isActive = tabSession.id === activeTabSessionId;"], + [5621, " const isActive = draft.id === activeTabSessionId;"], + // hunk 17: the state setter becomes a chokepoint that announces the + // conversation tab it selected, so the `useState` keeps the raw setter + // under a new name + [756, " const [activeTabSessionIdRaw, setActiveTabSessionId] = useState("], + // hunk 18: the three writers that are a CORRECTION rather than a + // selection keep the raw setter + [947, " setActiveTabSessionId(nextInitialTabState.activeTabSessionId);"], + [2585, " setActiveTabSessionId((prev) =>"], + [2591, " setActiveTabSessionId((prev) => (prev === sessionId ? prev : sessionId));"], + // Seam patch 6 hunk 24: the Side Chat launcher gains a third reason to be + // disabled. Its other three hunks add lines and remove none, so they are + // covered by the subsequence check rather than named here. + [3358, " disabled: launcherState === 'disabled' || isCreatingSideSession,"], + // Seam patch 7 hunk 12: the page's GitHub state answers the + // `githubIntegration` capability, so the two lines of the memo it was + // built by are rewritten. Its other three hunks in this file add lines + // and remove none. + [1564, " () => getSessionGitHubState(activeTabSession, workspaceOwnerSession),"], + [1565, " [activeTabSession, workspaceOwnerSession]"], + // Seam patch 7 hunk 15: `session.focusInput` takes `useCommand`'s second + // argument, so its closing line gains one. This is the ONE `});` in the + // file the seam declares, which is why the anchor is a line number. + [3719, " });"], + // Seam patch 15 hunk 11: the page's catch-up flag answers + // `hideConnectionStatus`, so the one line the `useDelayedFlag` was built + // from is rewritten. This takes the mobile header's spinner and the + // `titleSyncing` override together. Its other four hunks in this file add + // lines and remove none. + [ + 1110, + " activeSessionTabId !== null && isSyncingRoomSyncState(activeSessionDocSyncState),", + ], + // ── Seam patch 16, the mobile branch ───────────────────────────────── + // Every anchor below is inside the `if (isMobile)` return, or inside a + // component only that branch mounts. Hunks that only ADD lines — the + // host-tab surface block, the appended `mobileViewers` entries, the + // forwarded `hide*` props — are covered by the subsequence check. + // + // hunk 16: `MobileProjectInfo` answers the `githubIntegration` + // capability instead of re-deriving the repo from the session + [567, " const repoFullName = (resolveProjectGitHubRepo(project) ?? session.repoFullName)?.trim() ?? '';"], + [568, " const isGitHub = project?.kind === 'github' || !!repoFullName;"], + // hunk 12: the menu sheet's visibility row takes `hideCloudMenuItems` + [4662, " if (activeSessionSharing) {"], + // hunk 13: Copy URL is guarded, which re-indents the push it wraps + [4752, " mobileMenuActions.push({"], + [4753, " id: 'copy-url',"], + [4754, ' icon: ,'], + [4755, " label: t('sessions.copyUrl', 'Copy URL'),"], + [4756, " onClick: () => {"], + [4757, " void handleCopyUrl();"], + [4758, " },"], + [4759, " });"], + // hunk 14: Share with team gains the third term the desktop menu has + [4763, " if (activeSessionSharing && activeSessionSharing.visibility !== 'team') {"], + // hunk 15: Change owner gains the same third term + [4889, " isMultiMemberWorkspace && !activeSession.isArchived"], + // hunk 8: an active HOST tab hides the conversations and the drafts, + // which is seam patch 5 hunk 13's desktop rule on the mobile branch + [4908, " const isActive = !hasActiveViewerTab && tabSession.id === activeTabSessionId;"], + [4975, " const isActive = !hasActiveViewerTab && draft.id === activeTabSessionId;"], + ]); + }); + + it("removes nothing from the two mobile files but seam patch 16's anchors", () => { + expectSeamAgainstBaseline("components/mobile/mobile-session-tab-sheet.tsx", [ + // hunk 1: `ViewerTabEntry['kind']` gains `'custom'` + [58, " kind: 'file' | 'diff' | 'pr' | 'browser' | 'files';"], + // hunk 4: the row draws the host's glyph when it has one, which wraps + // the element it replaces + [233, "
+ + + ); +} + +function PrivateResourceHelpSheet({ + open, + onOpenChange, + labels, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + labels: MobileHomeScreenLabels; +}) { + const title = labels.privateHelpTitle ?? 'Private resources are only visible to you'; + return ( + + +
+ + + {title} + + {labels.privateHelpDescription ?? + 'Private machines, local projects, and their conversations are hidden from teammates. Share a machine in device settings or a project in project settings.'} + + +
+
+
+ ); +} + +/* Compact semantic age label ("now", "5m", "2h", "3d", "2mo", "1y") used + in the row's trailing slot. Mirrors `formatMobileAgeLabel` in + chat-landing.tsx; duplicated here so the home component stays + self-contained. */ +function formatHomeRowAgeLabel(dateValue: number | string | undefined | null): string { + if (dateValue == null) return ''; + const ts = typeof dateValue === 'number' ? dateValue : Date.parse(String(dateValue)); + if (!Number.isFinite(ts)) return ''; + const diffMs = Date.now() - ts; + if (diffMs < 60_000) return 'now'; + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo`; + return `${Math.floor(months / 12)}y`; +} + +/* Trailing-slot content for list rows on the Local + GitHub tabs. + Stacks the semantic age label on top with an unread-count pill + underneath — when there's nothing to show, the whole slot collapses + to `null` so Konsta's row layout doesn't reserve extra width. */ +function RowTrailingMeta({ + latestMessageAt, + unreadCount, +}: { + latestMessageAt?: number | null; + unreadCount?: number; +}) { + const ageLabel = formatHomeRowAgeLabel(latestMessageAt ?? null); + const unread = unreadCount ?? 0; + if (!ageLabel && unread === 0) return null; + return ( + + {ageLabel ? ( + {ageLabel} + ) : null} + {unread > 0 ? ( + + {unread > 99 ? '99+' : unread} + + ) : null} + + ); +} + +/* Fast top→bottom exit for header search before the status/pull pill + mounts. Keep short so the handoff still tracks the finger; longer + values leave a blank band that feels laggy. Must match the CSS + `duration-150` on the search slot (150ms). */ +const HEADER_SEARCH_EXIT_MS = 150; + +/* Header search input. Fills the chrome-row middle between the workspace + avatar and trailing actions. Same surface as FloatingPill. */ +function HeaderSearchInput({ + value, + onChange, + placeholder, + ariaLabel, + clearAriaLabel, + className, +}: { + value: string; + onChange: (next: string) => void; + placeholder: string; + ariaLabel: string; + clearAriaLabel: string; + className?: string; +}) { + return ( + + ); +} + +/* Chat-list filter toggle — lives on the first group heading's trailing + edge (not next to search). Active tint when the pill bar is open; a + small primary dot marks applied filters while the bar is collapsed. */ +function ChatListFilterToggle({ + open, + hasActiveFilters, + ariaLabel, + onToggle, +}: { + open: boolean; + hasActiveFilters: boolean; + ariaLabel: string; + onToggle: () => void; +}) { + return ( + + ); +} + +/* Small uppercase label used as a section heading. Visually matches + the "最近常用" heading on the recents strip, but standalone (no + horizontal scroll, no trailing chips). */ +function AllItemsHeading({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function MachineStatusDot({ online }: { online: boolean }) { + return ( +