# Desktop Language Toggle Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add a header language toggle that immediately switches between English and Simplified Chinese after safely persisting the choice.

**Architecture:** The WebView requests a language change through the existing Glimpse bridge. The extension validates and merges the language into `settings.json`, acknowledges success or failure, and the WebView rerenders localized static and dynamic UI without reloading the window.

**Tech Stack:** TypeScript extension entry, vanilla browser JavaScript, static HTML, Node.js verification scripts.

---

### Task 1: Settings Persistence Utility

**Files:**
- Modify: `scripts/test-i18n-utils.mjs`
- Modify: `i18n-utils.js`

**Step 1: Write failing persistence tests**

Extend `scripts/test-i18n-utils.mjs` to verify:

```js
const settingsPath = join(dir, "nested", "settings.json");
assert.equal(saveConfiguredLanguage(settingsPath, "zh-CN"), "zh-CN");
assert.deepEqual(JSON.parse(readFileSync(settingsPath, "utf8")), { language: "zh-CN" });

writeFileSync(settingsPath, JSON.stringify({ theme: "dark", language: "en-US" }), "utf8");
saveConfiguredLanguage(settingsPath, "zh-CN");
assert.deepEqual(JSON.parse(readFileSync(settingsPath, "utf8")), { theme: "dark", language: "zh-CN" });

assert.throws(() => saveConfiguredLanguage(settingsPath, "fr-FR"), /Unsupported language/);
writeFileSync(settingsPath, "{bad json", "utf8");
assert.throws(() => saveConfiguredLanguage(settingsPath, "en-US"));
assert.equal(readFileSync(settingsPath, "utf8"), "{bad json");
```

Import `readFileSync`, `readdirSync`, and the wished-for `saveConfiguredLanguage` API. After each successful write, assert that the settings directory contains no `.tmp` files.

**Step 2: Run the test and confirm RED**

Run: `npm run verify:i18n-utils`

Expected: FAIL because `saveConfiguredLanguage` is not exported.

**Step 3: Implement minimal persistence**

In `i18n-utils.js`, import `mkdirSync`, `renameSync`, `unlinkSync`, `writeFileSync`, and `dirname`, then implement an atomic same-directory replacement:

```js
export function saveConfiguredLanguage(settingsPath, value) {
  if (!SUPPORTED_LANGUAGES.includes(value)) throw new Error(`Unsupported language: ${String(value)}`);
  let settings = {};
  if (existsSync(settingsPath)) {
    settings = JSON.parse(readFileSync(settingsPath, "utf8"));
    if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
      throw new Error("Settings root must be an object");
    }
  }
  mkdirSync(dirname(settingsPath), { recursive: true });
  settings.language = value;
  const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
  try {
    writeFileSync(tempPath, JSON.stringify(settings, null, 2) + "\n", { encoding: "utf8", flag: "wx" });
    renameSync(tempPath, settingsPath);
    return value;
  } finally {
    try { unlinkSync(tempPath); } catch {}
  }
}
```

Because the temporary file lives beside `settings.json`, the rename stays on the same filesystem. If writing or renaming fails, the original file remains untouched and the `finally` block removes the temporary file.

**Step 4: Verify GREEN**

Run: `npm run verify:i18n-utils`

Expected: PASS.

---

### Task 2: Extension Message and Persistence Flow

**Files:**
- Modify: `index.ts`
- Modify: `scripts/verify-desktop-language-data.mjs`

**Step 1: Add failing structural assertions**

Require the extension source to:

```js
requireIn(indexTs, /saveConfiguredLanguage/, "index.ts must use saveConfiguredLanguage");
requireIn(indexTs, /case\s+["']set-language["']/, "window messages must handle set-language");
requireIn(indexTs, /type:\s*["']language-update["']/, "extension must acknowledge language updates");
```

Also extract the `set-language` case or `handleWindowMessage` body and ensure it references the `.pi/agent/settings.json` path.

**Step 2: Run and confirm RED**

Run: `npm run verify:desktop-language-data`

Expected: FAIL because the handler and acknowledgement do not exist.

**Step 3: Implement the backend handler**

Import `saveConfiguredLanguage`. Add a `set-language` case to `handleWindowMessage()`:

```ts
case "set-language": {
  const requested = typeof msg.language === "string" ? msg.language : "";
  const home = process.env.HOME || process.env.USERPROFILE || "";
  const settingsPath = join(home, ".pi", "agent", "settings.json");
  try {
    const language = saveConfiguredLanguage(settingsPath, requested);
    sendToWindow({ type: "language-update", success: true, language });
  } catch (error) {
    sendToWindow({
      type: "language-update",
      success: false,
      language: readConfiguredLanguage(settingsPath),
      error: String(error),
    });
  }
  break;
}
```

**Step 4: Verify backend flow**

Run:

```powershell
npm run verify:desktop-language-data
npx tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --skipLibCheck index.ts
```

Expected: both exit `0`.

---

### Task 3: Header Button and Mutable Language Runtime

**Files:**
- Create: `web/language-toggle-utils.js`
- Create: `scripts/test-language-toggle-utils.mjs`
- Modify: `index.ts`
- Modify: `web/index.html`
- Modify: `web/app.js`
- Modify: `scripts/verify-i18n.mjs`
- Create: `scripts/verify-language-toggle.mjs`
- Modify: `package.json`

**Step 1: Write failing behavior tests**

Create `scripts/test-language-toggle-utils.mjs`. Load `web/language-toggle-utils.js` into `node:vm`, then test:

```js
assert.equal(utils.nextLanguage("en-US"), "zh-CN");
assert.equal(utils.nextLanguage("zh-CN"), "en-US");

const details = [{ open: true }, { open: false }];
const container = {
  scrollTop: 120,
  scrollHeight: 500,
  clientHeight: 200,
  querySelectorAll: () => details,
};
const snapshot = utils.captureMessageUiState(container);
details[0].open = false;
details[1].open = true;
container.scrollHeight = 560;
utils.restoreMessageUiState(container, snapshot);
assert.deepEqual(details.map(detail => detail.open), [true, false]);
assert.equal(container.scrollTop, 180);
```

Also test a container initially at the bottom restores to the new bottom after its height changes.

**Step 2: Run behavior tests and confirm RED**

Run: `node scripts/test-language-toggle-utils.mjs`

Expected: FAIL because `web/language-toggle-utils.js` does not exist.

**Step 3: Implement the browser-neutral helper**

Create a classic-script IIFE so the same source runs in WebView2 and `node:vm`:

```js
(function installLanguageToggleUtils(root) {
  function nextLanguage(language) {
    return language === "zh-CN" ? "en-US" : "zh-CN";
  }

  function captureMessageUiState(container) {
    if (!container) return null;
    const distanceFromBottom = Math.max(0, container.scrollHeight - container.clientHeight - container.scrollTop);
    return {
      distanceFromBottom,
      wasAtBottom: distanceFromBottom <= 4,
      openDetails: Array.from(container.querySelectorAll("details"), detail => detail.open),
    };
  }

  function restoreMessageUiState(container, snapshot) {
    if (!container || !snapshot) return;
    Array.from(container.querySelectorAll("details")).forEach((detail, index) => {
      detail.open = snapshot.openDetails[index] ?? detail.open;
    });
    container.scrollTop = snapshot.wasAtBottom
      ? Math.max(0, container.scrollHeight - container.clientHeight)
      : Math.max(0, container.scrollHeight - container.clientHeight - snapshot.distanceFromBottom);
  }

  root.LanguageToggleUtils = { nextLanguage, captureMessageUiState, restoreMessageUiState };
})(globalThis);
```

Run the behavior test again. Expected: PASS.

**Step 4: Inject the helper into runtime HTML**

Add `<script>__INLINE_LANGUAGE_TOGGLE_JS__</script>` before `__INLINE_JS__` in `web/index.html`. Update `buildDesktopHtml()` to read `web/language-toggle-utils.js`, include its size in the WebView2 budget, and replace the marker before returning the HTML.

Wire `node --check web/language-toggle-utils.js` and `node scripts/test-language-toggle-utils.mjs` into `npm run check`.

**Step 5: Create a failing toggle verifier**

The new verifier must require:

- `#btn-language` appears before `#btn-theme`.
- The button uses the `translate` Material Symbol.
- `ICON_FALLBACKS` maps `translate` and `ICON_SVG_PATHS` contains its target path.
- `currentLanguage` is declared with `let`.
- A click sends `{ type: "set-language" }` with the opposite supported language.
- `language-update` is handled.
- `applyLanguage()` captures message UI state, calls `renderMainContent()` exactly once, avoids `updateStreamingUI()`, and schedules state restoration.

Wire `verify:language-toggle` into `check`, then run it.

Expected: FAIL because the button and runtime do not exist.

**Step 6: Add translation and icon fallback data**

Add matching English and ASCII-escaped Chinese values:

```js
"title.switchLanguage": "Switch to Chinese"
"title.switchLanguage": "\u5207\u6362\u5230\u82f1\u6587"
```

Add the key to `REQUIRED_I18N_KEYS`.

Add a deterministic local fallback:

```js
translate: "languages",
```

Add an `ICON_SVG_PATHS.languages` path representing language/translation. Run `npm run verify:icon-fallbacks` after adding the button; it must exit `0`.

**Step 7: Add the header button**

Immediately before `#btn-theme` in `web/index.html`:

```html
<button id="btn-language" class="rounded-full p-2 hover:bg-pi-sidebar-hover"
  title="Switch to Chinese" data-i18n-title="title.switchLanguage"
  aria-label="Switch to Chinese" data-i18n-aria-label="title.switchLanguage">
  <span class="material-symbols-outlined msym-sm text-pi-text-muted">translate</span>
</button>
```

Extend `applyStaticTranslations()` to apply `[data-i18n-aria-label]` with `setAttribute("aria-label", ...)`.

**Step 8: Implement state-preserving runtime switching**

Change `currentLanguage` to `let`. Add `btnLanguage`, a pending flag, and split the existing send/cancel button mutation out of `updateStreamingUI()` into `updateStreamingButton()` so it can run without rebuilding messages.

Implement one-pass rerendering:

```js
function applyLanguage(language) {
  if (!TRANSLATIONS[language]) return;
  const messageUiState = LanguageToggleUtils.captureMessageUiState(messagesEl);
  currentLanguage = language;
  data.language = language;
  _renderCache.clear();
  applyStaticTranslations();
  renderStats();
  renderContextUsage();
  renderMainContent();
  updateReadOnlyUI();
  updateStreamingButton();
  requestAnimationFrame(() => {
    LanguageToggleUtils.restoreMessageUiState(messagesEl, messageUiState);
  });
}
```

Clear `_renderCache` before rendering because cached message/tool HTML contains language-dependent labels. Do not call `renderProjectTree()` separately because `renderMainContent()` already does so. Do not call `updateStreamingUI()` because it calls `renderMessages()` again.

The click handler disables the button and sends `LanguageToggleUtils.nextLanguage(currentLanguage)`. Add a `language-update` bridge case that reenables the button, calls `applyLanguage()` on success, and calls `showCommandToast("set-language", error, false)` on failure.

**Step 9: Verify frontend flow**

Run:

```powershell
npm run verify:language-toggle
npm run verify:i18n
npm run verify:icon-fallbacks
node scripts/test-language-toggle-utils.mjs
node --check web/app.js
```

Expected: all exit `0`.

---

### Task 4: Final Verification and Manual QA

**Files:**
- Modify: `README.md`

**Step 1: Document the header toggle**

Update the language section to state that the header button switches immediately and persists the selected language. Keep the direct `settings.json` method documented as an alternative.

**Step 2: Run automated verification**

Run:

```powershell
npm run check
git diff --check
```

Expected: both exit `0`; i18n verification reports matching dictionaries and ASCII-safe Chinese strings.

**Step 3: Manual QA**

Open `/desktop` and verify:

1. The language button is immediately left of the theme button.
2. English switches to Chinese without reopening the window.
3. Chinese switches back to English without reopening the window.
4. Sidebar, current view, stats, placeholders, tooltips, plan mode, and active streaming controls update.
5. Current view, input content, streaming state, non-bottom scroll position, and expanded/collapsed tool cards are preserved.
6. Reopening `/desktop` keeps the selected language.
7. A forced settings write failure leaves the current language unchanged and displays an error toast.

No Git commit should be created unless the user explicitly requests one.
