From 9d512957bbbbabeb040a35076ada79845140726b Mon Sep 17 00:00:00 2001 From: pythonlearner1025 <77006616+pythonlearner1025@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:00:54 +0000 Subject: [PATCH 1/2] =?UTF-8?q?lody:=20an=20invited=20member=20can=20send?= =?UTF-8?q?=20in=20a=20new=20chat=20=E2=80=94=20the=20worktree=20default?= =?UTF-8?q?=20waits=20for=20a=20box=20that=20answers=20git-state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An org member invited to a workspace opened `/chat`, typed a message, and found the send button greyed out with no way forward. The same account could send fine in another workspace. `getChatLandingSubmitDisabled` (`chat-landing-derived.ts`) has exactly one arm that can grey the button once text is typed: the local context, with a project selected, in WORKTREE mode, while git state is loading or has errored. All three halves of that were ours: - worktree mode is our seed. `seedWorktreeWorkdirDefault` wrote `lody.workdirMode.global` at provider mount for every member, unconditionally (LODY-SESSIONS §0.5). Upstream defaults to `'local'`, where the arm cannot fire. Note the predicate reads the SELECTED mode, not `effectiveWorkdirMode` — so it counts against a project that has no worktree available at all. - the local context is where a BlitzOS landing lands, because the GitHub context needs `publishBoxReposAsWorkspaceRepos` to find a clone with a GitHub remote and a local-source repo has none. - a project is auto-selected the moment that context is active (`chat-landing.tsx`), so the member never has to pick one. Once the git-state load errs it is PERMANENT — five retries, then a stuck error — so the composer never recovers. A box that cannot answer `local-project/git-state` became a chat surface nobody could send from. So the seed now waits for evidence. `publishBoxReposAsWorkspaceRepos` already asks git-state for every registered project, and it now returns that second answer as a `gitProbe` verdict: `verified` (every project answered and at least one is a real repository), `no-git-project` (all answered, none is a repo — a fresh box, or only the `/workspace` root project `workdir-default.ts` registers for plain chats), or `unanswered` (the list or any probe refused). One refusal poisons the verdict, because the landing may auto-select exactly the project that refused. `agent-config-gate.tsx` seeds on `verified` and nothing else; the provider-mount seed is gone. An unverified box is left on upstream's `'local'`, where a send always goes out. The writer is unchanged — write-once-when-absent — so a box that verifies on a later mount still gets the seed, and a member who set the key by hand is still not overwritten. WHAT THIS DOES NOT REPAIR: a browser that already holds the key. The seed is never deleted, because our write is indistinguishable from the member's own. Unticking the worktree pill once clears it. Co-Authored-By: Claude Fable 5 --- .../webapp/src/lody/agent-config-gate.tsx | 21 ++- packages/webapp/src/lody/local-projects.ts | 58 +++++++- .../webapp/src/lody/surface-providers.tsx | 9 +- packages/webapp/src/lody/workdir-default.ts | 17 +++ .../test/lody-agent-config-gate-memo.test.tsx | 8 +- .../test/lody-project-control-frames.test.ts | 135 +++++++++++++++++- .../webapp/test/lody-session-workdir.test.ts | 2 +- .../test/lody-terminal-tab-wave3.test.tsx | 7 +- 8 files changed, 240 insertions(+), 17 deletions(-) diff --git a/packages/webapp/src/lody/agent-config-gate.tsx b/packages/webapp/src/lody/agent-config-gate.tsx index 1cebd6ca..b857be40 100644 --- a/packages/webapp/src/lody/agent-config-gate.tsx +++ b/packages/webapp/src/lody/agent-config-gate.tsx @@ -21,7 +21,11 @@ import { publishBoxReposAsWorkspaceRepos, registerWorkspaceRepositories, } from "./local-projects.js"; -import { applyDefaultSessionProject, createSessionProjectDefaults } from "./workdir-default.js"; +import { + applyDefaultSessionProject, + createSessionProjectDefaults, + seedWorktreeWorkdirDefault, +} from "./workdir-default.js"; import type { LodyAtomStore, LodyRuntimeEndpoints, LodyWorkspaceRuntime } from "./runtime.js"; /** @@ -210,7 +214,20 @@ export function LodyAgentConfigGate(props: { // name is in the workspace's connected-repo list, and without that // field the session is a chat to the rail and to the daemon's diff // stats alike. See `local-projects.ts`. - await publishBoxReposAsWorkspaceRepos(store, endpointsRef.current, runtime, machineId); + const publication = await publishBoxReposAsWorkspaceRepos( + store, + endpointsRef.current, + runtime, + machineId, + ); + // The worktree default is seeded HERE, on the sweep's own verdict, and + // not unconditionally at provider mount: the landing greys its send + // button whenever a local project is selected in worktree mode and its + // git-state load errs, so a box the sweep could not verify must be + // left on upstream's `'local'` default, where a send can always go + // out. The key is only ever written when absent, so a box that + // verifies on a later mount still gets the seed (`workdir-default.ts`). + if (publication.gitProbe === "verified") seedWorktreeWorkdirDefault(); // Second, and only after the rows exist: the capabilities pass keys off // them. A config that fails to report costs the composer that agent's // selectors and nothing else, so it is warned about rather than raised diff --git a/packages/webapp/src/lody/local-projects.ts b/packages/webapp/src/lody/local-projects.ts index ee6da75d..535c7f9f 100644 --- a/packages/webapp/src/lody/local-projects.ts +++ b/packages/webapp/src/lody/local-projects.ts @@ -259,20 +259,49 @@ export function browseDirPage(result: JsonValue): BrowseDirPage { * the daemon's own answer to `local-project/git-state`, so nothing is invented * here — a clone with no GitHub remote contributes nothing and its sessions stay * in Chats, which is the honest reading. + * + * THE SWEEP IS ALSO A PROBE, and `gitProbe` is its second answer. The landing + * disables its send button whenever a local project is selected in worktree + * mode and the project's git-state load errs (`getChatLandingSubmitDisabled`, + * `chat-landing-derived.ts`) — and worktree mode is OUR default, seeded by + * `workdir-default.ts`. So the seed must not land on a box whose daemon cannot + * actually answer git-state: this sweep already asks the exact question for + * every registered project, and the verdict says whether every project + * answered and at least one is a git repository the worktree default could + * apply to. `agent-config-gate.tsx` seeds on `"verified"` and nothing else. */ +export type LocalProjectGitProbe = + /** Every registered project answered `git-state`, and at least one is a git + * repository — the worktree default is meaningful and known to load. */ + | "verified" + /** Every probe answered, but no project is a git repository (a fresh box, or + * only the `/workspace` root project). Nothing to conclude; ask again on the + * next mount. */ + | "no-git-project" + /** The list or any git-state call was refused — the state the landing's own + * loader would turn into a permanent error under a worktree default. */ + | "unanswered"; + +export interface BoxRepoPublication { + /** The GitHub full names published to the workspace-repos cache. */ + publishedFullNames: string[]; + gitProbe: LocalProjectGitProbe; +} + export async function publishBoxReposAsWorkspaceRepos( store: LodyAtomStore, endpoints: LodyHttpPlaneEndpoints, runtime: LodyWorkspaceRuntime, machineId: string, -): Promise { +): Promise { const listed = await sendProjectControl(endpoints, { type: "local-project/list", machineId, }); - if (!listed.ok) return []; + if (!listed.ok) return { publishedFullNames: [], gitProbe: "unanswered" }; const projects = localProjectRows(listed.result); + let gitProbe: LocalProjectGitProbe = "no-git-project"; const repositories: { fullName: string }[] = []; for (const project of projects) { const lookup = await readLocalProjectRepoFullName( @@ -281,13 +310,21 @@ export async function publishBoxReposAsWorkspaceRepos( runtime.workspaceId, project.localProjectId, ); - if (!lookup.answered || lookup.repoFullName === null) continue; + if (!lookup.answered) { + // One refused probe poisons the verdict for good: the landing may + // auto-select exactly this project, so "the box answers git-state" has + // to hold for every project, not just one. + gitProbe = "unanswered"; + continue; + } + if (gitProbe === "no-git-project" && lookup.git) gitProbe = "verified"; + if (lookup.repoFullName === null) continue; repositories.push({ fullName: lookup.repoFullName }); } - if (repositories.length === 0) return []; + if (repositories.length === 0) return { publishedFullNames: [], gitProbe }; store.set(setWorkspaceReposCacheAtom, { workspaceId: runtime.workspaceId, repositories }); - return repositories.map((repository) => repository.fullName); + return { publishedFullNames: repositories.map((repository) => repository.fullName), gitProbe }; } /** The `local-project/list` result, narrowed to the one field this file reads. */ @@ -319,7 +356,7 @@ function localProjectRows(result: JsonValue): { localProjectId: string }[] { * next try. `workdir-default.ts` memoizes on exactly this distinction. */ export type LocalProjectRepoLookup = - | { answered: true; repoFullName: string | null } + | { answered: true; repoFullName: string | null; git: boolean } | { answered: false }; /** @@ -343,7 +380,14 @@ export async function readLocalProjectRepoFullName( localProjectId, }); if (!state.ok) return { answered: false }; - return { answered: true, repoFullName: repoFullNameOf(state.result) }; + return { answered: true, repoFullName: repoFullNameOf(state.result), git: isGitRepository(state.result) }; +} + +/** Whether a `local-project/git-state` result names a git repository. `git` is + * the daemon's own verdict: `true` for any repository, remote or not, and + * `false` for a registered plain directory such as the `/workspace` root. */ +function isGitRepository(result: JsonValue): boolean { + return isJsonObject(result) && result.git === true; } /** `githubRepoFullName` off a `local-project/git-state` result, or `null` for a diff --git a/packages/webapp/src/lody/surface-providers.tsx b/packages/webapp/src/lody/surface-providers.tsx index e6a370b1..97e44247 100644 --- a/packages/webapp/src/lody/surface-providers.tsx +++ b/packages/webapp/src/lody/surface-providers.tsx @@ -19,7 +19,6 @@ import { TooltipProvider } from "@lody/components/ui/tooltip"; import { Toaster } from "@lody/components/ui/sonner"; import { initLodyI18n } from "./i18n.js"; import { BlitzThemedLodyTree, adoptShellTheme } from "./shell-theme.js"; -import { seedWorktreeWorkdirDefault } from "./workdir-default.js"; /** * THE TOASTER IS PART OF THE STACK, and leaving it out cost the surface every @@ -65,9 +64,11 @@ export const LODY_TOASTER_HOST_CLASS = "lody-surface__toaster"; export function LodySurfaceProviders(props: { children: ReactNode }) { const i18n = useMemo(() => initLodyI18n(), []); const theme = useMemo(() => adoptShellTheme(), []); - // Beside the theme adoption for the same reason: both write a key their own - // code reads on first render, so both have to happen before that render. - useMemo(() => seedWorktreeWorkdirDefault(), []); + // The worktree workdir default is NOT seeded here any more. It used to be, + // "before their first render" — but an unconditional seed armed the landing's + // one greyed-send trap on any box whose git-state probe fails, so the seed + // now waits for the agent-config bootstrap's own sweep to verify the box + // first (`agent-config-gate.tsx`, `workdir-default.ts` §1). return ( diff --git a/packages/webapp/src/lody/workdir-default.ts b/packages/webapp/src/lody/workdir-default.ts index 2b5ad812..fbe8d44a 100644 --- a/packages/webapp/src/lody/workdir-default.ts +++ b/packages/webapp/src/lody/workdir-default.ts @@ -25,6 +25,23 @@ * and only when it is absent. That leaves both overrides intact: their own * per-project write (which is what ticking the pill off does) still wins, and a * member who sets the global key by hand is not overwritten on the next mount. + * + * SEEDED ONLY ON A BOX THE GIT-STATE SWEEP VERIFIED (the greyed-send finding, + * 2026-09-02). A worktree default is not free: the landing's send button is + * disabled whenever a local project is selected in worktree mode and its + * git-state load errs or never finishes (`getChatLandingSubmitDisabled`, + * `chat-landing-derived.ts` — the predicate reads the SELECTED mode, not the + * effective one), and the landing auto-selects a project the moment the local + * context is active. So an unconditional seed turned any box that cannot + * answer `local-project/git-state` into a composer nobody can send from. The + * caller is therefore `agent-config-gate.tsx`, after + * `publishBoxReposAsWorkspaceRepos` — whose sweep asks git-state for every + * registered project anyway — answers `"verified"`: every project answered and + * at least one is a git repository. An unverified mount seeds nothing and + * upstream's reader falls back to `'local'`, where a send always goes out; the + * key being written only when absent means a later verified mount still seeds. + * The cost is that the very first mount of a fresh browser may render the pill + * off until the sweep lands, which is the honest degradation. */ import { FILES_DAV_ROOT } from "../resolver.js"; import { isJsonObject, isJsonString, type JsonObject, type JsonValue } from "@blitzos/schema"; diff --git a/packages/webapp/test/lody-agent-config-gate-memo.test.tsx b/packages/webapp/test/lody-agent-config-gate-memo.test.tsx index 044b9ac4..cf735251 100644 --- a/packages/webapp/test/lody-agent-config-gate-memo.test.tsx +++ b/packages/webapp/test/lody-agent-config-gate-memo.test.tsx @@ -39,12 +39,18 @@ vi.mock("../src/lody/agent-configs.js", () => ({ })); vi.mock("../src/lody/local-projects.js", () => ({ mirrorLocalProjectsToMachineMeta: async () => undefined, - publishBoxReposAsWorkspaceRepos: async () => undefined, + // An unverified probe, so the gate never writes the worktree default into + // this process's localStorage. + publishBoxReposAsWorkspaceRepos: async () => ({ + publishedFullNames: [], + gitProbe: "no-git-project", + }), registerWorkspaceRepositories: async () => undefined, })); vi.mock("../src/lody/workdir-default.js", () => ({ applyDefaultSessionProject: (runtime: unknown) => runtime, createSessionProjectDefaults: () => ({}), + seedWorktreeWorkdirDefault: () => null, })); import { LodyAgentConfigGate, resetAgentConfigGateMemoForTests } from "../src/lody/agent-config-gate.js"; diff --git a/packages/webapp/test/lody-project-control-frames.test.ts b/packages/webapp/test/lody-project-control-frames.test.ts index 4e8d8824..c1b9e440 100644 --- a/packages/webapp/test/lody-project-control-frames.test.ts +++ b/packages/webapp/test/lody-project-control-frames.test.ts @@ -17,14 +17,17 @@ import type { JsonObject } from "@blitzos/schema"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { createStore } from "jotai"; import { describe, expect, it } from "vitest"; import { LocalProjectControlRequestSchema } from "@lody/shared/message-schemas"; import { createLodyLocalBridge } from "../src/lody/local-bridge.js"; import { + publishBoxReposAsWorkspaceRepos, readLocalProjectRepoFullName, registerWorkspaceRepositories, } from "../src/lody/local-projects.js"; import { sendProjectControl } from "../src/lody/rpc-client.js"; +import type { LodyWorkspaceRuntime } from "../src/lody/runtime.js"; import { repoRoot } from "./lody-daemon-harness.js"; const CORPUS = join(repoRoot(), "packages/schema/fixtures/lody-project-registration"); @@ -203,7 +206,7 @@ describe("local-project control frames", () => { "local-project-5c929c9ed93542aaa69bc27e", ); - expect(lookup).toEqual({ answered: true, repoFullName: "blitzdotdev/wt-probe" }); + expect(lookup).toEqual({ answered: true, repoFullName: "blitzdotdev/wt-probe", git: true }); expect(sent[0]).toEqual({ type: "local-project/git-state", machineId: MACHINE_ID, @@ -313,3 +316,133 @@ describe("local-project control frames", () => { bridge.dispose(); }); }); + +/** + * The publish sweep's second answer: the git probe that gates the worktree + * workdir default (`workdir-default.ts` §1). The landing greys its send button + * when a local project in worktree mode cannot load git state, so the seed must + * only ever land on a box this sweep verified — every project answered + * `git-state` and at least one is a git repository. + */ +describe("the publish sweep's git probe", () => { + // SAFETY: `publishBoxReposAsWorkspaceRepos` reads exactly `workspaceId` off + // the runtime; the vendor shape is unreachable from it, so a call that grew + // another member would fail here first. + const runtime = { workspaceId: WORKSPACE_ID } as unknown as LodyWorkspaceRuntime; + + it("answers verified for a box whose one project is a git repository", async () => { + const { fetchImpl, sent } = stubFetchSequence([ + fixture("response/list-one-project.json"), + fixture("response/git-state-github-remote.json"), + ]); + + const publication = await publishBoxReposAsWorkspaceRepos( + createStore(), + endpoints(fetchImpl), + runtime, + MACHINE_ID, + ); + + expect(publication.gitProbe).toBe("verified"); + expect(publication.publishedFullNames).toEqual(["blitzdotdev/wt-probe"]); + // Both requests the sweep sent are Lody's own union — the probe added no + // second wire shape. + for (const request of sent) { + expect(LocalProjectControlRequestSchema.safeParse(request).success).toBe(true); + } + }); + + it("answers no-git-project when every project is a plain directory", async () => { + // The `/workspace` root project `workdir-default.ts` registers for plain + // chats is exactly this shape: registered, answering, and not a repository. + const { fetchImpl } = stubFetchSequence([ + fixture("response/list-one-project.json"), + { ...fixture("response/git-state-github-remote.json"), result: { git: false } }, + ]); + + const publication = await publishBoxReposAsWorkspaceRepos( + createStore(), + endpoints(fetchImpl), + runtime, + MACHINE_ID, + ); + + expect(publication.gitProbe).toBe("no-git-project"); + expect(publication.publishedFullNames).toEqual([]); + }); + + it("answers no-git-project for a fresh box with no projects at all", async () => { + const { fetchImpl } = stubFetchSequence([fixture("response/list-empty.json")]); + + const publication = await publishBoxReposAsWorkspaceRepos( + createStore(), + endpoints(fetchImpl), + runtime, + MACHINE_ID, + ); + + expect(publication.gitProbe).toBe("no-git-project"); + }); + + it("answers unanswered when a git-state probe is refused, even after a verified one", async () => { + // One refusal poisons the verdict: the landing may auto-select exactly the + // project that refused, so partial health is not health. + const twoProjects = { + ok: true, + type: "local-project/list", + result: { + workspaces: [ + { + workspaceId: WORKSPACE_ID, + workspaceName: "Lody", + projects: [ + { + localProjectId: "local-project-5c929c9ed93542aaa69bc27e", + name: "wt-probe", + rootPath: "/tmp/lp7/ws/wt-probe", + }, + { + localProjectId: "local-project-0000000000000000deadbeef", + name: "refuses", + rootPath: "/tmp/lp7/ws/refuses", + }, + ], + }, + ], + }, + }; + const { fetchImpl } = stubFetchSequence([ + twoProjects, + fixture("response/git-state-github-remote.json"), + { ok: false, type: "local-project/git-state", error: "internal_error", message: "" }, + ]); + + const publication = await publishBoxReposAsWorkspaceRepos( + createStore(), + endpoints(fetchImpl), + runtime, + MACHINE_ID, + ); + + expect(publication.gitProbe).toBe("unanswered"); + // The clone that DID answer is still published — the probe verdict guards + // the workdir seed, not the workspace-repos cache. + expect(publication.publishedFullNames).toEqual(["blitzdotdev/wt-probe"]); + }); + + it("answers unanswered when the project list itself is refused", async () => { + const { fetchImpl } = stubFetchSequence([ + { ok: false, type: "local-project/list", error: "internal_error", message: "" }, + ]); + + const publication = await publishBoxReposAsWorkspaceRepos( + createStore(), + endpoints(fetchImpl), + runtime, + MACHINE_ID, + ); + + expect(publication.gitProbe).toBe("unanswered"); + expect(publication.publishedFullNames).toEqual([]); + }); +}); diff --git a/packages/webapp/test/lody-session-workdir.test.ts b/packages/webapp/test/lody-session-workdir.test.ts index 6ed1164f..227c479b 100644 --- a/packages/webapp/test/lody-session-workdir.test.ts +++ b/packages/webapp/test/lody-session-workdir.test.ts @@ -423,7 +423,7 @@ describe("the clone's own remote, on a repo-backed session", () => { const { repoFullName } = createSessionProjectDefaults({ ...PLANE_ENDPOINTS, fetchImpl }, "m-1"); expect(await repoFullName(WORKSPACE_ID, CLONE_PROJECT_ID)).toEqual({ answered: false }); - const answered = { answered: true, repoFullName: CLONE_REPO_FULL_NAME }; + const answered = { answered: true, repoFullName: CLONE_REPO_FULL_NAME, git: true }; expect(await repoFullName(WORKSPACE_ID, CLONE_PROJECT_ID)).toEqual(answered); expect(await repoFullName(WORKSPACE_ID, CLONE_PROJECT_ID)).toEqual(answered); expect(calls).toHaveLength(2); diff --git a/packages/webapp/test/lody-terminal-tab-wave3.test.tsx b/packages/webapp/test/lody-terminal-tab-wave3.test.tsx index a227a98a..001f645e 100644 --- a/packages/webapp/test/lody-terminal-tab-wave3.test.tsx +++ b/packages/webapp/test/lody-terminal-tab-wave3.test.tsx @@ -738,7 +738,12 @@ describe("ADJ2 — the agent-config bootstrap runs once per box", () => { })); vi.doMock("../src/lody/local-projects.js", () => ({ mirrorLocalProjectsToMachineMeta: async () => undefined, - publishBoxReposAsWorkspaceRepos: async () => [], + // An unverified probe, so the gate under test never writes the worktree + // default into this process's localStorage. + publishBoxReposAsWorkspaceRepos: async () => ({ + publishedFullNames: [], + gitProbe: "no-git-project", + }), })); const { LodyAgentConfigGate } = await import("../src/lody/agent-config-gate.js"); const { runtimeAtom } = await import("@lody/components/atoms/runtime"); From 266b3cfc409befd762d72a8064224cf94e750d7f Mon Sep 17 00:00:00 2001 From: Minjune Song Date: Wed, 2 Sep 2026 18:27:46 -0700 Subject: [PATCH 2/2] fix(lody): fall back to local when git state fails Replace the asynchronous box-local seed probe with the landing's existing effective local fallback. Keep worktree selected while Git state loads, but permit both click and keyboard submission after a terminal error. Declare the vendored seam, add a regression for the cross-workspace global preference, and include the upstream PR sketch. --- .../webapp/src/lody/agent-config-gate.tsx | 21 +-- packages/webapp/src/lody/local-projects.ts | 58 +------- .../webapp/src/lody/surface-providers.tsx | 11 +- packages/webapp/src/lody/workdir-default.ts | 24 ++-- .../test/lody-agent-config-gate-memo.test.tsx | 8 +- .../test/lody-git-state-fallback.test.ts | 78 ++++++++++ .../test/lody-project-control-frames.test.ts | 135 +----------------- .../webapp/test/lody-session-workdir.test.ts | 2 +- .../test/lody-terminal-tab-wave3.test.tsx | 7 +- plans/evidence/lody-git-state-fallback-pr.md | 68 +++++++++ vendor/lody/BLITZ-PATCHES.md | 52 ++++++- .../components/chat/chat-landing-derived.ts | 2 +- .../src/components/chat/chat-landing.tsx | 7 - 13 files changed, 222 insertions(+), 251 deletions(-) create mode 100644 packages/webapp/test/lody-git-state-fallback.test.ts create mode 100644 plans/evidence/lody-git-state-fallback-pr.md diff --git a/packages/webapp/src/lody/agent-config-gate.tsx b/packages/webapp/src/lody/agent-config-gate.tsx index b857be40..1cebd6ca 100644 --- a/packages/webapp/src/lody/agent-config-gate.tsx +++ b/packages/webapp/src/lody/agent-config-gate.tsx @@ -21,11 +21,7 @@ import { publishBoxReposAsWorkspaceRepos, registerWorkspaceRepositories, } from "./local-projects.js"; -import { - applyDefaultSessionProject, - createSessionProjectDefaults, - seedWorktreeWorkdirDefault, -} from "./workdir-default.js"; +import { applyDefaultSessionProject, createSessionProjectDefaults } from "./workdir-default.js"; import type { LodyAtomStore, LodyRuntimeEndpoints, LodyWorkspaceRuntime } from "./runtime.js"; /** @@ -214,20 +210,7 @@ export function LodyAgentConfigGate(props: { // name is in the workspace's connected-repo list, and without that // field the session is a chat to the rail and to the daemon's diff // stats alike. See `local-projects.ts`. - const publication = await publishBoxReposAsWorkspaceRepos( - store, - endpointsRef.current, - runtime, - machineId, - ); - // The worktree default is seeded HERE, on the sweep's own verdict, and - // not unconditionally at provider mount: the landing greys its send - // button whenever a local project is selected in worktree mode and its - // git-state load errs, so a box the sweep could not verify must be - // left on upstream's `'local'` default, where a send can always go - // out. The key is only ever written when absent, so a box that - // verifies on a later mount still gets the seed (`workdir-default.ts`). - if (publication.gitProbe === "verified") seedWorktreeWorkdirDefault(); + await publishBoxReposAsWorkspaceRepos(store, endpointsRef.current, runtime, machineId); // Second, and only after the rows exist: the capabilities pass keys off // them. A config that fails to report costs the composer that agent's // selectors and nothing else, so it is warned about rather than raised diff --git a/packages/webapp/src/lody/local-projects.ts b/packages/webapp/src/lody/local-projects.ts index 535c7f9f..ee6da75d 100644 --- a/packages/webapp/src/lody/local-projects.ts +++ b/packages/webapp/src/lody/local-projects.ts @@ -259,49 +259,20 @@ export function browseDirPage(result: JsonValue): BrowseDirPage { * the daemon's own answer to `local-project/git-state`, so nothing is invented * here — a clone with no GitHub remote contributes nothing and its sessions stay * in Chats, which is the honest reading. - * - * THE SWEEP IS ALSO A PROBE, and `gitProbe` is its second answer. The landing - * disables its send button whenever a local project is selected in worktree - * mode and the project's git-state load errs (`getChatLandingSubmitDisabled`, - * `chat-landing-derived.ts`) — and worktree mode is OUR default, seeded by - * `workdir-default.ts`. So the seed must not land on a box whose daemon cannot - * actually answer git-state: this sweep already asks the exact question for - * every registered project, and the verdict says whether every project - * answered and at least one is a git repository the worktree default could - * apply to. `agent-config-gate.tsx` seeds on `"verified"` and nothing else. */ -export type LocalProjectGitProbe = - /** Every registered project answered `git-state`, and at least one is a git - * repository — the worktree default is meaningful and known to load. */ - | "verified" - /** Every probe answered, but no project is a git repository (a fresh box, or - * only the `/workspace` root project). Nothing to conclude; ask again on the - * next mount. */ - | "no-git-project" - /** The list or any git-state call was refused — the state the landing's own - * loader would turn into a permanent error under a worktree default. */ - | "unanswered"; - -export interface BoxRepoPublication { - /** The GitHub full names published to the workspace-repos cache. */ - publishedFullNames: string[]; - gitProbe: LocalProjectGitProbe; -} - export async function publishBoxReposAsWorkspaceRepos( store: LodyAtomStore, endpoints: LodyHttpPlaneEndpoints, runtime: LodyWorkspaceRuntime, machineId: string, -): Promise { +): Promise { const listed = await sendProjectControl(endpoints, { type: "local-project/list", machineId, }); - if (!listed.ok) return { publishedFullNames: [], gitProbe: "unanswered" }; + if (!listed.ok) return []; const projects = localProjectRows(listed.result); - let gitProbe: LocalProjectGitProbe = "no-git-project"; const repositories: { fullName: string }[] = []; for (const project of projects) { const lookup = await readLocalProjectRepoFullName( @@ -310,21 +281,13 @@ export async function publishBoxReposAsWorkspaceRepos( runtime.workspaceId, project.localProjectId, ); - if (!lookup.answered) { - // One refused probe poisons the verdict for good: the landing may - // auto-select exactly this project, so "the box answers git-state" has - // to hold for every project, not just one. - gitProbe = "unanswered"; - continue; - } - if (gitProbe === "no-git-project" && lookup.git) gitProbe = "verified"; - if (lookup.repoFullName === null) continue; + if (!lookup.answered || lookup.repoFullName === null) continue; repositories.push({ fullName: lookup.repoFullName }); } - if (repositories.length === 0) return { publishedFullNames: [], gitProbe }; + if (repositories.length === 0) return []; store.set(setWorkspaceReposCacheAtom, { workspaceId: runtime.workspaceId, repositories }); - return { publishedFullNames: repositories.map((repository) => repository.fullName), gitProbe }; + return repositories.map((repository) => repository.fullName); } /** The `local-project/list` result, narrowed to the one field this file reads. */ @@ -356,7 +319,7 @@ function localProjectRows(result: JsonValue): { localProjectId: string }[] { * next try. `workdir-default.ts` memoizes on exactly this distinction. */ export type LocalProjectRepoLookup = - | { answered: true; repoFullName: string | null; git: boolean } + | { answered: true; repoFullName: string | null } | { answered: false }; /** @@ -380,14 +343,7 @@ export async function readLocalProjectRepoFullName( localProjectId, }); if (!state.ok) return { answered: false }; - return { answered: true, repoFullName: repoFullNameOf(state.result), git: isGitRepository(state.result) }; -} - -/** Whether a `local-project/git-state` result names a git repository. `git` is - * the daemon's own verdict: `true` for any repository, remote or not, and - * `false` for a registered plain directory such as the `/workspace` root. */ -function isGitRepository(result: JsonValue): boolean { - return isJsonObject(result) && result.git === true; + return { answered: true, repoFullName: repoFullNameOf(state.result) }; } /** `githubRepoFullName` off a `local-project/git-state` result, or `null` for a diff --git a/packages/webapp/src/lody/surface-providers.tsx b/packages/webapp/src/lody/surface-providers.tsx index 97e44247..dd1cddc7 100644 --- a/packages/webapp/src/lody/surface-providers.tsx +++ b/packages/webapp/src/lody/surface-providers.tsx @@ -19,6 +19,7 @@ import { TooltipProvider } from "@lody/components/ui/tooltip"; import { Toaster } from "@lody/components/ui/sonner"; import { initLodyI18n } from "./i18n.js"; import { BlitzThemedLodyTree, adoptShellTheme } from "./shell-theme.js"; +import { seedWorktreeWorkdirDefault } from "./workdir-default.js"; /** * THE TOASTER IS PART OF THE STACK, and leaving it out cost the surface every @@ -64,11 +65,11 @@ export const LODY_TOASTER_HOST_CLASS = "lody-surface__toaster"; export function LodySurfaceProviders(props: { children: ReactNode }) { const i18n = useMemo(() => initLodyI18n(), []); const theme = useMemo(() => adoptShellTheme(), []); - // The worktree workdir default is NOT seeded here any more. It used to be, - // "before their first render" — but an unconditional seed armed the landing's - // one greyed-send trap on any box whose git-state probe fails, so the seed - // now waits for the agent-config bootstrap's own sweep to verify the box - // first (`agent-config-gate.tsx`, `workdir-default.ts` §1). + // Beside theme adoption because both write a key the vendored tree reads on + // first render. A failed Git-state load makes the landing's effective mode + // local; seam patch 18 keeps both submit paths aligned with that fallback, so + // the default is safe even for an old or temporarily unreachable box. + useMemo(() => seedWorktreeWorkdirDefault(), []); return ( diff --git a/packages/webapp/src/lody/workdir-default.ts b/packages/webapp/src/lody/workdir-default.ts index fbe8d44a..8c0bc7ef 100644 --- a/packages/webapp/src/lody/workdir-default.ts +++ b/packages/webapp/src/lody/workdir-default.ts @@ -26,22 +26,14 @@ * per-project write (which is what ticking the pill off does) still wins, and a * member who sets the global key by hand is not overwritten on the next mount. * - * SEEDED ONLY ON A BOX THE GIT-STATE SWEEP VERIFIED (the greyed-send finding, - * 2026-09-02). A worktree default is not free: the landing's send button is - * disabled whenever a local project is selected in worktree mode and its - * git-state load errs or never finishes (`getChatLandingSubmitDisabled`, - * `chat-landing-derived.ts` — the predicate reads the SELECTED mode, not the - * effective one), and the landing auto-selects a project the moment the local - * context is active. So an unconditional seed turned any box that cannot - * answer `local-project/git-state` into a composer nobody can send from. The - * caller is therefore `agent-config-gate.tsx`, after - * `publishBoxReposAsWorkspaceRepos` — whose sweep asks git-state for every - * registered project anyway — answers `"verified"`: every project answered and - * at least one is a git repository. An unverified mount seeds nothing and - * upstream's reader falls back to `'local'`, where a send always goes out; the - * key being written only when absent means a later verified mount still seeds. - * The cost is that the very first mount of a fresh browser may render the pill - * off until the sweep lands, which is the honest degradation. + * SAFE WHEN GIT STATE IS UNAVAILABLE (the greyed-send finding, 2026-09-02). + * The key is global, so no box-local probe can make the preference safe: a + * healthy box can seed it before the same browser visits an old or temporarily + * unreachable box. The vendored landing already computes `effectiveWorkdirMode` + * as `'local'` when Git state is unavailable and renders the toggle off; seam + * patch 18 makes its button and keyboard-submit gates honor that same fallback. + * That fixes existing stored values as well as new ones while preserving the + * worktree-first render on every healthy box. */ import { FILES_DAV_ROOT } from "../resolver.js"; import { isJsonObject, isJsonString, type JsonObject, type JsonValue } from "@blitzos/schema"; diff --git a/packages/webapp/test/lody-agent-config-gate-memo.test.tsx b/packages/webapp/test/lody-agent-config-gate-memo.test.tsx index cf735251..044b9ac4 100644 --- a/packages/webapp/test/lody-agent-config-gate-memo.test.tsx +++ b/packages/webapp/test/lody-agent-config-gate-memo.test.tsx @@ -39,18 +39,12 @@ vi.mock("../src/lody/agent-configs.js", () => ({ })); vi.mock("../src/lody/local-projects.js", () => ({ mirrorLocalProjectsToMachineMeta: async () => undefined, - // An unverified probe, so the gate never writes the worktree default into - // this process's localStorage. - publishBoxReposAsWorkspaceRepos: async () => ({ - publishedFullNames: [], - gitProbe: "no-git-project", - }), + publishBoxReposAsWorkspaceRepos: async () => undefined, registerWorkspaceRepositories: async () => undefined, })); vi.mock("../src/lody/workdir-default.js", () => ({ applyDefaultSessionProject: (runtime: unknown) => runtime, createSessionProjectDefaults: () => ({}), - seedWorktreeWorkdirDefault: () => null, })); import { LodyAgentConfigGate, resetAgentConfigGateMemoForTests } from "../src/lody/agent-config-gate.js"; diff --git a/packages/webapp/test/lody-git-state-fallback.test.ts b/packages/webapp/test/lody-git-state-fallback.test.ts new file mode 100644 index 00000000..95a5eb30 --- /dev/null +++ b/packages/webapp/test/lody-git-state-fallback.test.ts @@ -0,0 +1,78 @@ +/** + * A persisted worktree preference must not strand a different box whose + * Git-state RPC fails (vendor seam patch 18). + * + * `lody.workdirMode.global` is shared by every workspace on the origin, so a + * box-local preflight cannot make the value safe: a healthy box visited earlier + * may already have written it. The vendored landing already computes and + * displays an effective local mode on a terminal Git-state error; this pins the + * two submit gates to that fallback so both click and Enter remain usable. + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { getChatLandingSubmitDisabled } from "@lody/components/components/chat/chat-landing-derived"; + +function findRepoRoot(): string { + let directory = process.cwd(); + for (;;) { + if (existsSync(join(directory, "lint-baseline.json"))) return directory; + const parent = dirname(directory); + if (parent === directory) throw new Error(`repo root not found above ${process.cwd()}`); + directory = parent; + } +} + +const repoRoot = findRepoRoot(); + +describe("worktree preference fallback after a Git-state failure", () => { + const sendableWorktree = { + submitting: false, + hasBlockingImages: false, + hasBlockingFiles: false, + hasSendableContent: true, + contextType: "local" as const, + workdirMode: "worktree" as const, + hasSelectedLocalProject: true, + isRuntimeInitializing: false, + }; + + it("waits while Git state is loading, then enables the existing local fallback on error", () => { + expect(getChatLandingSubmitDisabled({ + ...sendableWorktree, + isLoadingLocalGitState: true, + hasLocalGitStateError: false, + })).toBe(true); + + // This is the cross-workspace reproduction: the selected `worktree` value + // came from a healthy box's global seed, while the current box failed its + // own probe. The component's effective mode is now local, so Send must open. + expect(getChatLandingSubmitDisabled({ + ...sendableWorktree, + isLoadingLocalGitState: false, + hasLocalGitStateError: true, + })).toBe(false); + }); + + it("does not retain a keyboard-submit early return behind the enabled button", () => { + const source = readFileSync( + `${repoRoot}/vendor/lody/packages/components/src/components/chat/chat-landing.tsx`, + "utf8", + ); + expect(source).not.toContain("local_project_git_state_failed"); + expect(source).not.toContain( + "localGitStateError && selectedWorkdirMode === 'worktree'", + ); + expect(source).toContain( + "effectiveWorkdirMode === 'worktree' ? { useWorktree: true } : {}", + ); + }); + + it("is declared as the upstreamable seam the merge runbook must preserve", () => { + const patches = readFileSync(`${repoRoot}/vendor/lody/BLITZ-PATCHES.md`, "utf8"); + expect(patches).toContain( + "### 18. A failed Git-state probe degrades a worktree selection to local", + ); + expect(patches).toContain("plans/evidence/lody-git-state-fallback-pr.md"); + }); +}); diff --git a/packages/webapp/test/lody-project-control-frames.test.ts b/packages/webapp/test/lody-project-control-frames.test.ts index c1b9e440..4e8d8824 100644 --- a/packages/webapp/test/lody-project-control-frames.test.ts +++ b/packages/webapp/test/lody-project-control-frames.test.ts @@ -17,17 +17,14 @@ import type { JsonObject } from "@blitzos/schema"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { createStore } from "jotai"; import { describe, expect, it } from "vitest"; import { LocalProjectControlRequestSchema } from "@lody/shared/message-schemas"; import { createLodyLocalBridge } from "../src/lody/local-bridge.js"; import { - publishBoxReposAsWorkspaceRepos, readLocalProjectRepoFullName, registerWorkspaceRepositories, } from "../src/lody/local-projects.js"; import { sendProjectControl } from "../src/lody/rpc-client.js"; -import type { LodyWorkspaceRuntime } from "../src/lody/runtime.js"; import { repoRoot } from "./lody-daemon-harness.js"; const CORPUS = join(repoRoot(), "packages/schema/fixtures/lody-project-registration"); @@ -206,7 +203,7 @@ describe("local-project control frames", () => { "local-project-5c929c9ed93542aaa69bc27e", ); - expect(lookup).toEqual({ answered: true, repoFullName: "blitzdotdev/wt-probe", git: true }); + expect(lookup).toEqual({ answered: true, repoFullName: "blitzdotdev/wt-probe" }); expect(sent[0]).toEqual({ type: "local-project/git-state", machineId: MACHINE_ID, @@ -316,133 +313,3 @@ describe("local-project control frames", () => { bridge.dispose(); }); }); - -/** - * The publish sweep's second answer: the git probe that gates the worktree - * workdir default (`workdir-default.ts` §1). The landing greys its send button - * when a local project in worktree mode cannot load git state, so the seed must - * only ever land on a box this sweep verified — every project answered - * `git-state` and at least one is a git repository. - */ -describe("the publish sweep's git probe", () => { - // SAFETY: `publishBoxReposAsWorkspaceRepos` reads exactly `workspaceId` off - // the runtime; the vendor shape is unreachable from it, so a call that grew - // another member would fail here first. - const runtime = { workspaceId: WORKSPACE_ID } as unknown as LodyWorkspaceRuntime; - - it("answers verified for a box whose one project is a git repository", async () => { - const { fetchImpl, sent } = stubFetchSequence([ - fixture("response/list-one-project.json"), - fixture("response/git-state-github-remote.json"), - ]); - - const publication = await publishBoxReposAsWorkspaceRepos( - createStore(), - endpoints(fetchImpl), - runtime, - MACHINE_ID, - ); - - expect(publication.gitProbe).toBe("verified"); - expect(publication.publishedFullNames).toEqual(["blitzdotdev/wt-probe"]); - // Both requests the sweep sent are Lody's own union — the probe added no - // second wire shape. - for (const request of sent) { - expect(LocalProjectControlRequestSchema.safeParse(request).success).toBe(true); - } - }); - - it("answers no-git-project when every project is a plain directory", async () => { - // The `/workspace` root project `workdir-default.ts` registers for plain - // chats is exactly this shape: registered, answering, and not a repository. - const { fetchImpl } = stubFetchSequence([ - fixture("response/list-one-project.json"), - { ...fixture("response/git-state-github-remote.json"), result: { git: false } }, - ]); - - const publication = await publishBoxReposAsWorkspaceRepos( - createStore(), - endpoints(fetchImpl), - runtime, - MACHINE_ID, - ); - - expect(publication.gitProbe).toBe("no-git-project"); - expect(publication.publishedFullNames).toEqual([]); - }); - - it("answers no-git-project for a fresh box with no projects at all", async () => { - const { fetchImpl } = stubFetchSequence([fixture("response/list-empty.json")]); - - const publication = await publishBoxReposAsWorkspaceRepos( - createStore(), - endpoints(fetchImpl), - runtime, - MACHINE_ID, - ); - - expect(publication.gitProbe).toBe("no-git-project"); - }); - - it("answers unanswered when a git-state probe is refused, even after a verified one", async () => { - // One refusal poisons the verdict: the landing may auto-select exactly the - // project that refused, so partial health is not health. - const twoProjects = { - ok: true, - type: "local-project/list", - result: { - workspaces: [ - { - workspaceId: WORKSPACE_ID, - workspaceName: "Lody", - projects: [ - { - localProjectId: "local-project-5c929c9ed93542aaa69bc27e", - name: "wt-probe", - rootPath: "/tmp/lp7/ws/wt-probe", - }, - { - localProjectId: "local-project-0000000000000000deadbeef", - name: "refuses", - rootPath: "/tmp/lp7/ws/refuses", - }, - ], - }, - ], - }, - }; - const { fetchImpl } = stubFetchSequence([ - twoProjects, - fixture("response/git-state-github-remote.json"), - { ok: false, type: "local-project/git-state", error: "internal_error", message: "" }, - ]); - - const publication = await publishBoxReposAsWorkspaceRepos( - createStore(), - endpoints(fetchImpl), - runtime, - MACHINE_ID, - ); - - expect(publication.gitProbe).toBe("unanswered"); - // The clone that DID answer is still published — the probe verdict guards - // the workdir seed, not the workspace-repos cache. - expect(publication.publishedFullNames).toEqual(["blitzdotdev/wt-probe"]); - }); - - it("answers unanswered when the project list itself is refused", async () => { - const { fetchImpl } = stubFetchSequence([ - { ok: false, type: "local-project/list", error: "internal_error", message: "" }, - ]); - - const publication = await publishBoxReposAsWorkspaceRepos( - createStore(), - endpoints(fetchImpl), - runtime, - MACHINE_ID, - ); - - expect(publication.gitProbe).toBe("unanswered"); - expect(publication.publishedFullNames).toEqual([]); - }); -}); diff --git a/packages/webapp/test/lody-session-workdir.test.ts b/packages/webapp/test/lody-session-workdir.test.ts index 227c479b..6ed1164f 100644 --- a/packages/webapp/test/lody-session-workdir.test.ts +++ b/packages/webapp/test/lody-session-workdir.test.ts @@ -423,7 +423,7 @@ describe("the clone's own remote, on a repo-backed session", () => { const { repoFullName } = createSessionProjectDefaults({ ...PLANE_ENDPOINTS, fetchImpl }, "m-1"); expect(await repoFullName(WORKSPACE_ID, CLONE_PROJECT_ID)).toEqual({ answered: false }); - const answered = { answered: true, repoFullName: CLONE_REPO_FULL_NAME, git: true }; + const answered = { answered: true, repoFullName: CLONE_REPO_FULL_NAME }; expect(await repoFullName(WORKSPACE_ID, CLONE_PROJECT_ID)).toEqual(answered); expect(await repoFullName(WORKSPACE_ID, CLONE_PROJECT_ID)).toEqual(answered); expect(calls).toHaveLength(2); diff --git a/packages/webapp/test/lody-terminal-tab-wave3.test.tsx b/packages/webapp/test/lody-terminal-tab-wave3.test.tsx index 001f645e..a227a98a 100644 --- a/packages/webapp/test/lody-terminal-tab-wave3.test.tsx +++ b/packages/webapp/test/lody-terminal-tab-wave3.test.tsx @@ -738,12 +738,7 @@ describe("ADJ2 — the agent-config bootstrap runs once per box", () => { })); vi.doMock("../src/lody/local-projects.js", () => ({ mirrorLocalProjectsToMachineMeta: async () => undefined, - // An unverified probe, so the gate under test never writes the worktree - // default into this process's localStorage. - publishBoxReposAsWorkspaceRepos: async () => ({ - publishedFullNames: [], - gitProbe: "no-git-project", - }), + publishBoxReposAsWorkspaceRepos: async () => [], })); const { LodyAgentConfigGate } = await import("../src/lody/agent-config-gate.js"); const { runtimeAtom } = await import("@lody/components/atoms/runtime"); diff --git a/plans/evidence/lody-git-state-fallback-pr.md b/plans/evidence/lody-git-state-fallback-pr.md new file mode 100644 index 00000000..c049b747 --- /dev/null +++ b/plans/evidence/lody-git-state-fallback-pr.md @@ -0,0 +1,68 @@ +# Upstream PR: fall back to local mode when Git state is unavailable + +Drafted 2026-09-03 for `LodyAI/Lody`, against the vendored pin `f3474894`. +It is the contribution that lets BlitzOS drop seam patch 18 in +`vendor/lody/BLITZ-PATCHES.md`. + +## Before it is opened + +Follow Lody's `.github/AGENTS.md`: open the required Issue and get maintainer +agreement first, preserve the public Context handoff, validate the PR body with +`node .github/scripts/check-pr-body.mjs --body-file `, and use their +`fix: ...` commit convention plus the required `Model:` trailer for AI commits. + +## The defect + +The chat landing already falls a requested worktree back to local mode when it +cannot load Git state: + +```ts +const effectiveWorkdirMode = + selectedWorkdirMode === 'worktree' && worktreeAvailable ? 'worktree' : 'local'; +``` + +The toggle renders from that effective value and the session payload uses it to +decide whether to set `branch` and `useWorktree`. On a terminal Git-state error, +the visible and persisted session is therefore a direct local-project session. + +The Send button and `handleSubmit` contradict that decision. Both inspect the +selected value instead, so a persisted worktree preference plus a failed +`local-project/git-state` request disables the button forever and makes the +keyboard path return without starting a session. + +## Reproduction + +1. Persist `lody.workdirMode.global=worktree` (or select worktree for a project). +2. Open a local project whose machine is offline or whose Git-state RPC fails. +3. Type a prompt. + +The toggle displays the local fallback, but Send stays disabled. Pressing Enter +also does nothing because `handleSubmit` reports +`local_project_git_state_failed` and returns. + +## Proposed change + +- Keep Send disabled while a requested worktree's Git state is still loading. +- Once the load has terminated with an error, allow Send. +- Remove the matching `handleSubmit` early return and its now-unused analytics + reason. + +No new fallback is introduced: the existing `effectiveWorkdirMode` already +builds a local `ProjectRef`, omits the worktree branch, records `local` in the +preference after a successful start, and renders the toggle off. + +## Why it is safe + +Healthy paths do not change. Runtime initialization still blocks, worktree +loading still blocks, and a successful Git-state result still dispatches with +`useWorktree: true`. Only a terminal error changes, and that state already has +`effectiveWorkdirMode === 'local'`; the patch makes the two submit gates agree +with the payload they guard. + +## Tests + +Add a derived-state regression proving that a sendable local prompt with a +selected worktree and a terminal Git-state error is enabled, while the existing +loading test remains disabled. Exercise the submit path far enough to prove it +creates a local-project session rather than returning the removed +`local_project_git_state_failed` reason. diff --git a/vendor/lody/BLITZ-PATCHES.md b/vendor/lody/BLITZ-PATCHES.md index 53ad6d63..c3acd905 100644 --- a/vendor/lody/BLITZ-PATCHES.md +++ b/vendor/lody/BLITZ-PATCHES.md @@ -484,7 +484,7 @@ merges.** ### 7. Host suppression of surfaces BlitzOS does not serve (v1 scope cuts, 2026-09-01) -**One idea, 40 hunks in seven files, and every one of them is inert by default.** +**One idea, 42 hunks in seven files, and every one of them is inert by default.** The 463-row support matrix (`plans/LODY-SESSIONS.md`, the scope decision) found four groups of controls that RENDER in a BlitzOS browser and cannot work there. Each one is a control a member can click, and the failure is never at the button: @@ -606,7 +606,9 @@ and all three are named in `lody-surface-tabs.test.tsx`'s anchor table. |---|---|---|---| | 36 | 20 | the `@/lib/github-avatar` import | imports `useAppCapability` | | 37 | 398 | `const { t } = useTranslation();` in `UnifiedProjectSelectorView` | reads the capability | -| 38 | 635 | the `repos.connectMore` `` | renders it only with the capability — C65 | +| 38 | 410 | above the selectable-options memo | clears a controlled GitHub selection when the capability is unavailable, so a stale URL or saved draft cannot retain the unsupported remote path | +| 39 | 428 | `for (const repository of repositories ?? [])` | adds cached GitHub repositories to the selectable project list only with the capability; BlitzOS deliberately caches clone names for local-project metadata, not as remote project choices | +| 40 | 635 | the `repos.connectMore` `` | renders it only with the capability — C65 | `packages/components/src/components/sessions/session-conversation-diff-panel.tsx` — the same two-line shape as hunks 11-12, and what takes SP43 and SP44 with it: @@ -614,8 +616,8 @@ and all three are named in `lody-surface-tabs.test.tsx`'s anchor table. | # | Line | Upstream anchor | What it does | |---|---|---|---| -| 39 | 47 | the `@/lib/github-token` import | imports `useAppCapability` | -| 40 | 430, 432, 433 | the `getSessionGitHubState` memo | reads the capability and passes it as the third argument | +| 41 | 47 | the `@/lib/github-token` import | imports `useAppCapability` | +| 42 | 430, 432, 433 | the `getSessionGitHubState` memo | reads the capability and passes it as the third argument | #### What this patch does NOT do, and why @@ -1604,6 +1606,48 @@ vendored hook across a box switch and fails without hunk 3. bridge (or expose this reset) so a host driving more than one daemon can move between them. Until then this is the smallest seam that closes it. +### 18. A failed Git-state probe degrades a worktree selection to local (2026-09-03) + +**One idea, three hunks in two files, and it fixes upstream's own mismatch.** +The chat landing already distinguishes the SELECTED workdir mode from the +EFFECTIVE one: when local Git state is unavailable, +`effectiveWorkdirMode` is `local`, the worktree toggle renders off, and the +session `ProjectRef` omits `useWorktree`. That is a complete and safe fallback +to editing the selected local project in place. + +Two later checks nevertheless read `selectedWorkdirMode === 'worktree'` and +turn that fallback into a dead end. `getChatLandingSubmitDisabled` permanently +disables the button after the Git-state load errors, and `handleSubmit` has a +matching early return for keyboard submission. A persisted worktree preference +therefore makes a project whose machine cannot answer `local-project/git-state` +impossible to use, even though the rest of the component has already selected +the local fallback. + +| # | File | Upstream anchor | What it does | +|---|---|---|---| +| 1 | `packages/components/src/components/chat/chat-landing-derived.ts` | the worktree arm in `getChatLandingSubmitDisabled` | keeps the loading guard, but an answered error no longer disables Send; the effective mode has already fallen back to local | +| 2 | `packages/components/src/components/chat/chat-landing.tsx` | `local_project_git_state_failed` in `captureSessionInputBlocked`'s reason union | removes the reason that hunk 3 makes unreachable | +| 3 | same | the `localGitStateError && selectedWorkdirMode === 'worktree'` early return in `handleSubmit` | removes the second block so click and Enter both dispatch with the existing `effectiveWorkdirMode === 'local'` project shape | + +Guard: `packages/webapp/test/lody-git-state-fallback.test.ts` reproduces the +cross-workspace case — a healthy box has already persisted the global worktree +preference, then a different box's Git-state request fails — and pins both the +button decision and the submit path. + +This is not a BlitzOS-specific behaviour switch. No prop or capability is +added, and every healthy Git-state path is byte-for-byte unchanged: a worktree +selection still blocks while state is loading and still dispatches a worktree +after state resolves. The only changed state is a terminal error, where the UI +already displays and builds a local session. The upstream PR is drafted in +`plans/evidence/lody-git-state-fallback-pr.md`; **drop all three hunks when it +merges.** + +**Merge conflict drill.** If upstream consolidates selected/effective workdir +mode, preserve one rule: loading a requested worktree may block, but a terminal +Git-state error must allow the already-selected local fallback through both the +button and the submit handler. If upstream implements that rule itself, delete +this seam rather than reconciling it. + ## Patches to the published npm artifact (NOT to this tree) These are applied at box-image build to the `lody` package installed from npm. diff --git a/vendor/lody/packages/components/src/components/chat/chat-landing-derived.ts b/vendor/lody/packages/components/src/components/chat/chat-landing-derived.ts index c9483db4..c626dac9 100644 --- a/vendor/lody/packages/components/src/components/chat/chat-landing-derived.ts +++ b/vendor/lody/packages/components/src/components/chat/chat-landing-derived.ts @@ -554,7 +554,7 @@ export function getChatLandingSubmitDisabled({ contextType === 'local' && hasSelectedLocalProject && (isRuntimeInitializing || - (workdirMode === 'worktree' && (isLoadingLocalGitState || hasLocalGitStateError))) + (workdirMode === 'worktree' && isLoadingLocalGitState && !hasLocalGitStateError)) ); } diff --git a/vendor/lody/packages/components/src/components/chat/chat-landing.tsx b/vendor/lody/packages/components/src/components/chat/chat-landing.tsx index c4e3d662..03d2aaaa 100644 --- a/vendor/lody/packages/components/src/components/chat/chat-landing.tsx +++ b/vendor/lody/packages/components/src/components/chat/chat-landing.tsx @@ -2905,7 +2905,6 @@ function WorkspaceChatLanding({ | 'missing_agent_config' | 'missing_machine' | 'missing_context' - | 'local_project_git_state_failed' | 'missing_branch' | 'missing_project', extra?: Record @@ -3004,12 +3003,6 @@ function WorkspaceChatLanding({ const githubBranch = selectedBranch?.trim() || ''; const localWorktreeBranch = effectiveWorkdirMode === 'worktree' ? selectedLocalBranch?.trim() || undefined : undefined; - if (contextType === 'local' && localGitStateError && selectedWorkdirMode === 'worktree') { - captureSessionInputBlocked('local_project_git_state_failed', { - error_message: localGitStateError, - }); - return; - } // Only require branch selection when the repo actually has branches. // Empty repos have no branches, but sessions can still be created. if (contextType === 'github' && !githubBranch && repoBranches.length > 0) {