Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/webapp/src/lody/surface-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<I18nextProvider i18n={i18n}>
Expand Down
9 changes: 9 additions & 0 deletions packages/webapp/src/lody/workdir-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
78 changes: 78 additions & 0 deletions packages/webapp/test/lody-git-state-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
68 changes: 68 additions & 0 deletions plans/evidence/lody-git-state-fallback-pr.md
Original file line number Diff line number Diff line change
@@ -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 <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.
42 changes: 42 additions & 0 deletions vendor/lody/BLITZ-PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ export function getChatLandingSubmitDisabled({
contextType === 'local' &&
hasSelectedLocalProject &&
(isRuntimeInitializing ||
(workdirMode === 'worktree' && (isLoadingLocalGitState || hasLocalGitStateError)))
(workdirMode === 'worktree' && isLoadingLocalGitState && !hasLocalGitStateError))
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2905,7 +2905,6 @@ function WorkspaceChatLanding({
| 'missing_agent_config'
| 'missing_machine'
| 'missing_context'
| 'local_project_git_state_failed'
| 'missing_branch'
| 'missing_project',
extra?: Record<string, unknown>
Expand Down Expand Up @@ -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) {
Expand Down
Loading