diff --git a/packages/webapp/src/lody/surface-providers.tsx b/packages/webapp/src/lody/surface-providers.tsx index e6a370b1..dd1cddc7 100644 --- a/packages/webapp/src/lody/surface-providers.tsx +++ b/packages/webapp/src/lody/surface-providers.tsx @@ -65,8 +65,10 @@ 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. + // 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 2b5ad812..8c0bc7ef 100644 --- a/packages/webapp/src/lody/workdir-default.ts +++ b/packages/webapp/src/lody/workdir-default.ts @@ -25,6 +25,15 @@ * 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. + * + * 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-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/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 f0768b5c..4d113c33 100644 --- a/vendor/lody/BLITZ-PATCHES.md +++ b/vendor/lody/BLITZ-PATCHES.md @@ -1606,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) {