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 (
+
+ );
+}
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, " "],
+ ]);
+
+ expectSeamAgainstBaseline("components/mobile/mobile-home-screen.tsx", [
+ // hunk 24: the GitHub segment becomes conditional, which re-indents the
+ // object it wraps
+ [1132, " {"],
+ [1133, " key: 'github',"],
+ [1134, " label: githubLabel,"],
+ [1135, " icon: iosTheme ? ("],
+ [1136, ' '],
+ [1137, " ) : ("],
+ [1138, ' '],
+ [1139, " ),"],
+ [1140, " ref: githubRef,"],
+ [1141, " },"],
+ // hunk 24 again: the rendered sub-tab is pinned to Local without it
+ [1924, " active={selectedSubTab}"],
+ [1930, " {selectedSubTab === 'local' ? ("],
+ ]);
+ });
+
+ it("holds a baseline of the commit vendor/lody/UPSTREAM.md pins", () => {
+ // The baselines are only evidence while they are the pin's own bytes, and
+ // nothing else in the tree would notice them going stale. `docs/LODY-MERGE.md`
+ // §4 says to refresh them in the same change as the merge; this is what
+ // fails when that is forgotten.
+ const upstream = readFileSync(join(repoRoot, "vendor/lody/UPSTREAM.md"), "utf8");
+ const pin = /\| Pinned commit \| `([0-9a-f]{40})` \|/u.exec(upstream)?.[1];
+ expect(pin, "UPSTREAM.md still states a pinned commit").toBeDefined();
+ const readme = readFileSync(join(baselineDir, "README.md"), "utf8");
+ expect(readme, "the baselines name the commit they were taken from").toContain(pin ?? "");
+ });
+
+ it("declares the same six props on both sides of the seam", () => {
+ const detail = readFileSync(join(vendorDir, "session-detail.tsx"), "utf8");
+ for (const prop of [
+ "surfaceTabs?: readonly SessionSurfaceTab[];",
+ "activeSurfaceTabId?: string | null;",
+ "onSurfaceTabSelect?: (tabId: string) => void;",
+ "onSurfaceTabClose?: (tabId: string) => void;",
+ "onSessionTabSelect?: (tabId: string) => void;",
+ // Hunk 19, added in wave 3: the page returns above the strip when the
+ // session does not exist, and the host loses every tab with it.
+ "onSessionMissing?: (sessionId: string) => void;",
+ ]) {
+ expect(detail, `seam patch 5 declares ${prop}`).toContain(prop);
+ }
+ // Hunks 17-18, pinned as a CALL and not only as a declaration. A declared
+ // prop that nothing invokes is exactly the shape of the defect it fixes:
+ // the host keeps its tab selected, hunk 15 keeps drawing it, and a click on
+ // a session tab does nothing a member can see.
+ expect(detail, "the announcing setter exists").toContain(
+ "const setActiveTabSessionId = useCallback((tabId: string) => {",
+ );
+ expect(detail, "and it announces").toContain("onSessionTabSelectRef.current?.(tabId);");
+ });
+
+ /**
+ * THE CHOKEPOINT IS ONLY A CHOKEPOINT WHILE NOTHING WALKS AROUND IT.
+ *
+ * Ten call sites move the conversation selection and the first version of the
+ * fix notified from one of them, which left the strip's `+` opening a draft
+ * tab underneath the terminal — the same defect one button along. What the
+ * seam relies on now is that the raw setter has exactly three callers, all
+ * declared, so a merge that adds an eleventh writer either goes through the
+ * wrapper or fails here.
+ */
+ it("routes every conversation-tab SELECTION through the announcing setter", () => {
+ const detail = readFileSync(join(vendorDir, "session-detail.tsx"), "utf8");
+ const rawWrites = [...detail.matchAll(/^\s*setActiveTabSessionIdState\(/gmu)];
+ expect(
+ rawWrites.length,
+ "the raw setter is called only inside the wrapper and by hunk 18's three corrections",
+ ).toBe(4);
+
+ // The writers that were inert with the click-only notification, named so a
+ // merge that reroutes one of them says which.
+ const announcing = new Set(
+ [...detail.matchAll(/^\s*(?:void )?setActiveTabSessionId\((.+?)\);?$/gmu)].map(
+ (match) => match[1],
+ ),
+ );
+ for (const [argument, what] of [
+ ["tabId", "the strip's own tab click, and everything routed through it"],
+ ["draft.id", "the strip's + — a new draft tab"],
+ ["childSessionId", "a draft promoted to a real child session"],
+ ["sessionId", "a close falling back to the parent"],
+ ["tabSessionId", "the browser panel opening a tab"],
+ ] as const) {
+ expect(announcing, `${what} announces`).toContain(argument);
+ }
+ // The next/previous cycle and the archived-tab restore reach the same
+ // setter through `handleSessionTabSelect` rather than directly.
+ expect(detail, "the tab cycle goes through the announcing handler").toContain(
+ "void handleSessionTabSelect(nextTabId);",
+ );
+ expect(detail, "the archived-tab restore goes through it too").toContain(
+ "handleSessionTabSelect(id as SessionId);",
+ );
+ // Our side re-states the tab shape, because every `@lody/components/*`
+ // specifier is `any` at the typecheck seam. The two must not drift.
+ const ours = readFileSync(
+ join(repoRoot, "packages/webapp/src/lody/surface-tabs.ts"),
+ "utf8",
+ );
+ for (const field of ["id: string;", "label: string;", "icon?: ReactNode;", "content: ReactNode;"]) {
+ expect(detail, `vendored SessionSurfaceTab carries ${field}`).toContain(field);
+ expect(ours, `our SessionSurfaceTab carries ${field}`).toContain(field);
+ }
+ });
+});
diff --git a/packages/webapp/test/lody-surface-tabs.test.tsx b/packages/webapp/test/lody-surface-tabs.test.tsx
index 9819830f..cb78d700 100644
--- a/packages/webapp/test/lody-surface-tabs.test.tsx
+++ b/packages/webapp/test/lody-surface-tabs.test.tsx
@@ -35,9 +35,6 @@
* with the same rule (mounted always, `hidden` when inactive).
*/
import { act } from "react";
-import { readFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-import { fileURLToPath } from "node:url";
import { I18nextProvider } from "react-i18next";
import { Provider as JotaiProvider, createStore } from "jotai";
import { RouterProvider } from "@tanstack/react-router";
@@ -63,221 +60,15 @@ import { installLodyDomStubs } from "./lody-dom-stubs.js";
import { expectLandingHeading } from "./lody-landing-heading.js";
import { render, settle } from "./dom.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");
-
-const readLines = (path: string): string[] => readFileSync(path, "utf8").split("\n");
-
-/** One line the seam removes from upstream, by its line number in the pristine
- * file. The number is what makes an anchor unambiguous: ` )}` occurs
- * dozens of times in `session-tab-bar.tsx`, and "the first one" is not a
- * statement about anything. */
-type Anchor = readonly [line: number, text: string];
-
/**
- * Asserts one vendored file against its pristine upstream baseline.
- *
- * Two claims, and the second is the one that carries the inertness:
- *
- * 1. Each declared anchor is the line upstream actually has at that number, so
- * the table in `BLITZ-PATCHES.md` describes this tree and not a remembered
- * one.
- * 2. Upstream MINUS those lines is still a subsequence of the patched file.
- * Every other line upstream wrote survives, in order — so the patch only
- * ADDS, and the branches upstream takes with the new props absent are the
- * branches it took before. An undeclared deletion, or a reworded line, fails
- * with the first upstream line that could not be found.
+ * THE SOURCE PIN MOVED, AND THE MOUNT STAYED. Every assertion that reads a
+ * vendored file against its pristine upstream baseline now lives in
+ * `lody-seam-pin.test.ts`, which imports nothing: the `beforeAll` below pulls
+ * the whole vendored renderer in, and on a loaded machine it exceeds its hook
+ * budget — at which point vitest reports every test in THIS file as skipped.
+ * A pin that a slow machine silently turns off is not a pin. What is left here
+ * is claim (2): the patch WORKS, driven through the two real hosts.
*/
-function expectSeam(file: string, anchors: readonly Anchor[]): void {
- const upstream = readLines(join(baselineDir, `${file}.txt`));
- const patched = readLines(join(vendorDir, file));
- const removed = new Set();
- for (const [line, text] of anchors) {
- expect(upstream[line - 1], `${file}:${line} is the anchor BLITZ-PATCHES.md names`).toBe(text);
- removed.add(line);
- }
- expect(removed.size, "an anchor is declared twice").toBe(anchors.length);
-
- const kept = upstream.filter((_line, index) => !removed.has(index + 1));
- // Greedy is exact for a subsequence test: the earliest match never rules out
- // a later one, so a failure here is a line the patched file really lost.
- let cursor = 0;
- for (const line of kept) {
- while (cursor < patched.length && patched[cursor] !== line) cursor += 1;
- expect(
- cursor,
- `${file} no longer carries an upstream line the seam does not declare: ${JSON.stringify(line)}`,
- ).toBeLessThan(patched.length);
- cursor += 1;
- }
-}
-
-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 and 7'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),",
- ],
- ]);
- });
-
- it("holds a baseline of the commit vendor/lody/UPSTREAM.md pins", () => {
- // The baselines are only evidence while they are the pin's own bytes, and
- // nothing else in the tree would notice them going stale. `docs/LODY-MERGE.md`
- // §4 says to refresh them in the same change as the merge; this is what
- // fails when that is forgotten.
- const upstream = readFileSync(join(repoRoot, "vendor/lody/UPSTREAM.md"), "utf8");
- const pin = /\| Pinned commit \| `([0-9a-f]{40})` \|/u.exec(upstream)?.[1];
- expect(pin, "UPSTREAM.md still states a pinned commit").toBeDefined();
- const readme = readFileSync(join(baselineDir, "README.md"), "utf8");
- expect(readme, "the baselines name the commit they were taken from").toContain(pin ?? "");
- });
-
- it("declares the same six props on both sides of the seam", () => {
- const detail = readFileSync(join(vendorDir, "session-detail.tsx"), "utf8");
- for (const prop of [
- "surfaceTabs?: readonly SessionSurfaceTab[];",
- "activeSurfaceTabId?: string | null;",
- "onSurfaceTabSelect?: (tabId: string) => void;",
- "onSurfaceTabClose?: (tabId: string) => void;",
- "onSessionTabSelect?: (tabId: string) => void;",
- // Hunk 19, added in wave 3: the page returns above the strip when the
- // session does not exist, and the host loses every tab with it.
- "onSessionMissing?: (sessionId: string) => void;",
- ]) {
- expect(detail, `seam patch 5 declares ${prop}`).toContain(prop);
- }
- // Hunks 17-18, pinned as a CALL and not only as a declaration. A declared
- // prop that nothing invokes is exactly the shape of the defect it fixes:
- // the host keeps its tab selected, hunk 15 keeps drawing it, and a click on
- // a session tab does nothing a member can see.
- expect(detail, "the announcing setter exists").toContain(
- "const setActiveTabSessionId = useCallback((tabId: string) => {",
- );
- expect(detail, "and it announces").toContain("onSessionTabSelectRef.current?.(tabId);");
- });
-
- /**
- * THE CHOKEPOINT IS ONLY A CHOKEPOINT WHILE NOTHING WALKS AROUND IT.
- *
- * Ten call sites move the conversation selection and the first version of the
- * fix notified from one of them, which left the strip's `+` opening a draft
- * tab underneath the terminal — the same defect one button along. What the
- * seam relies on now is that the raw setter has exactly three callers, all
- * declared, so a merge that adds an eleventh writer either goes through the
- * wrapper or fails here.
- */
- it("routes every conversation-tab SELECTION through the announcing setter", () => {
- const detail = readFileSync(join(vendorDir, "session-detail.tsx"), "utf8");
- const rawWrites = [...detail.matchAll(/^\s*setActiveTabSessionIdState\(/gmu)];
- expect(
- rawWrites.length,
- "the raw setter is called only inside the wrapper and by hunk 18's three corrections",
- ).toBe(4);
-
- // The writers that were inert with the click-only notification, named so a
- // merge that reroutes one of them says which.
- const announcing = new Set(
- [...detail.matchAll(/^\s*(?:void )?setActiveTabSessionId\((.+?)\);?$/gmu)].map(
- (match) => match[1],
- ),
- );
- for (const [argument, what] of [
- ["tabId", "the strip's own tab click, and everything routed through it"],
- ["draft.id", "the strip's + — a new draft tab"],
- ["childSessionId", "a draft promoted to a real child session"],
- ["sessionId", "a close falling back to the parent"],
- ["tabSessionId", "the browser panel opening a tab"],
- ] as const) {
- expect(announcing, `${what} announces`).toContain(argument);
- }
- // The next/previous cycle and the archived-tab restore reach the same
- // setter through `handleSessionTabSelect` rather than directly.
- expect(detail, "the tab cycle goes through the announcing handler").toContain(
- "void handleSessionTabSelect(nextTabId);",
- );
- expect(detail, "the archived-tab restore goes through it too").toContain(
- "handleSessionTabSelect(id as SessionId);",
- );
- // Our side re-states the tab shape, because every `@lody/components/*`
- // specifier is `any` at the typecheck seam. The two must not drift.
- const ours = readFileSync(
- join(repoRoot, "packages/webapp/src/lody/surface-tabs.ts"),
- "utf8",
- );
- for (const field of ["id: string;", "label: string;", "icon?: ReactNode;", "content: ReactNode;"]) {
- expect(detail, `vendored SessionSurfaceTab carries ${field}`).toContain(field);
- expect(ours, `our SessionSurfaceTab carries ${field}`).toContain(field);
- }
- });
-});
const i18n = initLodyI18n();
diff --git a/packages/webapp/test/lody-v1-scope-sources.test.ts b/packages/webapp/test/lody-v1-scope-sources.test.ts
index 6f092d68..c87ae3b3 100644
--- a/packages/webapp/test/lody-v1-scope-sources.test.ts
+++ b/packages/webapp/test/lody-v1-scope-sources.test.ts
@@ -39,6 +39,9 @@ const ourSource = (file: string): string => read(join(lodySrc, file));
describe("the v1 scope constant", () => {
it("still cuts every group", () => {
// A flip is a product decision, not a refactor. It fails here first.
+ // `connectionStatus` is seam patch 15's, and it is an ownership boundary
+ // rather than a "not in v1" cut — see `v1-scope.ts`. Mounting the mobile
+ // branch is what gave it a phone to answer on.
expect(LODY_V1_SCOPE).toEqual({
gitHubIntegration: false,
agentRolesAndMcp: false,
@@ -62,25 +65,42 @@ describe("the v1 scope constant", () => {
keyboardShortcutsAvailable: false,
hideLanguageServiceActions: true,
hideTeamScope: true,
+ // Seam patch 16's one. `hideSettingsEntry` reads `cloudSurfaces`, the
+ // flag that already covers the hint band's Go-to-settings button.
+ hideSettingsEntry: true,
hideConnectionStatus: true,
});
});
});
-describe("router.tsx hands the suppression to all three mounted components", () => {
+describe("the two mounts hand the suppression to all four mounted components", () => {
const router = ourSource("router.tsx");
+ const stack = ourSource("MobileSessionStack.tsx");
it("passes every prop `lodyV1SuppressionProps` returns", () => {
- // Read from the returned object rather than restated, so adding a fifth
+ // Read from the returned object rather than restated, so adding a
// suppression and forgetting to pass it fails here.
+ //
+ // TWO MOUNTS, ONE OBJECT. `router.tsx` mounts the desktop pages and the
+ // archive; `MobileSessionStack.tsx` mounts the phone's. A prop may live in
+ // either — `hideSettingsEntry` has no desktop surface at all — but it must
+ // live in ONE of them, or the flag reaches nothing.
for (const prop of Object.keys(lodyV1SuppressionProps())) {
- expect(router, `router.tsx passes ${prop}`).toContain(`${prop}={V1.${prop}}`);
+ const passed = `${prop}={V1.${prop}}`;
+ expect(
+ router.includes(passed) || stack.includes(passed),
+ `${prop} is passed by router.tsx or MobileSessionStack.tsx`,
+ ).toBe(true);
}
});
it("builds them from the scope constant and nothing else", () => {
- expect(router).toContain('from "./v1-scope.js"');
- expect(router).toContain("const V1 = lodyV1SuppressionProps();");
+ for (const [name, source] of [["router.tsx", router], ["MobileSessionStack.tsx", stack]] as const) {
+ expect(source, `${name} reads the scope constant`).toContain('from "./v1-scope.js"');
+ expect(source, `${name} builds the props once`).toContain(
+ "const V1 = lodyV1SuppressionProps();",
+ );
+ }
});
it("gives ChatLanding the hint, Role and connection suppressions", () => {
@@ -197,23 +217,39 @@ describe("the command palette and the keyboard dispatcher stay unmounted", () =>
});
});
-describe("the mobile branch is not mounted", () => {
- it("has no mobile route component and no vendored mobile import", () => {
- // C110, SP62, T28, X13, X14. Both real routes are the desktop ones; seam
- // patch 5 leaves `mobile-session-tab-sheet.tsx` unpatched on purpose.
+describe("the mobile branch IS mounted, and the routes stand down for it", () => {
+ /* THIS DESCRIBE USED TO SAY THE OPPOSITE, and the inversion is the amendment
+ rather than a weakening. Area 23 was KILL because both real routes dropped
+ the mobile branch, so no v1 flag had to reach a phone. The user approved
+ mounting it (`plans/LODY-V1-SCOPE.md` §5), and what this suite must now
+ hold is the OTHER side of the same claim: exactly one thing draws the
+ phone's landing and session, and every flag reaches it. */
+
+ it("mounts the stack above both leaves, and both leaves return null on a phone", () => {
const router = ourSource("router.tsx");
- expect(router).toContain("Their `routes/$workspaceName/_auth/chat.tsx`, minus the mobile branch.");
- expect(router).toContain(
- "Their `routes/$workspaceName/_auth/sessions/$sessionId.tsx`, minus mobile.",
- );
- // An IMPORT, not a mention: `router.tsx`'s own doc comment cites
+ // The stack outlives the chat -> session route change, so it hangs off the
+ // route both leaves share. On a leaf it would be torn down by the very
+ // navigation it exists to animate.
+ expect(router).toContain("component: authRouteComponent(options.readOnly === true)");
+ expect(router).toContain(" {
+ // An IMPORT, not a mention: `router.tsx`'s doc comment cites
// `components/mobile/mobile-workspace-stack.tsx` as the file whose route ids
- // ours reproduce, and that citation is the reason the ids match.
- for (const file of readdirSync(lodySrc).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))) {
- expect(ourSource(file), `${file} mounts no vendored mobile screen`).not.toMatch(
- /^import .*components\/mobile\//mu,
- );
- }
+ // ours reproduce, and that citation is why the ids match.
+ //
+ // ONE importer is the point. Two would mean two things draw the phone's
+ // session, and the second would be a duplicate mount rather than a feature.
+ const importers = readdirSync(lodySrc)
+ .filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
+ .filter((f) => /^import .*components\/mobile\//mu.test(ourSource(f)));
+ expect(importers).toEqual(["MobileSessionStack.tsx"]);
});
});
@@ -367,11 +403,26 @@ describe("seam patch 15 is declared where a merge agent reads it", () => {
// `StuckConnectionBannerContainer` mounts once, in `MainLayout`. We mount
// pages, never upstream's roots — the same reason seam patch 15 declares no
// hunk for it.
+ //
+ // A MOUNT, NOT A MENTION. The check was a bare substring until the mobile
+ // stack imported `getMobileMainLayoutRootClassName` — two CLASS-NAME
+ // helpers that `MainLayout` also calls, and that seam patch 16's mount
+ // reproduces precisely BECAUSE it does not mount the layout that would
+ // supply them. A mount is an import of the component or an element, so
+ // that is what this looks for; the old spelling would have been satisfied
+ // by deleting a doc comment.
expect(read(join(vendorSrc, "components/main-layout.tsx"))).toContain(
"StuckConnectionBannerContainer",
);
for (const file of readdirSync(lodySrc).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))) {
- expect(ourSource(file), `${file} mounts no upstream layout`).not.toContain("MainLayout");
+ const source = ourSource(file);
+ expect(source, `${file} renders no MainLayout`).not.toMatch(/]/u);
+ expect(source, `${file} imports no MainLayout`).not.toMatch(
+ /^import \{[^}]*\bMainLayout\b/mu,
+ );
+ expect(source, `${file} imports no WorkspaceRuntimeShell`).not.toMatch(
+ /^import \{[^}]*\bWorkspaceRuntimeShell\b/mu,
+ );
}
});
});
diff --git a/packages/webapp/test/upstream-baseline/README.md b/packages/webapp/test/upstream-baseline/README.md
index d63db4e0..a47e48d4 100644
--- a/packages/webapp/test/upstream-baseline/README.md
+++ b/packages/webapp/test/upstream-baseline/README.md
@@ -10,6 +10,13 @@ nothing else.
|---|---|
| `session-tab-bar.tsx.txt` | `vendor/lody/packages/components/src/components/sessions/session-tab-bar.tsx` |
| `session-detail.tsx.txt` | `vendor/lody/packages/components/src/components/sessions/session-detail.tsx` |
+| `mobile-session-tab-sheet.tsx.txt` | `vendor/lody/packages/components/src/components/mobile/mobile-session-tab-sheet.tsx` |
+| `mobile-home-screen.tsx.txt` | `vendor/lody/packages/components/src/components/mobile/mobile-home-screen.tsx` |
+
+The last two arrived with seam patch 16, which is the first patch to edit a
+mobile-only file. `components/chat/chat-landing.tsx` is patched by seam patches
+7, 15 and 16 and has NO baseline here: adding one means declaring seam patch 7's
+removals in that file as well, which is a job for whoever needs it.
**Taken from `f34748945028ffc04316861ad25edc24535c0235`**, the commit
`vendor/lody/UPSTREAM.md` pins. The test reads that pin out of `UPSTREAM.md` and
@@ -47,9 +54,10 @@ that has the new upstream commit:
```sh
PIN=
-for f in session-tab-bar session-detail; do
- git show "$PIN:packages/components/src/components/sessions/$f.tsx" \
- > packages/webapp/test/upstream-baseline/"$f.tsx.txt"
+for f in sessions/session-tab-bar sessions/session-detail \
+ mobile/mobile-session-tab-sheet mobile/mobile-home-screen; do
+ git show "$PIN:packages/components/src/components/$f.tsx" \
+ > packages/webapp/test/upstream-baseline/"$(basename "$f").tsx.txt"
done
```
diff --git a/packages/webapp/test/upstream-baseline/mobile-home-screen.tsx.txt b/packages/webapp/test/upstream-baseline/mobile-home-screen.tsx.txt
new file mode 100644
index 00000000..8255ba57
--- /dev/null
+++ b/packages/webapp/test/upstream-baseline/mobile-home-screen.tsx.txt
@@ -0,0 +1,2385 @@
+import {
+ forwardRef,
+ Fragment,
+ lazy,
+ Suspense,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+import { AnimatePresence, motion } from 'framer-motion';
+import {
+ Archive,
+ BellRing,
+ CircleHelp,
+ CircleCheckBig,
+ Clock3,
+ Download,
+ FolderPlus,
+ Folders,
+ Github,
+ ListTodo,
+ Loader2,
+ LockKeyhole,
+ MessageCircle,
+ Monitor,
+ MonitorSmartphone,
+ Plus,
+ Search,
+ Settings,
+ X,
+} from 'lucide-react';
+import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/ui/drawer';
+import { MdChat, MdChecklist, MdComputer, MdFolderCopy } from 'react-icons/md';
+import { FaGithub } from 'react-icons/fa';
+import type { IconType } from 'react-icons';
+import { isIOSRuntimeEnvironment } from '@/lib/native-platform';
+import { observeResizeOnAnimationFrame } from '@/lib/resize-observer';
+import { cn } from '@/lib/utils';
+import { usePullToRefresh } from '@/hooks/use-pull-to-refresh';
+import { CarbonSettingsAdjust } from '@/components/icons/carbon-settings-adjust';
+import { GlassIconButton } from '@/components/mobile/glass-icon-button';
+import { type MobileConversationItem } from './mobile-project-screen';
+import {
+ MobileChatList,
+ type MobileChatGroupBy,
+ type MobileChatListSelectionLabels,
+} from './mobile-chat-list';
+import {
+ MobileConnectionStatus,
+ type MobileConnectionStatusLabels,
+} from './mobile-connection-status';
+import type { LodyConnectionUiState } from '@/atoms/control-connection';
+import { MobileFilterPillBar, type FilterPill } from './mobile-filter-pill-bar';
+import { MobileInitialLetterAvatar } from './mobile-initial-letter-avatar';
+import { CachedAvatarImg } from '@/components/cached-avatar-img';
+import { WorkspaceAvatar } from '@/components/workspace-avatar';
+import { MobileWorkspaceTabBar, type MobileBottomTabBarTabSpec } from './mobile-workspace-tabbar';
+
+/* Lazy so the Tasks surface (board/list + detail graph) stays out of the
+ mobile home chunk for the vast majority of users who never enable the
+ Tasks beta — the tab only renders when `showTasksTab` is on. */
+const TasksListBody = lazy(() =>
+ import('../tasks/tasks-workspace').then((m) => ({ default: m.TasksListBody }))
+);
+
+function MobileReactIcon({
+ icon: Icon,
+ className,
+ ariaHidden = true,
+}: {
+ icon: IconType;
+ className?: string;
+ ariaHidden?: boolean;
+}) {
+ /* Fill the sized wrapper — do NOT use size="1em". Tab buttons set
+ `text-[0.72rem]` for the label, so 1em collapsed Material icons to ~12px. */
+ return (
+
+
+
+ );
+}
+
+/* Re-export so callers that already import `MobileChatGroupBy` from
+ the home screen don't have to chase the type to its new module. */
+export type { MobileChatGroupBy };
+
+/* The mobile home dock surfaces up to four content tabs, ordered
+ left → right: Inbox · Chat · Tasks · 项目. Inbox is available in
+ multi-member workspaces and occupies the natural "home" position.
+ Tasks only appears when the caller passes `showTasksTab` (the
+ developer-mode Tasks beta gate). The 项目 tab merges Local + GitHub
+ via an inner sub-tab; the 设置 surface lives in the header's gear
+ button. */
+export type MobileHomeTab = 'inbox' | 'chat' | 'projects' | 'tasks';
+
+/* Sub-tab inside the "项目" tab. Drives both the heading + full list
+ below the segmented selector. Persisted via `mobileHomeProjectsSubTabAtom`
+ in atoms/mobile-home-state.ts so a Chat ↔ Projects round-trip lands the
+ user back on the side they were last on. */
+export type MobileProjectsSubTab = 'local' | 'github';
+
+export type MobileHomeWorkspace = {
+ id: string;
+ name: string;
+ avatarUrl?: string | null;
+};
+
+export type MobileHomeWorkspaceOption = MobileHomeWorkspace & {
+ isActive?: boolean;
+};
+
+export type MobileHomeMachine = {
+ id: string;
+ name: string;
+ isOnline: boolean;
+ isPrivate?: boolean;
+};
+
+export type MobileInboxItem = {
+ id: string;
+ kind: 'session_completed' | 'permission_requested' | 'sharing_review';
+ title: string;
+ description: string;
+ updatedAt: number;
+ unread?: boolean;
+ actionLabel?: string;
+};
+
+/* Copy for the first-run hint shown on the Chat tab when the workspace
+ has no machines AND no conversations yet — i.e. the user installed the
+ mobile app before ever launching the desktop client. The mobile app is
+ a thin client (the agent runs on the user's own computer), so we show a
+ short nudge to download + start the desktop client. Kept deliberately
+ minimal — one line + a button. */
+export type MobileHomeOnboardingLabels = {
+ title?: string;
+ description?: string;
+ /** Primary CTA — opens the Lody download page. */
+ downloadButton?: string;
+};
+
+export type MobileHomeLocalProject = {
+ id: string;
+ /** Machine that owns this project — used by the home screen to group
+ projects under their machine section. */
+ machineId: string;
+ name: string;
+ path: string;
+ conversationCount: number;
+ /** Latest message timestamp across all conversations under this
+ project. Drives the trailing "5m / 2h / 3d" age label on the row.
+ `null` / undefined renders no label. */
+ latestMessageAt?: number | null;
+ /** Number of conversations under this project that have new messages
+ since the user last opened them. `0` / undefined hides the badge. */
+ unreadCount?: number;
+ /** Effective privacy: true when either the machine or project grant is private. */
+ isPrivate?: boolean;
+ /** Durable project-removal state. Pending rows stay visible but cannot open. */
+ removalState?: 'removing' | 'waiting_for_device' | null;
+};
+
+export type MobileHomeGitHubRepository = {
+ id: string;
+ /** Short repo name (no owner prefix). Kept around for search / sort
+ callers even though the row title is now rendered as
+ `owner/name`. */
+ name: string;
+ /** `owner/name` — also used as the row's click target and title. */
+ fullName: string;
+ /** Owner handle (user or org). Drives the leading avatar lookup. */
+ ownerHandle: string;
+ /** Owner avatar URL. When null the row falls back to a generic github
+ glyph in the leading slot. */
+ ownerAvatarUrl?: string | null;
+ /** Optional one-line repo description. Currently unused by the flat
+ list (the row reserves a single line for `owner/name`), but kept on
+ the type so search can match against it. */
+ description?: string | null;
+ /** Currently unused in the row UI (count badge was removed). Kept on
+ the type so consumers can still surface it elsewhere (search,
+ sort). */
+ conversationCount?: number;
+ /** Latest message timestamp for any conversation under this repo —
+ drives the trailing semantic age label AND the list sort order
+ (newest first). */
+ latestMessageAt?: number | null;
+ /** Number of conversations under this repo that have new messages
+ since the user last opened them. `0` / undefined hides the badge. */
+ unreadCount?: number;
+};
+
+/* Chat-tab rows reuse the `MobileConversationItem` shape used by the
+ in-project conversation page (title + status + optional line counts
+ + age on a single line). Re-export it here so consumers don't have
+ to know that the type lives next door in `mobile-project-screen`. */
+export type { MobileConversationItem };
+
+/* Item in the "最近常用" horizontal strip. The strip used to live inside
+ each tab (local / github) separately; after the Projects merge it
+ lives at the top of the merged tab and shows a single sorted list of
+ recent items across BOTH kinds. The strip render branches on `kind`
+ so each item still shows the right avatar / caption format. Capped
+ to ~6 by the caller. */
+export type MobileHomeRecentLocalProject = {
+ id: string;
+ /** Project short name (no path prefix). */
+ name: string;
+ /** Path under the project's machine — shown as the small caption
+ beneath the project name. */
+ path: string;
+ /** Machine name (shown as a secondary label, since the home tab no
+ longer has a machine pill to disambiguate). */
+ machineName?: string;
+ /** Timestamp the caller uses to merge-sort with GitHub recents in
+ the unified strip. Higher = more recent. */
+ latestActivityAt?: number | null;
+};
+
+export type MobileHomeRecentRepo = {
+ id: string;
+ /** Repo short name (no owner prefix). */
+ name: string;
+ /** "owner/repo". Used as the click target. */
+ fullName: string;
+ /** GitHub org / user handle to show beneath the avatar. */
+ ownerHandle: string;
+ /** Owner avatar URL (org or user); falls back to the octocat glyph. */
+ avatarUrl?: string | null;
+ /** Timestamp the caller uses to merge-sort with Local recents in
+ the unified strip. Higher = more recent. */
+ latestActivityAt?: number | null;
+};
+
+/* Discriminated union for the merged-recents strip. The home screen
+ keeps the original two arrays as separate props (for type clarity
+ and minimal caller churn) and merges them on demand via
+ `mergeRecentProjects`. */
+export type MobileHomeRecentProject =
+ | ({ kind: 'local' } & MobileHomeRecentLocalProject)
+ | ({ kind: 'github' } & MobileHomeRecentRepo);
+
+export type MobileHomeScreenLabels = {
+ switchWorkspace?: string;
+ /** Copy shown by the inline connection-status indicator in the home
+ header's middle slot when the workspace's control connection is
+ anything other than `'online'`. */
+ connectionBanner?: MobileConnectionStatusLabels;
+ /** Label for the leftmost "Inbox" tab in the bottom dock. */
+ inboxTab?: string;
+ /** Placeholder body shown inside the Inbox tab while the feature
+ has no active notifications. */
+ inboxPlaceholder?: string;
+ inboxLoading?: string;
+ inboxDismissAriaLabel?: string;
+ privateLabel?: string;
+ privateHelpAriaLabel?: string;
+ privateHelpTitle?: string;
+ privateHelpDescription?: string;
+ privateHelpClose?: string;
+ /** Label for the merged "项目" tab in the bottom dock. */
+ projectsTab?: string;
+ /** Sub-tab segmented selector labels inside the Projects tab. The
+ same `localTab` / `githubTab` strings are reused. */
+ localTab?: string;
+ githubTab?: string;
+ /** Header add-project action sheet: trigger/title + the two options
+ (each with a short hint subtitle). */
+ addProjectMenu?: string;
+ addLocalProject?: string;
+ addLocalProjectHint?: string;
+ addGitHubRepository?: string;
+ addGitHubRepositoryHint?: string;
+ chatTab?: string;
+ /** Label for the Tasks tab in the bottom dock. Only rendered when the
+ caller also passes `showTasksTab`. */
+ tasksTab?: string;
+ settingsTab?: string;
+ /** aria-label for the archive-toggle chip in the header. Toggles the
+ Chat tab between active and archived conversations. */
+ archiveToggleLabel?: string;
+ /** aria-label for the chip after search that shows/hides the Chat
+ filter pill bar (Team Tasks / Group / Filters). */
+ filterBarToggleLabel?: string;
+ /** Heading shown above the unified recents strip on the Projects
+ tab. Defaults to "最近常用" when omitted. */
+ recentProjectsHeading?: string;
+ online?: string;
+ offline?: string;
+ projectRemoving?: string;
+ projectRemovalWaiting?: string;
+ /** Copy shown inside the pull-to-refresh indicator while the user is
+ pulling down. Two states: below threshold = "pull more", at or
+ past threshold = "release to refresh". Both fade in as the pull
+ grows so the user gets a smooth on-ramp instead of a sudden text
+ pop. */
+ pullToRefresh?: string;
+ releaseToRefresh?: string;
+ /** Placeholder shown inside the header search input. Defaults to a
+ contextual placeholder per tab ("搜索项目" / "搜索仓库" / "搜索对话")
+ when omitted. */
+ searchPlaceholder?: string;
+ searchAriaLabel?: string;
+ clearSearchAriaLabel?: string;
+ /** Heading above the recently-used strip on Local + GitHub tabs. */
+ recentReposHeading?: string;
+ recentLocalProjectsHeading?: string;
+ /** Heading above the flat "all items" list on Local / GitHub tabs.
+ Defaults to "全部项目" / "全部仓库" when omitted. Chat tab no longer
+ renders an "全部对话" label above the flat list. */
+ allLocalProjectsHeading?: string;
+ allGitHubReposHeading?: string;
+ /** @deprecated Chat list no longer shows a section heading in the
+ active (non-archived) mode. Kept for call-site compatibility. */
+ allChatsHeading?: string;
+ /** Heading rendered above the Chat tab's list when the archive
+ toggle is on. Falls back to "归档对话" when not provided. */
+ archivedChatsHeading?: string;
+ /** Copy for the multi-select toolbar + delete confirmation shown
+ when the user long-presses a row in the archive view. See
+ `MobileChatListSelectionLabels`. */
+ archiveSelection?: MobileChatListSelectionLabels;
+ /** Per-bucket headings used by the Chat tab's grouped view modes.
+ Keys are the bucket ids each grouping mode produces. Missing
+ entries fall back to the id itself. */
+ chatGroupLabels?: Partial>;
+ /** Empty-state message for the entire tab when nothing exists yet
+ (no machines, no authorized repos, no chats). */
+ emptyLocalProjects?: string;
+ emptyGitHubProjects?: string;
+ emptyChats?: string;
+ /** First-run onboarding copy for the Chat tab, shown when the
+ workspace has no machines AND no conversations (distinct from
+ `emptyChats`, which assumes a machine already exists). Guides the
+ user to download + launch the desktop client. */
+ onboarding?: MobileHomeOnboardingLabels;
+ /** Empty-state shown when active chat filters removed every
+ conversation (distinct from `emptyChats`, which means none exist).
+ Paired with the "Clear filters" button. */
+ emptyFilteredChats?: string;
+ /** Label for the "Clear filters" button rendered in the filtered
+ empty state. */
+ clearChatFilters?: string;
+ /** Empty-state when search query matches nothing. */
+ emptySearch?: string;
+ /** ARIA label for the standalone new-conversation chip in the dock. */
+ newChatAriaLabel?: string;
+ conversationCount?: (count: number) => string;
+};
+
+export type MobileHomeScreenProps = {
+ workspace: MobileHomeWorkspace;
+ workspaceOptions?: MobileHomeWorkspaceOption[];
+ machines: MobileHomeMachine[];
+ inboxItems?: MobileInboxItem[];
+ inboxLoading?: boolean;
+ onInboxItemSelect?: (itemId: string) => void;
+ onInboxItemDismiss?: (itemId: string) => void;
+ /** Workspace control-connection state (mirrors the desktop sidebar's
+ `lodyConnectionUiStateAtom`). Defaults to `'online'` (banner stays
+ hidden) when the caller doesn't pass anything — keeps Storybook /
+ legacy callers from having to thread it through. */
+ connectionUiState?: LodyConnectionUiState;
+ /** True while the workspace's first machine/session sync is still in
+ flight on app launch. Mirrors the desktop chat-landing's
+ `isInitialDataLoading`. Used to suppress the first-run onboarding
+ takeover until we actually know the workspace is empty — otherwise a
+ returning user would see a flash of "download the client" before their
+ cached machines + chats hydrate. Defaults to `false`. */
+ isInitialDataLoading?: boolean;
+ /** Fired when the user pulls down past threshold at the top of the
+ list. Caller drives the actual sync (typically
+ `runtime.repo.sync()`); the home screen owns the gesture
+ detection but lets the parent decide what "refresh" means. */
+ onPullToRefresh?: () => Promise | void;
+ selectedTab: MobileHomeTab;
+ /** Inbox only appears in workspaces with more than one member. */
+ showInboxTab?: boolean;
+ /** Shows the Tasks tab in the bottom dock (developer-mode Tasks beta
+ gate). When false the tab is not rendered at all — as if never
+ built — and a `selectedTab` of `'tasks'` falls back to Chat. */
+ showTasksTab?: boolean;
+ /** Active sub-tab inside the Projects tab. Required when `selectedTab`
+ is 'projects'; ignored on the Chat tab. */
+ selectedProjectsSubTab?: MobileProjectsSubTab;
+ /** Fired when the user taps a segment in the Projects sub-tab
+ selector. Caller is expected to persist the choice + update
+ whatever downstream state needs the sub-tab (e.g. the composer's
+ contextType). */
+ onProjectsSubTabSelect?: (sub: MobileProjectsSubTab) => void;
+ /** Header add-project dropdown actions (shown on the Projects tab, left of
+ the settings gear): pick a folder, or open GitHub integration settings. */
+ onAddLocalProject?: () => void;
+ onAddGitHubRepository?: () => void;
+ localProjects: MobileHomeLocalProject[];
+ /** Recently active local projects — rendered as a horizontal strip
+ at the top of the Local tab. Empty array hides the strip. */
+ recentLocalProjects?: MobileHomeRecentLocalProject[];
+ /** Flat list of all authorized GitHub repositories, sorted by the
+ caller (newest activity first). The component does not regroup
+ them by owner anymore; each row stands on its own. */
+ githubRepositories: MobileHomeGitHubRepository[];
+ /** Recently active repositories — rendered as a horizontal strip at
+ the top of the GitHub tab. Empty array hides the strip. */
+ recentGitHubRepos?: MobileHomeRecentRepo[];
+ /** Every non-archived conversation in the workspace, sorted by the
+ caller (newest first). Rendered as single-line rows (title +
+ optional +/- / age) shared with the in-project page. */
+ chats: MobileConversationItem[];
+ /** Pills rendered above the Chat tab's list for filtering / view
+ mode switching. When omitted no bar is mounted (the tab renders
+ the same as before). */
+ chatFilterPills?: ReadonlyArray;
+ /** Active grouping mode for the Chat tab. `none` (default) renders
+ the chats as one flat newest-first list. Anything else inserts
+ section headings between buckets. The caller passes the bucket
+ labels via `labels.chatGroupLabels`. */
+ chatGroupBy?: MobileChatGroupBy;
+ /** True when active chat filters are narrowing the list. Drives the
+ "Clear filters" affordance in the empty state (so an all-filtered-out
+ list reads as "your filters hid everything", not "no conversations"). */
+ hasActiveChatFilters?: boolean;
+ /** Resets all chat filters to their default (show-everything) state.
+ Wired to the empty-state "Clear filters" button. */
+ onClearChatFilters?: () => void;
+ labels?: MobileHomeScreenLabels;
+ theme?: 'ios' | 'material';
+ /** Tap on the workspace pill in the header — typically opens a
+ bottom-sheet workspace switcher rendered by the parent. The pill
+ itself no longer hosts an inline dropdown (the sheet handles
+ create / invite actions too, which don't fit a dropdown well). */
+ onWorkspaceMenuOpen?: () => void;
+ /** Retained for callers that still drive selection externally (e.g.
+ stories without the sheet). Most consumers should wire the sheet
+ instead and select via that. */
+ onWorkspaceSelect?: (workspaceId: string) => void;
+ onTabSelect?: (tab: MobileHomeTab) => void;
+ onLocalProjectSelect?: (projectId: string) => void;
+ onGitHubRepositorySelect?: (repoFullName: string) => void;
+ onChatSelect?: (chatId: string) => void;
+ /** Pin / unpin a chat from the swipe-to-reveal drawer on its row.
+ Receives the next pinned state so the handler doesn't have to
+ re-derive the current state. */
+ onChatTogglePin?: (chatId: string, nextPinned: boolean) => void;
+ /** Archive a chat from the swipe-to-reveal drawer (or from the
+ super-swipe). */
+ onChatArchive?: (chatId: string) => void;
+ /** Restore (un-archive) a chat from the swipe-to-reveal drawer shown
+ on rows of the *archived* Chat list. */
+ onChatRestore?: (chatId: string) => void;
+ /** Hands a batch of chat ids to the parent for *permanent* deletion.
+ Only invoked from the multi-select toolbar shown in the archive
+ view's selection mode. The list itself owns the selection +
+ confirmation UX; this prop only carries out the destructive
+ action. */
+ onChatPermanentDelete?: (chatIds: string[]) => void | Promise;
+ /** Fires when the gear chip in the top header is tapped. Navigates to
+ the settings surface. */
+ onSettingsOpen?: () => void;
+ /** Fires when the standalone new-conversation chip in the bottom dock
+ is tapped. Optional — when omitted the chip is not rendered. */
+ onNewChat?: () => void;
+ /** Fires when the user taps "Download Lody" in the first-run onboarding
+ empty state (Chat tab, no machines + no chats). Typically opens the
+ localized download page in the external browser. When omitted the
+ onboarding still renders but the button is inert. */
+ onDownloadClient?: () => void;
+ /** When true, the Chat tab renders the *archived* conversations
+ (instead of the active ones) and the content area is tinted with
+ `bg-muted` to give the "everything here is archived" feel. The
+ caller still owns the data — it just hands over the archived
+ list via `chats` when this is true and the active list when it's
+ false. */
+ showArchived?: boolean;
+ /** Fires when the user taps the Archive chip in the header. Only
+ rendered when both this callback and the Chat tab are active. */
+ onShowArchivedToggle?: () => void;
+};
+
+/* Home header workspace chip — shared `WorkspaceAvatar` so logo /
+ first-letter fallback match the switcher sheet and desktop sidebar. */
+function HomeWorkspaceAvatar({
+ workspace,
+ size = 'sm',
+}: {
+ workspace: MobileHomeWorkspace;
+ size?: 'sm' | 'md';
+}) {
+ const sizeClass = size === 'sm' ? 'h-7 w-7 text-[0.72rem]' : 'h-9 w-9 text-[0.82rem]';
+ return (
+
+ );
+}
+
+/* Floating pill (workspace chip / settings chip).
+ Light: soft muted inset (card == white background, so no white
+ shadow blobs). Dark: muted alone is too close to the canvas —
+ add a hairline + slightly lifted white/10 fill so archive /
+ settings / chips actually read as controls. */
+const FloatingPill = forwardRef>(
+ function FloatingPill({ className, type = 'button', ...rest }, ref) {
+ return (
+
+ );
+ }
+);
+
+/* Add-project action sheet launched from the Projects header. A bottom sheet
+ with large, well-separated tap targets (rejected: a desktop-style dropdown —
+ its items sat too close together on touch and read as out of place on
+ mobile). Dismisses via swipe-down / backdrop tap (vaul). */
+function AddProjectActionSheet({
+ open,
+ onOpenChange,
+ onAddLocalProject,
+ onAddGitHubRepository,
+ labels,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onAddLocalProject?: () => void;
+ onAddGitHubRepository?: () => void;
+ labels: MobileHomeScreenLabels;
+}) {
+ const options: Array<{
+ key: string;
+ icon: ReactNode;
+ title: string;
+ hint: string;
+ onSelect: () => void;
+ }> = [];
+ if (onAddLocalProject) {
+ options.push({
+ key: 'local',
+ icon: ,
+ title: labels.addLocalProject ?? '添加本地项目',
+ hint: labels.addLocalProjectHint ?? '浏览机器目录,选择一个文件夹',
+ onSelect: onAddLocalProject,
+ });
+ }
+ if (onAddGitHubRepository) {
+ options.push({
+ key: 'github',
+ icon: ,
+ title: labels.addGitHubRepository ?? '添加 GitHub 仓库',
+ hint: labels.addGitHubRepositoryHint ?? '连接 GitHub 集成',
+ onSelect: onAddGitHubRepository,
+ });
+ }
+
+ const title = labels.addProjectMenu ?? '添加项目';
+
+ return (
+
+
+ {title}
+ {title}
+
+
+
+
+ {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 (
+
+ );
+}
+
+/* Initial-letter avatar lives in its own module so the in-project
+ header can share the same look. */
+
+/* Owner avatar for GitHub rows: real image when we have a URL,
+ otherwise the github glyph on a neutral tile. */
+function GhOwnerAvatar({ url, handle }: { url?: string | null; handle: string }) {
+ if (url) {
+ return (
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+/* Rounded card surface that wraps the flat row list on each tab. */
+function MobileHomeListShell({ children }: { children: ReactNode }) {
+ return (
+
{children}
+ );
+}
+
+/* Single row in the flat home list. Slots:
+ - `leading`: 40×40 avatar tile (initial letter / owner image / glyph)
+ - `title` + optional `titleSuffix` on the same line (project name +
+ light path; or `owner/repo` with no suffix)
+ - optional `secondaryLine` underneath (machine meta for Local + Chat;
+ empty for GitHub which is a single-line row)
+ - `trailing`: time + unread badge (renders `RowTrailingMeta`)
+
+ `last:border-b-0` lets the shell crop the final divider without a
+ per-call special case. */
+function MobileHomeListRow({
+ leading,
+ title,
+ titleSuffix,
+ secondaryLine,
+ trailing,
+ onClick,
+ ariaLabel,
+ wrapTitle = false,
+ disabled = false,
+}: {
+ leading: ReactNode;
+ title: ReactNode;
+ titleSuffix?: ReactNode;
+ secondaryLine?: ReactNode;
+ trailing?: ReactNode;
+ onClick?: () => void;
+ ariaLabel?: string;
+ /** Let the title wrap to show in full instead of truncating on one
+ line. The `titleSuffix` then stays pinned (shrink-0) to the first
+ line. Used by the local-projects list so long project names aren't
+ clipped. */
+ wrapTitle?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+
+
+
+ );
+}
+
+/* Horizontal "最近常用" strip rendered at the top of the Local + GitHub
+ tabs. Each card is a tap-to-enter rounded rectangle showing a
+ leading visual (machine icon / owner avatar), the project or repo
+ name, and a secondary caption (machine name / owner handle). The
+ parent caps the list and sorts by recency. */
+function RecentItemsRow({
+ items,
+ heading,
+ onSelect,
+ renderItem,
+}: {
+ items: TItem[];
+ heading: ReactNode;
+ onSelect: (item: TItem) => void;
+ renderItem: (item: TItem) => ReactNode;
+}) {
+ if (items.length === 0) return null;
+ return (
+
+
+ {heading}
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+/* Build the bottom tabbar's tab list from the screen's labels. Lives
+ at module scope (rather than inside `MobileHomeScreen`) so the spec
+ doesn't get rebuilt unnecessarily — `labels` is identity-stable per
+ render of the parent, and the tab callbacks are passed to
+ `` via plain props, not closures. */
+function workspaceTabSpecs(
+ labels: MobileHomeScreenLabels,
+ showTasksTab = false,
+ showInboxTab = false
+): ReadonlyArray> {
+ /* Icons at 24px (h-6) — the dock pill is h-14, so h-5/20px read as
+ under-drawn next to the label. Material icons go through MobileReactIcon
+ which fills this box (not 1em of the label's 0.72rem). */
+ return [
+ ...(showInboxTab
+ ? [
+ {
+ key: 'inbox' as const,
+ ios: ,
+ material: ,
+ label: labels.inboxTab ?? 'Inbox',
+ },
+ ]
+ : []),
+ {
+ key: 'chat',
+ ios: ,
+ material: ,
+ label: labels.chatTab ?? 'Chat',
+ },
+ ...(showTasksTab
+ ? [
+ {
+ key: 'tasks' as const,
+ ios: ,
+ material: ,
+ label: labels.tasksTab ?? 'Tasks',
+ },
+ ]
+ : []),
+ {
+ key: 'projects',
+ ios: ,
+ material: ,
+ label: labels.projectsTab ?? '项目',
+ },
+ ];
+}
+
+/* Storybook / unconfigured callers see hard-coded Chinese fallbacks
+ here — in production every caller threads through chat-landing,
+ which supplies its own localized `labels.searchPlaceholder` (see
+ the `useMemo` in chat-landing that picks the per-tab i18n key).
+ Keep the fallbacks Chinese to match this repo's dev language; the
+ i18n scanner can't reach inline literals like these because they
+ aren't wrapped in `t()`. */
+function defaultSearchPlaceholder(
+ tab: MobileHomeTab,
+ sub: MobileProjectsSubTab | undefined
+): string {
+ if (tab === 'inbox') return '搜索';
+ if (tab === 'chat') return '搜索对话';
+ if (sub === 'github') return '搜索仓库';
+ return '搜索项目';
+}
+
+/* Merge the two recent arrays into a single newest-first sorted list,
+ capped at `cap` items. Stable for items without `latestActivityAt`
+ — they sink to the bottom in their input order. The discriminator
+ `kind` is added here so the strip renderer can branch on it.
+
+ We merge in the component (not the caller) so callers stay close to
+ their data sources: each kind has different timestamp fields and
+ different filtering rules, and forcing the caller to flatten would
+ leak that logic to every consumer. The merge is cheap (O(n log n))
+ and the inputs are already capped at ≤5 each. */
+function mergeRecentProjects(
+ local: MobileHomeRecentLocalProject[],
+ github: MobileHomeRecentRepo[],
+ cap = 6
+): MobileHomeRecentProject[] {
+ const tagged: MobileHomeRecentProject[] = [
+ ...local.map((item) => ({ kind: 'local' as const, ...item })),
+ ...github.map((item) => ({ kind: 'github' as const, ...item })),
+ ];
+ tagged.sort((left, right) => {
+ const leftTs = left.latestActivityAt ?? -Infinity;
+ const rightTs = right.latestActivityAt ?? -Infinity;
+ return rightTs - leftTs;
+ });
+ return tagged.slice(0, cap);
+}
+
+/* Compact pill-segmented control for the Projects sub-tab.
+
+ Design intent: read as a section-local filter that sits *with* the
+ content, not as a heavy page-level mode switch. We get that by
+ keeping it small, content-sized (not full-width), and left-aligned.
+ The `layoutId`-driven thumb still gives the iOS-segmented feel —
+ framer-motion animates both x AND width when the active segment
+ changes, since the two labels render at different widths.
+
+ Rejected alternatives:
+ - Full-width half-and-half segments: visually equal to "primary
+ navigation", crowds the page; the user explicitly asked for
+ "小一点,更有设计感".
+ - Underlined tabs: too quiet for this layer; the segments need to
+ read as interactive switches, not section headings.
+ - Reuse `MobileFilterPillBar`: that bar shows N independent chips
+ where each is on/off — a 2-segment exclusive selector reads more
+ clearly with a shared thumb than two separately-toggleable pills.
+
+ Tokens: `bg-muted/50` for the track is intentionally lighter than
+ the original `bg-muted/60` — a thinner pill needs less contrast to
+ register as a single grouped control. The active thumb is plain
+ `bg-card` (no shadow) since the pill itself is so compact a shadow
+ would feel chunky at this scale. */
+function ProjectsSubTabSelector({
+ active,
+ localLabel,
+ githubLabel,
+ onSelect,
+ iosTheme,
+}: {
+ active: MobileProjectsSubTab;
+ localLabel: string;
+ githubLabel: string;
+ onSelect: (sub: MobileProjectsSubTab) => void;
+ iosTheme: boolean;
+}) {
+ const listRef = useRef(null);
+ const localRef = useRef(null);
+ const githubRef = useRef(null);
+ const segments: Array<{
+ key: MobileProjectsSubTab;
+ label: string;
+ icon: ReactNode;
+ ref: { current: HTMLButtonElement | null };
+ }> = [
+ {
+ key: 'local',
+ label: localLabel,
+ icon: iosTheme ? (
+
+ ) : (
+
+ ),
+ ref: localRef,
+ },
+ {
+ key: 'github',
+ label: githubLabel,
+ icon: iosTheme ? (
+
+ ) : (
+
+ ),
+ ref: githubRef,
+ },
+ ];
+
+ // Drive the active thumb with a *value* animation (x + width) rather than
+ // framer-motion's `layoutId` layout animation. A layout animation re-measures
+ // the thumb's on-screen box on every re-render and springs to correct any
+ // delta — so while pull-to-refresh translates this whole subtree, each
+ // `pullDistance` frame re-renders the selector, the thumb measures a new
+ // screen position, and it visibly lags behind the content while "catching up".
+ // Measuring the active button once per `active` change and animating x/width
+ // keeps the same spring feel on tab-switch while staying immune to the
+ // ancestor transform (rejected: keeping `layoutId` + memoizing the subtree —
+ // `pullDistance` lives high in the tree and threading memo through is fragile).
+ const [thumb, setThumb] = useState<{ x: number; width: number } | null>(null);
+ useLayoutEffect(() => {
+ const list = listRef.current;
+ if (!list) return undefined;
+ const measure = () => {
+ const btn = (active === 'local' ? localRef : githubRef).current;
+ if (!btn) return;
+ // getBoundingClientRect (border-box) instead of offsetLeft so the thumb's
+ // `left: 0` (padding-box of the borderless list, which coincides with its
+ // border-box) lines up with the measured offset.
+ const listRect = list.getBoundingClientRect();
+ const btnRect = btn.getBoundingClientRect();
+ setThumb({ x: btnRect.left - listRect.left, width: btnRect.width });
+ };
+ measure();
+ // Re-measure when the pill itself reflows (font swap, container resize). A
+ // ResizeObserver fires on the list's own box change but NOT on an ancestor's
+ // pull-to-refresh transform, so it keeps the thumb aligned without
+ // reintroducing the lag the layout animation caused.
+ return observeResizeOnAnimationFrame(list, () => measure());
+ }, [active, localLabel, githubLabel, iosTheme]);
+
+ return (
+
+ );
+}
+
+function normalizeForSearch(value: string | null | undefined): string {
+ return (value ?? '').toLowerCase();
+}
+
+export function filterMobileInboxItems(
+ items: readonly MobileInboxItem[],
+ normalizedQuery: string
+): MobileInboxItem[] {
+ if (!normalizedQuery) return [...items];
+ return items.filter(
+ (item) =>
+ normalizeForSearch(item.title).includes(normalizedQuery) ||
+ normalizeForSearch(item.description).includes(normalizedQuery) ||
+ normalizeForSearch(item.actionLabel).includes(normalizedQuery)
+ );
+}
+
+export function MobileHomeScreen({
+ workspace,
+ workspaceOptions = [],
+ machines,
+ inboxItems = [],
+ inboxLoading = false,
+ onInboxItemSelect,
+ onInboxItemDismiss,
+ connectionUiState = 'online',
+ isInitialDataLoading = false,
+ onPullToRefresh,
+ selectedTab,
+ showInboxTab = false,
+ showTasksTab = false,
+ selectedProjectsSubTab = 'local',
+ onProjectsSubTabSelect,
+ onAddLocalProject,
+ onAddGitHubRepository,
+ localProjects,
+ recentLocalProjects = [],
+ githubRepositories,
+ recentGitHubRepos = [],
+ chats,
+ chatFilterPills,
+ chatGroupBy = 'project',
+ hasActiveChatFilters = false,
+ onClearChatFilters,
+ labels = {},
+ theme,
+ onWorkspaceMenuOpen,
+ onWorkspaceSelect: _onWorkspaceSelect,
+ onTabSelect,
+ onLocalProjectSelect,
+ onGitHubRepositorySelect,
+ onChatSelect,
+ onChatTogglePin,
+ onChatArchive,
+ onChatRestore,
+ onChatPermanentDelete,
+ onSettingsOpen,
+ onNewChat,
+ onDownloadClient,
+ showArchived = false,
+ onShowArchivedToggle,
+}: MobileHomeScreenProps) {
+ /* `workspaceOptions` was the source for the inline workspace dropdown
+ menu — that's now handled by `MobileWorkspaceSwitcherSheet` rendered
+ by the parent. The prop stays on the type for backward compatibility
+ but isn't consumed here anymore. */
+ void workspaceOptions;
+
+ /* Chat filter pill bar starts collapsed so the list chrome stays
+ clean; the filter chip after search toggles it open. Session-local
+ only (not persisted) — reopening the app lands on a quiet surface. */
+ const [chatFiltersOpen, setChatFiltersOpen] = useState(false);
+
+ /* Single search query across all tabs — the input lives in the header
+ and stays visible regardless of tab. Resetting on tab change would
+ surprise users who flick between tabs while searching for the same
+ thing in both ("react" could match a project name AND a chat
+ title). Persisting keeps the filter useful. */
+ const [searchQuery, setSearchQuery] = useState('');
+ // Controls the add-project action sheet launched from the Projects header.
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
+ const [privateHelpOpen, setPrivateHelpOpen] = useState(false);
+ /* Ref to the list-region scroll container — handed to
+ `MobileWorkspaceTabBar` so the dock can collapse while the user
+ scrolls. */
+ const listScrollRef = useRef(null);
+ /* Pull-to-refresh: when the list is at the top and the user
+ pulls down past threshold, fire `onPullToRefresh`. The hook
+ reports `isRefreshing` while the promise is in flight (fed into
+ the header status pill so it shows "刷新中…" until the sync
+ settles) and `pullDistance` during the gesture itself, which both
+ translates the filter-pill bar + list down. The header runs a fast
+ top→bottom search exit, then mounts the centered pull/status pill. */
+ const {
+ pullDistance,
+ isRefreshing: isPullingRefresh,
+ threshold: pullThreshold,
+ } = usePullToRefresh({
+ scrollRef: listScrollRef,
+ onRefresh: () => onPullToRefresh?.() ?? Promise.resolve(),
+ enabled: Boolean(onPullToRefresh),
+ });
+ const pullPastThreshold = pullDistance >= pullThreshold;
+ const normalizedQuery = normalizeForSearch(searchQuery.trim());
+
+ /* First-run onboarding: the user opened the mobile app before ever
+ launching the desktop client, so the workspace has no machines and
+ no conversations. The Chat tab is the default landing surface, so we
+ surface a "download + start Lody on your computer" guide there instead
+ of a bare "no conversations" line. Gated to the live Chat list (not
+ the archive view, an active filter, or an in-progress search) so it
+ only ever stands in for the genuine empty workspace. */
+ const showChatOnboarding =
+ selectedTab === 'chat' &&
+ !isInitialDataLoading &&
+ !showArchived &&
+ !hasActiveChatFilters &&
+ normalizedQuery.length === 0 &&
+ machines.length === 0 &&
+ chats.length === 0;
+
+ /* Machine lookup table for the secondary line on Local + Chat rows.
+ Each row carries a `machineId`; we resolve it to `{name, isOnline}`
+ here rather than walking the array per row. */
+ const machineLookup = useMemo(() => {
+ const map = new Map();
+ for (const machine of machines) {
+ map.set(machine.id, {
+ name: machine.name,
+ isOnline: machine.isOnline,
+ isPrivate: machine.isPrivate,
+ });
+ }
+ return map;
+ }, [machines]);
+
+ /* The Tasks tab only exists while the beta gate is on. If it flips off
+ while the user is sitting on the tab (or a stale `selectedTab`
+ arrives), fall back to Chat rather than rendering a featureless
+ shell — gate-off must behave as if the tab were never built. */
+ const tasksTabActive = showTasksTab && selectedTab === 'tasks';
+ const effectiveSelectedTab: MobileHomeTab =
+ selectedTab === 'tasks' && !showTasksTab ? 'chat' : selectedTab;
+
+ const resolvedTheme: 'ios' | 'material' =
+ theme ?? (isIOSRuntimeEnvironment() ? 'ios' : 'material');
+
+ const searchPlaceholder =
+ labels.searchPlaceholder ?? defaultSearchPlaceholder(selectedTab, selectedProjectsSubTab);
+
+ /* Unified recents list for the Projects tab. Merged inside the
+ component so callers stay close to their own per-kind data. The
+ full list below stays per-sub-tab — only the recents are unified. */
+ const recentProjectsMerged = useMemo(
+ () => mergeRecentProjects(recentLocalProjects, recentGitHubRepos),
+ [recentLocalProjects, recentGitHubRepos]
+ );
+ const searchAriaLabel = labels.searchAriaLabel ?? searchPlaceholder;
+ const clearSearchAriaLabel = labels.clearSearchAriaLabel ?? 'Clear search';
+
+ /* Status/pull owns the middle chrome while pulling, refreshing, or
+ ambient connection needs attention. Search and status must never
+ overlap: search exits first (fast top→bottom fade), then the pill
+ mounts; on restore the pill unmounts immediately and search fades
+ back in. */
+ const wantStatusSlot =
+ pullDistance > 0 ||
+ isPullingRefresh ||
+ connectionUiState === 'loading' ||
+ connectionUiState === 'reconnecting' ||
+ connectionUiState === 'offline';
+ /* Keep search mounted for the exit transition; opacity/transform are
+ driven by `searchOpaque`. Pill only mounts once `statusRevealed`. */
+ const [searchOpaque, setSearchOpaque] = useState(() => !tasksTabActive && !wantStatusSlot);
+ const [statusRevealed, setStatusRevealed] = useState(() => tasksTabActive || wantStatusSlot);
+ useEffect(() => {
+ let reveal: number | undefined;
+ if (tasksTabActive) {
+ setSearchOpaque(false);
+ setStatusRevealed(true);
+ } else if (wantStatusSlot) {
+ /* Exit search first; reveal pill only after the fade finishes so
+ the two never share the chrome band. */
+ setSearchOpaque(false);
+ setStatusRevealed(false);
+ reveal = window.setTimeout(() => {
+ setStatusRevealed(true);
+ }, HEADER_SEARCH_EXIT_MS);
+ } else {
+ /* Restore: drop pill immediately (no crossfade overlap), then
+ fade search back in on the next frame so the exit transition
+ can reverse cleanly. */
+ setStatusRevealed(false);
+ setSearchOpaque(true);
+ }
+ return () => {
+ if (reveal !== undefined) window.clearTimeout(reveal);
+ };
+ }, [tasksTabActive, wantStatusSlot]);
+
+ /* Tab swipe was removed — conflicted with the row-level
+ left-swipe-to-reveal-actions gesture on conversation rows. The
+ dock tabbar at the bottom remains the only way to switch tabs. */
+
+ return (
+ /* No Konsta App wrapper anymore — the flat lists use plain
+ Tailwind-styled rows. The `safe-areas` class still has to be
+ here because Konsta's `pt-safe-*` / `ps-safe-*` / `pe-safe-*`
+ utilities (used by the header below and by other mobile screens
+ in this tree) only resolve the `--k-safe-area-*` vars when an
+ ancestor opts in via this class — `` used to do
+ it for us; now we do it directly. The class is just a CSS hook
+ (from konsta/styles/safe-areas.css), no Konsta runtime needed. */
+
+ {/* Column flex so the header + Chat-tab pill bar sit ABOVE the
+ scroll region — keeps the vertical scrollbar contained to
+ the list area instead of running through the chrome at the
+ top. Mirrors the structure on MobileProjectScreen. */}
+
+ {/* Sticky header: safe-area padding outside the chrome row so the
+ status/pull pill can center on the same h-9 band as the
+ workspace / search / trailing controls — not the full header
+ including `pt-safe` (that was pulling the pill into the notch). */}
+
+ {/* Single chrome row: workspace | search (middle blank) |
+ archive/settings. Search fills the gap at rest; on pull it
+ does a fast top→bottom fade, then the status/pull pill
+ mounts (never overlapping). Leading/trailing keep
+ `relative z-10` so taps stay hittable under the pill overlay. */}
+
+ {/* Icon-only workspace identity. With a menu callback it is the
+ switcher trigger; without one it remains a static nameplate. */}
+
+ {onWorkspaceMenuOpen ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Search stays mounted so CSS can run the exit transition.
+ Slot always occupies flex-1 so trailing discs don't jump. */}
+
+ {!tasksTabActive ? (
+
+ ) : null}
+
+
+ {/* Trailing header actions — same canvas liquid-glass discs as
+ the in-session mobile header (`GlassIconButton`). */}
+
+
+ {/* Connection / pull status — horizontal center of the chrome
+ row. Mounted only after search has finished exiting so the
+ two never share the band. pointer-events-none so it never
+ steals taps from workspace / trailing discs. */}
+ {statusRevealed ? (
+
+
+
+
+
+
+ {/* Pull-driven content region. Filter pills + list share one
+ `translate3d` so they move as a single compositor layer.
+ The header stays put: search does a fast top→bottom exit,
+ then the centered status pill mounts. */}
+
+ {/* The chat/projects group stays MOUNTED (but `hidden`) while
+ the Tasks tab is active: unmounting would drop the list
+ scroll element the pull-to-refresh + dock-collapse listeners
+ are bound to, and would reset the chat list's scroll
+ position on every Tasks round-trip. */}
+
0
+ ? {
+ transform: `translate3d(0, ${pullDistance}px, 0)`,
+ willChange: 'transform',
+ }
+ : undefined
+ }
+ >
+ {/* Chat-tab filter pill bar — toggled from the first group
+ heading's trailing chip. Stays ABOVE the list scroll region
+ (pinned) and rides pull-to-refresh with the list. */}
+
+ {selectedTab === 'chat' &&
+ chatFiltersOpen &&
+ chatFilterPills &&
+ chatFilterPills.length > 0 ? (
+
+
+
+ ) : null}
+
+
+
+
+ {/* Tasks tab: the shared All Tasks body (inbox + list) fills the
+ content region under the home header, dock still visible.
+ `embedded` skips BaseHeader's safe-area / drawer menu so we
+ don't double-stack chrome under the home header. The home
+ search row stays hidden (it only filters chats/projects).
+ Tapping a card routes to full-screen `/tasks/$taskId`. */}
+ {tasksTabActive ? (
+
+
+
+
+
+ ) : null}
+
+
+ {/* Shared workspace tabbar (chat / tasks / projects) + the
+ optional separate new-chat chip. The 设置 surface is reached
+ via the gear button in the top header, so it's no longer one
+ of the bottom tabs. Tabs are built locally so the
+ translations stay co-located with the other screen copy. */}
+
+ tabs={workspaceTabSpecs(labels, showTasksTab, showInboxTab)}
+ selectedTab={effectiveSelectedTab}
+ onTabSelect={(tab) => onTabSelect?.(tab)}
+ onNewChat={onNewChat}
+ newChatAriaLabel={labels.newChatAriaLabel}
+ ariaLabel={labels.chatTab ?? '导航'}
+ theme={resolvedTheme}
+ scrollContainerRef={listScrollRef}
+ />
+
+ );
+}
+
+function LocalProjectsList({
+ projects,
+ machineLookup,
+ hasMachines,
+ labels,
+ query,
+ onSelect,
+ onPrivateHelp,
+}: {
+ projects: MobileHomeLocalProject[];
+ machineLookup: Map;
+ hasMachines: boolean;
+ labels: MobileHomeScreenLabels;
+ query: string;
+ onSelect?: (projectId: string) => void;
+ onPrivateHelp: () => void;
+}) {
+ const visible = useMemo(() => {
+ if (!query) return projects;
+ return projects.filter(
+ (project) =>
+ normalizeForSearch(project.name).includes(query) ||
+ normalizeForSearch(project.path).includes(query)
+ );
+ }, [projects, query]);
+
+ /* Bucket the visible projects by machine. Group order follows the
+ caller's already-sorted project list, so cross-machine project
+ recency is not lost when section headings are inserted. */
+ const groups = useMemo>(() => {
+ const byMachine = new Map();
+ for (const project of visible) {
+ const bucket = byMachine.get(project.machineId);
+ if (bucket) bucket.push(project);
+ else byMachine.set(project.machineId, [project]);
+ }
+ return Array.from(byMachine.entries());
+ }, [visible]);
+
+ if (projects.length === 0) {
+ return (
+
+ );
+ }
+ if (query && visible.length === 0) {
+ return ;
+ }
+
+ return (
+ <>
+ {groups.map(([machineId, machineProjects]) => {
+ const machine = machineLookup.get(machineId) ?? null;
+ return (
+
+ project.isPrivate)}
+ onPrivateHelp={onPrivateHelp}
+ />
+
+ {machineProjects.map((project) => (
+ onSelect?.(project.id)}
+ disabled={Boolean(project.removalState)}
+ leading={
+
+ }
+ title={project.name}
+ /* Full project name (wraps instead of truncating). The
+ machine now lives in the group heading, so the row
+ only carries the name + path. */
+ wrapTitle
+ titleSuffix={
+ project.removalState ? (
+
+ {project.removalState === 'waiting_for_device' ? (
+
+ ) : (
+
+ )}
+
+ {project.removalState === 'waiting_for_device'
+ ? (labels.projectRemovalWaiting ?? 'Waiting for device…')
+ : (labels.projectRemoving ?? 'Removing…')}
+
+
+ ) : project.isPrivate ? (
+
+ ) : undefined
+ }
+ secondaryLine={
+ project.path ? (
+
+ {project.path}
+
+ ) : undefined
+ }
+ trailing={
+ project.removalState ? undefined : (
+
+ )
+ }
+ />
+ ))}
+
+
+ );
+ })}
+ >
+ );
+}
+
+/* ── GitHub repos (flat list, newest first) ──────────────────────────── */
+
+function GitHubReposList({
+ repositories,
+ labels,
+ query,
+ onSelect,
+}: {
+ repositories: MobileHomeGitHubRepository[];
+ labels: MobileHomeScreenLabels;
+ query: string;
+ onSelect?: (repoFullName: string) => void;
+}) {
+ const visible = useMemo(() => {
+ if (!query) return repositories;
+ return repositories.filter(
+ (repo) =>
+ normalizeForSearch(repo.fullName).includes(query) ||
+ normalizeForSearch(repo.name).includes(query) ||
+ normalizeForSearch(repo.ownerHandle).includes(query) ||
+ (repo.description ? normalizeForSearch(repo.description).includes(query) : false)
+ );
+ }, [repositories, query]);
+
+ if (repositories.length === 0) {
+ return (
+
+ );
+ }
+ if (query && visible.length === 0) {
+ return ;
+ }
+
+ return (
+ <>
+ {labels.allGitHubReposHeading ?? '全部仓库'}
+
+ {visible.map((repository) => (
+ onSelect?.(repository.fullName)}
+ leading={
+
+ }
+ title={repository.fullName}
+ trailing={
+
+ }
+ />
+ ))}
+
+ >
+ );
+}
+
+/* ── Chats (flat list, newest first) ─────────────────────────────────── */
+
+/* The filter pill bar is rendered ABOVE the scroll region by
+ `MobileHomeScreen` itself, so this view doesn't carry it anymore
+ — keeping the pill bar outside the scroll container is what
+ contains the vertical scrollbar to the list area only. */
+function ChatsFlatView({
+ chats,
+ groupBy,
+ labels,
+ query,
+ onSelect,
+ onTogglePin,
+ onArchive,
+ onRestore,
+ archived,
+ onPermanentDelete,
+ hasActiveFilters = false,
+ onClearFilters,
+ firstGroupTrailing,
+ onPrivateHelp,
+}: {
+ chats: MobileConversationItem[];
+ groupBy: MobileChatGroupBy;
+ labels: MobileHomeScreenLabels;
+ query: string;
+ onSelect?: (chatId: string) => void;
+ onTogglePin?: (chatId: string, nextPinned: boolean) => void;
+ onArchive?: (chatId: string) => void;
+ onRestore?: (chatId: string) => void;
+ archived?: boolean;
+ onPermanentDelete?: (chatIds: string[]) => void | Promise;
+ /** True when active filters narrowed the list — switches the empty
+ state to the "filters hid everything" copy + Clear filters button. */
+ hasActiveFilters?: boolean;
+ onClearFilters?: () => void;
+ /** Filter chip mounted on the first group heading (or a trailing-only
+ row when the list is empty / flat with no heading). */
+ firstGroupTrailing?: ReactNode;
+ onPrivateHelp: () => void;
+}) {
+ const visible = useMemo(() => {
+ if (!query) return chats;
+ return chats.filter(
+ (chat) =>
+ normalizeForSearch(chat.title).includes(query) ||
+ normalizeForSearch(chat.branchName).includes(query)
+ );
+ }, [chats, query]);
+
+ /* Shared "Clear filters" action for the empty states below. Only
+ offered when filters are active and a handler is wired. */
+ const clearFiltersAction =
+ hasActiveFilters && onClearFilters ? (
+
+ {labels.clearChatFilters ?? '清除所有过滤'}
+
+ ) : undefined;
+
+ /* Empty states still need the filter chip so the user can open the
+ bar / clear filters without a group heading to host it. */
+ const emptyTrailing =
+ firstGroupTrailing != null ? (
+
+ {firstGroupTrailing}
+
+ ) : null;
+
+ if (chats.length === 0) {
+ /* `chats` is already filtered by the caller, so an empty list with
+ active filters means the filters hid everything (vs. a workspace
+ with no conversations at all). */
+ return (
+
+ {emptyTrailing}
+
+
+ );
+ }
+ if (query && visible.length === 0) {
+ return (
+
+ {emptyTrailing}
+
+
+ );
+ }
+
+ return (
+ /* The chat tab aggregates EVERY conversation (chat-only,
+ local-project, github-repo) into one newest-first list. The
+ list body is rendered by `MobileChatList` (shared with the
+ in-project detail page) so design tweaks land in one place. */
+
+
+
+ );
+}
+
+/* Minimal first-run hint shown on the Chat tab of an empty workspace (no
+ machines + no conversations). The mobile app is a thin client over a
+ desktop/CLI host, so a brand-new user just needs a short nudge to go
+ install + start the desktop client — one icon, one line, one button.
+ Rendered inline (not a blocking modal) so the user can still switch
+ workspaces / open settings underneath it. Pure presentational — copy via
+ `labels`, action via `onDownloadClient` — so i18n + Storybook live in the
+ caller. */
+function MobileHomeOnboarding({
+ labels,
+ onDownloadClient,
+}: {
+ labels: MobileHomeOnboardingLabels;
+ onDownloadClient?: () => void;
+}) {
+ return (
+
+ {/* Wide enough that the one-line sub-copy doesn't wrap on a phone
+ (the title + button are short and stay centered regardless). */}
+
+ );
+}
+
+/** Viewer row (file/diff/PR/browser): icon + label, active highlight, no close. */
+function ViewerRow({
+ active,
+ leading,
+ label,
+ onSelect,
+}: {
+ active: boolean;
+ leading: ReactNode;
+ label: string;
+ onSelect: () => void;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Header trigger button — glass 💬 with a corner badge for background tabs.
+ *
+ * The badge is a single aggregate signal with precedence, since one dot can't
+ * express two things across many tabs:
+ * - unread wins → solid `bg-primary` dot (finished output waiting to be seen,
+ * the more actionable state);
+ * - else working → hollow ring with a slow opacity "breathe" (a background
+ * agent is still running — ambient, nothing to do yet).
+ * Shape (solid vs ring) carries the distinction so it survives reduced-motion.
+ */
+export function MobileSessionTabButton({
+ hasUnread,
+ hasWorking = false,
+ onOpen,
+ className,
+ ariaLabel,
+}: {
+ hasUnread: boolean;
+ hasWorking?: boolean;
+ onOpen: () => void;
+ className?: string;
+ ariaLabel?: string;
+}) {
+ const { t } = useTranslation();
+ const badge = hasUnread ? 'unread' : hasWorking ? 'working' : 'none';
+ return (
+
+
+ {badge === 'unread' ? (
+
+ ) : badge === 'working' ? (
+ // Fixed dark frame (bg-background disc); only the inner gradient dot pulses.
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/webapp/test/upstream-seam-pin.ts b/packages/webapp/test/upstream-seam-pin.ts
new file mode 100644
index 00000000..983119df
--- /dev/null
+++ b/packages/webapp/test/upstream-seam-pin.ts
@@ -0,0 +1,80 @@
+/**
+ * The seam pin, in one place, because two suites now make the same claim.
+ *
+ * It was `lody-surface-tabs.test.tsx`'s local helper while seam patch 5 was the
+ * only patch with a baseline to check against. Seam patch 15 patches two more
+ * vendored files and makes the same claim about them, and a second copy of this
+ * would be a second definition of what "inert" means.
+ *
+ * WHAT IT PROVES. With every new prop absent, a patched vendor file renders
+ * byte-for-byte what upstream renders. Two claims carry that:
+ *
+ * 1. Each declared anchor is the line upstream really has at that number, so
+ * the tables in `BLITZ-PATCHES.md` describe this tree and not a remembered
+ * one.
+ * 2. Upstream MINUS those lines is still a subsequence of the patched file.
+ * Every other line upstream wrote survives, in order — so the patch only
+ * ADDS, and the branches upstream takes with the new props absent are the
+ * branches it took before. An undeclared deletion, or a reworded line, fails
+ * with the first upstream line that could not be found.
+ *
+ * IT READS NOTHING BUT CHECKED-OUT FILES. Asking `git show :` for the
+ * pristine source works in a full clone and fails in CI: the subtree squash
+ * carries the upstream paths at their own root, and a shallow clone may not
+ * hold the object at all. The baselines are committed beside this file; see
+ * `upstream-baseline/README.md` for provenance and how to refresh them.
+ */
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect } from "vitest";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, "..", "..", "..");
+const componentsDir = join(repoRoot, "vendor/lody/packages/components/src");
+const baselineDir = join(here, "upstream-baseline");
+
+/** One line the seam removes from upstream, by its line number in the pristine
+ * file. The number is what makes an anchor unambiguous: ` )}` occurs
+ * dozens of times in `session-tab-bar.tsx`, and "the first one" is not a
+ * statement about anything. */
+export type SeamAnchor = readonly [line: number, text: string];
+
+const readLines = (path: string): string[] => readFileSync(path, "utf8").split("\n");
+
+/**
+ * Asserts one vendored file against its pristine upstream baseline.
+ *
+ * `vendorPath` is relative to `vendor/lody/packages/components/src`, and the
+ * baseline is `upstream-baseline/.txt`.
+ */
+export function expectSeam(vendorPath: string, anchors: readonly SeamAnchor[]): void {
+ const file = vendorPath.slice(vendorPath.lastIndexOf("/") + 1);
+ const upstream = readLines(join(baselineDir, `${file}.txt`));
+ const patched = readLines(join(componentsDir, vendorPath));
+ const removed = new Set();
+ for (const [line, text] of anchors) {
+ expect(upstream[line - 1], `${file}:${line} is the anchor BLITZ-PATCHES.md names`).toBe(text);
+ removed.add(line);
+ }
+ expect(removed.size, "an anchor is declared twice").toBe(anchors.length);
+
+ const kept = upstream.filter((_line, index) => !removed.has(index + 1));
+ // Greedy is exact for a subsequence test: the earliest match never rules out
+ // a later one, so a failure here is a line the patched file really lost.
+ let cursor = 0;
+ for (const line of kept) {
+ while (cursor < patched.length && patched[cursor] !== line) cursor += 1;
+ expect(
+ cursor,
+ `${file} no longer carries an upstream line the seam does not declare: ${JSON.stringify(line)}`,
+ ).toBeLessThan(patched.length);
+ cursor += 1;
+ }
+}
+
+/** The vendored file's own text, for a claim about what the patch ADDED — the
+ * half a subsequence check cannot make. */
+export function readVendoredSource(vendorPath: string): string {
+ return readFileSync(join(componentsDir, vendorPath), "utf8");
+}
diff --git a/plans/LODY-V1-SCOPE.md b/plans/LODY-V1-SCOPE.md
new file mode 100644
index 00000000..7cfe2dbd
--- /dev/null
+++ b/plans/LODY-V1-SCOPE.md
@@ -0,0 +1,134 @@
+# Lody v1 scope — the approved record
+
+This file is the durable record of what the vendored Lody surface offers in
+BlitzOS v1. The user approved the split. Before this file the record lived
+outside the repo, so a reader had no way to check a claim against the tree.
+
+Three sources agree with this file, and each one holds a different half:
+
+- `packages/webapp/src/lody/v1-scope.ts` — the flags, and the props they build.
+- `vendor/lody/BLITZ-PATCHES.md` — the seam patches that carry the props into
+ the vendored components.
+- The 463-row support matrix — the evidence. It grouped every row into 25
+ feature areas.
+
+Path roots: `V/` = `vendor/lody/packages/components/src/`,
+`W/` = `packages/webapp/src/`.
+
+## 1. The 25 areas, and the call on each
+
+**KEEP 16 areas · KILL 4 areas · DECIDE 5 areas, all five now answered.**
+
+| # | Area | Rows | Call |
+|---|---|--:|---|
+| 1 | Mount, boot, theme, agent sign-in | 22 | KEEP |
+| 2 | Session rail | 29 | KEEP |
+| 3 | Rail chrome we do not mount | 11 | KILL |
+| 4 | Landing scope selectors | 20 | KEEP |
+| 5 | Composer text, drafts, focus | 21 | KEEP |
+| 6 | Mentions, slash commands, skills | 25 | KEEP |
+| 7 | Attachments | 15 | KEEP |
+| 8 | Run configuration | 14 | KEEP |
+| 9 | Message queue and steering | 7 | KEEP |
+| 10 | Transcript and message actions | 38 | KEEP |
+| 11 | Permission requests and questions | 9 | KEEP |
+| 12 | Conversation chrome and header menu | 39 | KEEP |
+| 13 | Files panel and file viewer | 31 | KEEP |
+| 14 | Diffs, changes and Side Chat | 17 | KEEP |
+| 15 | Tabs and terminals | 35 | KEEP |
+| 16 | Worktrees | 13 | KEEP |
+| 17 | Sharing | 20 | KEEP |
+| 18 | Electron-only surfaces | 21 | KILL |
+| 19 | Command palette and keyboard chords | 9 | HIDE |
+| 20 | Settings surface and stub routes | 8 | KILL |
+| 21 | GitHub and pull-request flows | 22 | HIDE |
+| 22 | Lody-cloud and wrong-product surfaces | 18 | KILL |
+| 23 | Mobile | 5 | KEEP (amended, see §5) |
+| 24 | Agent Roles and MCP pickers | 8 | HIDE |
+| 25 | Deferred v1 items | 6 | Split |
+
+KILL and HIDE differ. A KILL area has no code path to it. A HIDE area has
+working code behind a flag, and one line brings it back.
+
+## 2. The five decisions
+
+1. **GitHub and pull requests: HIDE.** BlitzOS connects no GitHub App, so every
+ PR flow fails after the button. Upstream's own `githubIntegration` capability
+ answers `false` on a local platform, so seam patch 7 makes the surfaces ask.
+2. **Settings: KILL the affordances.** BlitzOS serves its own settings. The
+ Lody entry points flip an atom that nothing reads.
+3. **Agent Roles and MCP: HIDE.** Nothing writes the workspace catalog rows, so
+ both pickers are empty by construction.
+4. **Command palette and chords: HIDE.** We mount neither `commands.attach`
+ nor `CommandPalette`, so no chord reaches a dispatcher.
+5. **Deferred v1 items: the archive page ships.** Host-tab drag, split view and
+ agent orchestration stay out of v1.
+
+## 3. The flags
+
+`W/lody/v1-scope.ts` holds six flags. All six are `false` in v1.
+
+| Flag | Areas | Mechanism |
+|---|---|---|
+| `gitHubIntegration` | 21 | Upstream's own platform capability. |
+| `agentRolesAndMcp` | 24 | `hideAgentRoles` prop. |
+| `keyboardShortcuts` | 19 | `keyboardShortcutsAvailable` prop. |
+| `cloudSurfaces` | 22, 25 | `hideCloudMenuItems`, `hideNotificationPrompt`, `hideProductHints`, `hideTeamScope` props. |
+| `languageService` | 13 | `hideLanguageServiceActions` prop. |
+| `connectionStatus` | (ownership) | `hideConnectionStatus` prop, seam patch 15. |
+
+The mobile home header's settings gear reads `cloudSurfaces` through
+`hideSettingsEntry`. It is the same species as the hint band's Go-to-settings
+button, which that flag already covers.
+
+Two tests keep the areas dark: `packages/webapp/test/lody-v1-scope.test.tsx`
+pins the DOM, and `lody-v1-scope-sources.test.ts` pins the wiring.
+
+## 4. The seam patches
+
+An edit inside `vendor/lody` is legal only at a seam declared in
+`vendor/lody/BLITZ-PATCHES.md`. Read that file for the hunks. The index:
+
+| # | What it carries |
+|---|---|
+| 1 | The local-bridge predicate. |
+| 2 | `LoroSidebar` header and footer suppression. |
+| 3 | The attachment-sender predicate. |
+| 4 | The read-only session surface. |
+| 5 | Pluggable surface tabs — the terminal strip. |
+| 6 | The Side Chat launcher needs an assistant turn. |
+| 7 | Host suppression of the v1 scope cuts. |
+| 8 | The cloud-token guard must not preempt the local transport. |
+| 9 | `SessionList` rows lost the worktree glyph. |
+| 10 | The side panel's file surfaces. |
+| 11 | The composer's mention chips and the file drill-down. |
+| 12 | A landing image has no offline fallback. |
+| 13 | `LoroSidebar`'s footer, one entry at a time. |
+| 14 | The archive page's v1 scope cuts. |
+| 15 | The host owns connectivity, so Lody must not narrate it. |
+| 16 | The mobile branch: host tabs and the mobile scope cuts. |
+
+## 5. Amendments
+
+Each amendment carries a date and the change it records.
+
+- **2026-09-01 — the archive page ships (#164).** Area 25 said DECIDE. The
+ archive page enters v1 with restore and permanent delete. Seam patch 14 hides
+ its My Tasks / All Tasks scope control.
+- **2026-09-02 — connection status is Lody-side REMOVED (#173).** BlitzOS chrome
+ owns connection status. The shell footer says it for the whole workspace, so
+ Lody's status chip, its catch-up spinners and its mobile banner go dark. This
+ is an OWNERSHIP boundary, not a "not in v1" cut: the `connectionStatus` flag
+ flips when a host stops reporting connectivity, not when BlitzOS grows a
+ feature. Seam patch 15 carries it.
+- **2026-09-02 — mobile is IN.** Area 23 said KILL, because both real routes
+ dropped the mobile branch. Lody's real phone experience now mounts, scrubbed
+ to this scope. A phone gets the `MobileWorkspaceStack` shape, not squeezed
+ desktop UI. Seam patch 16 carries the host tabs into the mobile tab sheet and
+ the scope cuts into the mobile-only components.
+ The two amendments meet at one line. `session-detail.tsx` forks for mobile 952
+ lines above the builder that forwards every `hide*` prop, so seam patch 15's
+ own gate could not reach the phone's composer chip. Seam patch 16 hunk 11
+ forwards it. One flag, one prop, one gate story.
+- **Unchanged.** GitHub and PR flows, the command palette and the two pickers
+ stay hidden. A v1 revisit flips one field in `W/lody/v1-scope.ts`.
diff --git a/vendor/lody/BLITZ-PATCHES.md b/vendor/lody/BLITZ-PATCHES.md
index 70e82650..067d2c02 100644
--- a/vendor/lody/BLITZ-PATCHES.md
+++ b/vendor/lody/BLITZ-PATCHES.md
@@ -1392,6 +1392,175 @@ eighteen hunks and answer that instead from `v1-scope.ts`. If the status strip
gains a FOURTH state, decide it explicitly: connectivity answers the flag,
anything about membership or a blocked action does not.
+### 16. The mobile branch: host tabs and the v1 scope cuts (mobile mount, 2026-09-02)
+
+**One idea in two halves, 24 hunks in four files.** BlitzOS now mounts Lody's
+real phone experience (`packages/webapp/src/lody/MobileSessionStack.tsx`), so
+two things that were true only on a desktop have to become true on a phone: a
+host tab must be reachable, and the v1 scope cuts must fire.
+
+Seam patch 5 said this in writing — *"The mobile branch is deliberately NOT
+patched. `MobileSessionTabSheet` keeps a fourth, hand-maintained kind enum; the
+props are inert there and the mobile drawer keeps today's behaviour."* That was
+correct while both routes dropped the mobile branch. It is the gap now.
+
+**THE STRUCTURAL CAUSE, AND IT IS ONE SENTENCE.** `session-detail.tsx` returns
+for mobile at `:4803`, and `getSharedChatSurfaceProps` — the builder that
+forwards `hideCloudMenuItems`, `hideNotificationPrompt`, `hideAgentRoles` and
+seam patch 15's `hideConnectionStatus` to every chat surface — is defined at
+`:5755`, 952 lines BELOW it. The mobile branch hand-writes its own
+`SessionChatInterface` props and carried `readOnly` alone. So **props are lost
+at the mobile fork and capabilities are not**: every
+`useAppCapability('githubIntegration')` call sits above the return and answers
+on both branches, which is why seam patch 7's GitHub half needs nothing here and
+its other groups need everything.
+
+The same fork explains the landing. `hideProductHints` is read at
+`chat-landing.tsx:6584`, and the mobile branch returns at `:6174`/`:6282`.
+
+**WHAT SEAM PATCH 15 ALREADY DOES, SO THIS PATCH DOES NOT.** Connectivity is
+that patch's subject and it reaches the mobile branch on its own in two of the
+three places it matters:
+
+- The mobile home's connection banner is its hunk 18, on the one call site.
+- The mobile session header's catch-up spinner is its hunk 11, which gates
+ `activeSessionDocIsSyncing` at the source rather than at the header.
+- The composer status chip is the exception, and it is hunk 12 below. Its hunk
+ 13 forwards `hideConnectionStatus` through the shared builder, which the
+ mobile branch never reaches — the same sentence again.
+
+`hideConnectionStatus` is therefore DECLARED by seam patch 15 and merely
+forwarded here. One flag, one prop, one gate story.
+
+#### Half A — a host tab in the mobile tab sheet
+
+`packages/components/src/components/mobile/mobile-session-tab-sheet.tsx`
+
+| # | Line (at `f3474894`) | Upstream anchor | What it does |
+|---|---|---|---|
+| 1 | 58 | `kind: 'file' \| 'diff' \| 'pr' \| 'browser' \| 'files';` | adds `\| 'custom'` |
+| 2 | 55-60 | `ViewerTabEntry` | adds `icon?: ReactNode` (already imported at `:1`) |
+| 3 | 102-108 | `const VIEWER_ICON: Record` | adds the `custom` entry — the `Record` is total, so hunk 1 does not compile without it |
+| 4 | 226-238 | `const Icon = VIEWER_ICON[v.kind];` and the `leading` element | draws `v.icon` when the host supplied one, exactly as seam patch 5 hunk 3 does on the desktop strip (`session-tab-bar.tsx:476`) |
+
+**HUNK 1 IS ALSO A BUG FIX.** `session-detail.tsx:4368` already writes
+`kind: v.type` from a `ViewerTabItem`, whose `type` seam patch 5 hunk 2 widened
+to `'file' | 'diff' | 'custom'`. The mobile enum did not follow, so that
+assignment has been unsound since wave 3. It is unreachable today only because
+nothing on the mobile path reads `surfaceTabItems`.
+
+**THE SHEET GETS NO CLOSE VERB, AND THAT IS UPSTREAM'S DESIGN.** `ViewerRow` is
+one `