# Platform Account Selector Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a "连接账号" dropdown button next to the skill button in the chat toolbar, letting users connect/switch/disconnect e-commerce platform accounts; selected account stored as `.platform-accounts.json` in the agent workspace directory.

**Architecture:** Self-contained module approach — new `platform-accounts.ts` holds all logic; `chat.ts` module-level `vs` object gains new fields; a `renderAccountSelector()` function renders the dropdown inline using CSS absolute positioning. Backend gets two new endpoints on the existing workspace router.

**Tech Stack:** Python/FastAPI (backend), Lit/TypeScript (frontend), Vitest (frontend tests), pytest (backend tests)

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `xc-sealclaw-agent-runtime/src/sealclaw/app/routers/workspace.py` | Modify | Add `GET/POST /workspace/platform-accounts` |
| `xc-sealclaw-agent-runtime/tests/unit/workspace/test_platform_accounts_api.py` | Create | Backend endpoint tests |
| `ui/src/ui/chat/platform-accounts.ts` | Create | Tauri helpers, types, API helpers, login flow |
| `ui/src/ui/chat/platform-accounts.test.ts` | Create | Unit tests for logic module |
| `ui/src/ui/views/chat.ts` | Modify | `vs` state fields, `renderAccountSelector()`, button in toolbar |
| `ui/src/styles/chat/layout.css` | Modify | `.platform-accounts-*` styles |

---

## Task 1: Backend — `GET/POST /workspace/platform-accounts`

**Files:**
- Modify: `xc-sealclaw-agent-runtime/src/sealclaw/app/routers/workspace.py`
- Create: `xc-sealclaw-agent-runtime/tests/unit/workspace/test_platform_accounts_api.py`

- [ ] **Step 1: Write the failing tests**

Create `xc-sealclaw-agent-runtime/tests/unit/workspace/test_platform_accounts_api.py`:

```python
# -*- coding: utf-8 -*-
"""Tests for /workspace/platform-accounts endpoints."""
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from sealclaw.app.routers.workspace import router


def _make_app(workspace_dir: Path) -> FastAPI:
    app = FastAPI()

    async def _fake_agent(request):
        agent = MagicMock()
        agent.workspace_dir = workspace_dir
        return agent

    # Patch get_agent_for_request at import time by overriding the dependency
    import sealclaw.app.routers.workspace as ws_mod
    ws_mod._get_agent_for_request_override = _fake_agent  # noqa: SLF001

    app.include_router(router)
    return app


@pytest.fixture()
def tmp_workspace(tmp_path):
    """Yield a temporary workspace directory."""
    d = tmp_path / "workspace"
    d.mkdir()
    return d


def test_get_platform_accounts_no_file(tmp_workspace, monkeypatch):
    """Returns empty selections when .platform-accounts.json does not exist."""
    import sealclaw.app.routers.workspace as ws_mod

    async def _fake_agent(request):
        agent = MagicMock()
        agent.workspace_dir = tmp_workspace
        return agent

    monkeypatch.setattr(ws_mod, "_get_agent_override", _fake_agent)

    app = FastAPI()
    app.include_router(router)

    # We need to override get_agent_for_request inside the endpoint
    # by patching the module-level helper we will add
    with TestClient(app) as client:
        # patch happens via monkeypatch on the function we'll define
        pass  # see Step 3 for the actual implementation approach


def test_get_platform_accounts_existing_file(tmp_workspace):
    """Returns selections from existing file."""
    config = {"selections": {"taobao:sycm": "acc_123"}}
    (tmp_workspace / ".platform-accounts.json").write_text(json.dumps(config))
    # endpoint should return same content


def test_post_platform_accounts_creates_file(tmp_workspace):
    """POST creates .platform-accounts.json with the given selection."""
    # endpoint should write {"selections": {"taobao:sycm": "acc_456"}}


def test_post_platform_accounts_merges_existing(tmp_workspace):
    """POST merges into existing file without overwriting other keys."""
    existing = {"selections": {"taobao:sycm": "old_acc"}}
    (tmp_workspace / ".platform-accounts.json").write_text(json.dumps(existing))
    # POST douyin:fxg → file should have both keys
```

> **Note:** These tests use the direct function approach (not HTTP client) because `get_agent_for_request` relies on request context. Refactor to test the pure logic functions extracted in Step 3.

- [ ] **Step 2: Run tests to confirm they fail (import errors expected)**

```bash
cd /Users/mac/code/xc-sealclaw-agent-runtime
python -m pytest tests/unit/workspace/test_platform_accounts_api.py -v
```
Expected: collection errors or FAILED — that is correct.

- [ ] **Step 3: Implement the endpoints in `workspace.py`**

Add these imports at the top of `workspace.py` (after existing imports):

```python
import json
from pydantic import BaseModel
```

Add these two pure helper functions before the existing `router.get("/download")` definition:

```python
def _read_platform_accounts(workspace_dir: Path) -> dict:
    """Read .platform-accounts.json from workspace; return empty dict on missing/corrupt."""
    config_path = workspace_dir / ".platform-accounts.json"
    if not config_path.exists():
        return {"selections": {}}
    try:
        return json.loads(config_path.read_text(encoding="utf-8"))
    except Exception:
        return {"selections": {}}


def _write_platform_account(workspace_dir: Path, platform: str, sub_platform: str, account_id: str) -> None:
    """Merge one selection into .platform-accounts.json."""
    config_path = workspace_dir / ".platform-accounts.json"
    data = _read_platform_accounts(workspace_dir)
    if "selections" not in data:
        data["selections"] = {}
    data["selections"][f"{platform}:{sub_platform}"] = account_id
    config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
```

Add these models and endpoints after the existing `_reveal_path` helper (around line 285):

```python
class PlatformAccountRequest(BaseModel):
    platform: str
    sub_platform: str
    account_id: str


@router.get(
    "/platform-accounts",
    summary="Get selected platform accounts",
    description="Read .platform-accounts.json from workspace root. Returns empty selections if file absent.",
)
async def get_platform_accounts(request: Request) -> dict:
    """Return the platform account selections stored in workspace."""
    from ..agent_context import get_agent_for_request

    agent = await get_agent_for_request(request)
    return _read_platform_accounts(agent.workspace_dir)


@router.post(
    "/platform-accounts",
    summary="Set selected account for a platform",
    description="Merge one platform:sub_platform → account_id entry into .platform-accounts.json.",
)
async def set_platform_account(body: PlatformAccountRequest, request: Request) -> dict:
    """Persist the selected account for a platform into workspace."""
    from ..agent_context import get_agent_for_request

    agent = await get_agent_for_request(request)
    await asyncio.to_thread(
        _write_platform_account,
        agent.workspace_dir,
        body.platform,
        body.sub_platform,
        body.account_id,
    )
    return {"ok": True}
```

- [ ] **Step 4: Rewrite tests to call the pure helpers directly**

Replace the contents of `test_platform_accounts_api.py` with:

```python
# -*- coding: utf-8 -*-
"""Tests for platform-accounts workspace helpers."""
import json
from pathlib import Path

import pytest


def test_read_platform_accounts_no_file(tmp_path):
    """Returns empty selections when file does not exist."""
    from sealclaw.app.routers.workspace import _read_platform_accounts

    result = _read_platform_accounts(tmp_path)
    assert result == {"selections": {}}


def test_read_platform_accounts_corrupt_file(tmp_path):
    """Returns empty selections when file is corrupt JSON."""
    from sealclaw.app.routers.workspace import _read_platform_accounts

    (tmp_path / ".platform-accounts.json").write_text("not-json")
    result = _read_platform_accounts(tmp_path)
    assert result == {"selections": {}}


def test_read_platform_accounts_existing(tmp_path):
    """Returns file contents when file exists."""
    from sealclaw.app.routers.workspace import _read_platform_accounts

    data = {"selections": {"taobao:sycm": "acc_123"}}
    (tmp_path / ".platform-accounts.json").write_text(json.dumps(data))
    result = _read_platform_accounts(tmp_path)
    assert result["selections"]["taobao:sycm"] == "acc_123"


def test_write_platform_account_creates_file(tmp_path):
    """Creates file with the given selection."""
    from sealclaw.app.routers.workspace import _write_platform_account

    _write_platform_account(tmp_path, "taobao", "sycm", "acc_456")
    written = json.loads((tmp_path / ".platform-accounts.json").read_text())
    assert written["selections"]["taobao:sycm"] == "acc_456"


def test_write_platform_account_merges(tmp_path):
    """Merges into existing file, preserving other keys."""
    from sealclaw.app.routers.workspace import _write_platform_account

    existing = {"selections": {"taobao:sycm": "old_acc"}}
    (tmp_path / ".platform-accounts.json").write_text(json.dumps(existing))

    _write_platform_account(tmp_path, "douyin", "fxg", "douyin_acc")

    written = json.loads((tmp_path / ".platform-accounts.json").read_text())
    assert written["selections"]["taobao:sycm"] == "old_acc"
    assert written["selections"]["douyin:fxg"] == "douyin_acc"


def test_write_platform_account_overwrites_same_key(tmp_path):
    """Overwrites existing selection for the same platform."""
    from sealclaw.app.routers.workspace import _write_platform_account

    _write_platform_account(tmp_path, "taobao", "sycm", "acc_1")
    _write_platform_account(tmp_path, "taobao", "sycm", "acc_2")

    written = json.loads((tmp_path / ".platform-accounts.json").read_text())
    assert written["selections"]["taobao:sycm"] == "acc_2"
```

- [ ] **Step 5: Run tests — expect all to pass**

```bash
cd /Users/mac/code/xc-sealclaw-agent-runtime
python -m pytest tests/unit/workspace/test_platform_accounts_api.py -v
```
Expected output: 6 tests PASSED.

- [ ] **Step 6: Commit**

```bash
cd /Users/mac/code/xc-sealclaw-agent-runtime
git add src/sealclaw/app/routers/workspace.py tests/unit/workspace/test_platform_accounts_api.py
git commit -m "feat(workspace): add GET/POST /workspace/platform-accounts endpoints"
```

---

## Task 2: Frontend Logic Module (`platform-accounts.ts`)

**Files:**
- Create: `ui/src/ui/chat/platform-accounts.ts`
- Create: `ui/src/ui/chat/platform-accounts.test.ts`

- [ ] **Step 1: Write the failing tests**

Create `ui/src/ui/chat/platform-accounts.test.ts`:

```typescript
/* @vitest-environment jsdom */

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Will be imported once the module exists
let mod: typeof import("./platform-accounts.ts");

beforeEach(async () => {
  mod = await import("./platform-accounts.ts");
});

afterEach(() => {
  delete (globalThis as any).__TAURI_INTERNALS__;
  vi.restoreAllMocks();
});

describe("isTauriEnv", () => {
  it("returns false when __TAURI_INTERNALS__ is absent", () => {
    expect(mod.isTauriEnv()).toBe(false);
  });

  it("returns true when __TAURI_INTERNALS__.invoke is present", () => {
    (globalThis as any).__TAURI_INTERNALS__ = { invoke: vi.fn() };
    expect(mod.isTauriEnv()).toBe(true);
  });
});

describe("tauriInvoke", () => {
  it("rejects when not in Tauri environment", async () => {
    await expect(mod.tauriInvoke("any_cmd")).rejects.toThrow(
      "Not running in Tauri environment",
    );
  });

  it("calls internals.invoke with cmd and args", async () => {
    const mockInvoke = vi.fn().mockResolvedValue("ok");
    (globalThis as any).__TAURI_INTERNALS__ = { invoke: mockInvoke };
    await mod.tauriInvoke("test_cmd", { foo: "bar" });
    expect(mockInvoke).toHaveBeenCalledWith("test_cmd", { foo: "bar" });
  });
});

describe("PLATFORMS", () => {
  it("has taobao as the only enabled platform", () => {
    const enabled = mod.PLATFORMS.filter((p) => p.enabled);
    expect(enabled).toHaveLength(1);
    expect(enabled[0].platform).toBe("taobao");
  });

  it("includes jd platform", () => {
    const jd = mod.PLATFORMS.find((p) => p.platform === "jd");
    expect(jd).toBeDefined();
    expect(jd?.enabled).toBe(false);
  });

  it("all keys follow platform:subPlatform format", () => {
    for (const p of mod.PLATFORMS) {
      expect(p.key).toBe(`${p.platform}:${p.subPlatform}`);
    }
  });
});

describe("STATUS_MAP", () => {
  it("has entries for all three statuses", () => {
    expect(mod.STATUS_MAP.disconnected.label).toBe("未连接");
    expect(mod.STATUS_MAP.connected.label).toBe("已连接");
    expect(mod.STATUS_MAP.expired.label).toBe("已失效");
  });
});

describe("checkPlatformStatus", () => {
  it("returns disconnected when fetch throws", async () => {
    vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
    const result = await mod.checkPlatformStatus("", "taobao", "sycm");
    expect(result).toBe("disconnected");
  });

  it("returns connected when API returns status=valid", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "valid" }) }),
    );
    const result = await mod.checkPlatformStatus("", "taobao", "sycm");
    expect(result).toBe("connected");
  });

  it("returns expired when API returns status=expired", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: "expired" }) }),
    );
    const result = await mod.checkPlatformStatus("", "taobao", "sycm");
    expect(result).toBe("expired");
  });

  it("returns disconnected when response is not ok", async () => {
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false }));
    const result = await mod.checkPlatformStatus("", "taobao", "sycm");
    expect(result).toBe("disconnected");
  });
});

describe("loadAllSelectedAccounts", () => {
  it("returns empty object when fetch fails", async () => {
    vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
    const result = await mod.loadAllSelectedAccounts("");
    expect(result).toEqual({});
  });

  it("returns selections from response", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({ selections: { "taobao:sycm": "acc_1" } }),
      }),
    );
    const result = await mod.loadAllSelectedAccounts("/base");
    expect(result["taobao:sycm"]).toBe("acc_1");
  });
});
```

- [ ] **Step 2: Run tests — expect import failure**

```bash
cd /Users/mac/code/sealclaw
pnpm test ui/src/ui/chat/platform-accounts.test.ts
```
Expected: FAIL with "Cannot find module './platform-accounts.ts'".

- [ ] **Step 3: Create `ui/src/ui/chat/platform-accounts.ts`**

```typescript
import type { TemplateResult } from "lit";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export type ConnectionStatus = "disconnected" | "connected" | "expired";

export interface PlatformItem {
  key: string;
  platform: string;
  subPlatform: string;
  label: string;
  enabled: boolean;
  status: ConnectionStatus;
}

export interface AccountItem {
  account_id: string;
  account_name: string;
  main_cate_name?: string;
}

export interface LoginCookieResult {
  logged_in: boolean;
  cookie_str: string;
  cookies: Record<string, string>;
  missing_keys: string[];
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

export const STATUS_MAP: Record<ConnectionStatus, { label: string; color: string; bg: string }> = {
  disconnected: { label: "未连接", color: "#9ca3af", bg: "#f3f4f6" },
  connected: { label: "已连接", color: "#16a34a", bg: "#f0fdf4" },
  expired: { label: "已失效", color: "#ea580c", bg: "#fff7ed" },
};

export const PLATFORMS: PlatformItem[] = [
  {
    key: "taobao:sycm",
    platform: "taobao",
    subPlatform: "sycm",
    label: "淘宝",
    enabled: true,
    status: "disconnected",
  },
  {
    key: "douyin:fxg",
    platform: "douyin",
    subPlatform: "fxg",
    label: "抖音",
    enabled: false,
    status: "disconnected",
  },
  {
    key: "pdd:mms",
    platform: "pdd",
    subPlatform: "mms",
    label: "拼多多",
    enabled: false,
    status: "disconnected",
  },
  {
    key: "jd:pop",
    platform: "jd",
    subPlatform: "pop",
    label: "京东",
    enabled: false,
    status: "disconnected",
  },
];

// ---------------------------------------------------------------------------
// Tauri helpers
// ---------------------------------------------------------------------------

export function isTauriEnv(): boolean {
  return !!(window as any).__TAURI_INTERNALS__?.invoke;
}

export function tauriInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
  const internals = (window as any).__TAURI_INTERNALS__;
  if (!internals?.invoke) {
    return Promise.reject(new Error("Not running in Tauri environment"));
  }
  return internals.invoke(cmd, args) as Promise<T>;
}

// ---------------------------------------------------------------------------
// HTTP API helpers
// ---------------------------------------------------------------------------

export async function checkPlatformStatus(
  basePath: string,
  platform: string,
  subPlatform: string,
): Promise<ConnectionStatus> {
  try {
    const res = await fetch(
      `${basePath}/credentials/status?platform=${platform}&sub_platform=${subPlatform}`,
    );
    if (!res.ok) return "disconnected";
    const data = await res.json();
    if (data.status === "valid") return "connected";
    if (data.status === "expired") return "expired";
    return "disconnected";
  } catch {
    return "disconnected";
  }
}

export async function fetchPlatformAccounts(
  basePath: string,
  platform: string,
  subPlatform: string,
): Promise<AccountItem[]> {
  try {
    const res = await fetch(
      `${basePath}/credentials/accounts?platform=${platform}&sub_platform=${subPlatform}`,
    );
    if (!res.ok) return [];
    return await res.json();
  } catch {
    return [];
  }
}

export async function loadAllSelectedAccounts(
  basePath: string,
): Promise<Record<string, string>> {
  try {
    const res = await fetch(`${basePath}/workspace/platform-accounts`);
    if (!res.ok) return {};
    const data = await res.json();
    return (data.selections as Record<string, string>) ?? {};
  } catch {
    return {};
  }
}

export async function saveSelectedAccount(
  basePath: string,
  platform: string,
  subPlatform: string,
  accountId: string,
): Promise<void> {
  try {
    await fetch(`${basePath}/workspace/platform-accounts`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        platform,
        sub_platform: subPlatform,
        account_id: accountId,
      }),
    });
  } catch {
    // best-effort
  }
}

export async function deleteCredential(
  basePath: string,
  platform: string,
  subPlatform: string,
  accountId: string,
): Promise<boolean> {
  try {
    const res = await fetch(`${basePath}/credentials/delete`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        platform,
        sub_platform: subPlatform,
        account_id: accountId,
      }),
    });
    return res.ok;
  } catch {
    return false;
  }
}

// ---------------------------------------------------------------------------
// Login flow
// ---------------------------------------------------------------------------

export interface HandlePlatformLoginOpts {
  item: PlatformItem;
  basePath: string;
  existingAccountIds: Set<string>;
  pollRef: { current: ReturnType<typeof setInterval> | null };
  onLoginEnd: () => void;
  onSuccess: (accountId: string | null) => Promise<void>;
  onError: (msg: string) => void;
}

export async function handlePlatformLogin(opts: HandlePlatformLoginOpts): Promise<void> {
  const { item, basePath, existingAccountIds, pollRef, onLoginEnd, onSuccess, onError } = opts;

  if (!isTauriEnv()) {
    onError("请在桌面应用中使用平台登录");
    return;
  }

  try {
    await tauriInvoke<void>("open_platform_login", {
      platform: item.platform,
      subPlatform: item.subPlatform,
    });

    const result = await new Promise<LoginCookieResult>((resolve, reject) => {
      let attempts = 0;
      const maxAttempts = 150;

      pollRef.current = setInterval(async () => {
        attempts++;
        if (attempts > maxAttempts) {
          clearInterval(pollRef.current!);
          pollRef.current = null;
          reject(new Error("登录超时，请重试"));
          return;
        }

        try {
          const res = await tauriInvoke<LoginCookieResult>("check_login_cookies", {
            platform: item.platform,
            subPlatform: item.subPlatform,
          });

          if (res.logged_in) {
            const newAccountId =
              res.cookies["unb"] || res.cookies["PASS_ID"] || res.cookies["pin"] || "";
            if (!newAccountId) return;
            if (existingAccountIds.size > 0 && existingAccountIds.has(newAccountId)) return;
            if (item.platform === "taobao" && !res.cookies["_m_h5_tk"]) return;

            clearInterval(pollRef.current!);
            pollRef.current = null;
            resolve(res);
          }
        } catch (err: unknown) {
          const msg =
            typeof err === "string" ? err : String((err as { message?: string })?.message ?? err);
          if (msg.includes("窗口未打开")) {
            clearInterval(pollRef.current!);
            pollRef.current = null;
            reject(new Error("登录已取消"));
          }
        }
      }, 2000);
    });

    const saveRes = await fetch(`${basePath}/credentials/save`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        platform: item.platform,
        sub_platform: item.subPlatform,
        cookie_str: result.cookie_str,
      }),
    });

    if (!saveRes.ok) {
      const body = await saveRes.json().catch(() => ({}));
      const detail = (body as { detail?: string })?.detail ?? String(saveRes.status);
      throw new Error(`保存凭证失败: ${detail}`);
    }

    const saveData = (await saveRes.json()) as { account_id?: string };

    await tauriInvoke("close_platform_login").catch(() => {});

    if (saveData.account_id) {
      await saveSelectedAccount(
        basePath,
        item.platform,
        item.subPlatform,
        saveData.account_id,
      );
    }
    await onSuccess(saveData.account_id ?? null);
  } catch (err: unknown) {
    await tauriInvoke("close_platform_login").catch(() => {});
    const msg =
      typeof err === "string"
        ? err
        : String((err as { message?: string })?.message ?? "登录失败，请重试");
    onError(msg);
  } finally {
    if (pollRef.current) {
      clearInterval(pollRef.current);
      pollRef.current = null;
    }
    onLoginEnd();
  }
}
```

- [ ] **Step 4: Run tests — expect all to pass**

```bash
cd /Users/mac/code/sealclaw
pnpm test ui/src/ui/chat/platform-accounts.test.ts
```
Expected: all tests PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/mac/code/sealclaw
git add ui/src/ui/chat/platform-accounts.ts ui/src/ui/chat/platform-accounts.test.ts
git commit -m "feat(ui): add platform-accounts logic module"
```

---

## Task 3: Chat View — State and Reset

**Files:**
- Modify: `ui/src/ui/views/chat.ts` (lines ~1–56 imports, ~166–194 state)

- [ ] **Step 1: Add import at top of `chat.ts`**

After the last existing import line (around line 55), add:

```typescript
import {
  type PlatformItem,
  type AccountItem,
  PLATFORMS,
  STATUS_MAP,
  checkPlatformStatus,
  fetchPlatformAccounts,
  loadAllSelectedAccounts,
  saveSelectedAccount,
  deleteCredential,
  handlePlatformLogin,
} from "../chat/platform-accounts.ts";
```

- [ ] **Step 2: Extend `ChatEphemeralState` interface**

Replace the `ChatEphemeralState` interface (lines 166–177) with:

```typescript
interface ChatEphemeralState {
  slashMenuOpen: boolean;
  slashMenuItems: SlashCommandDef[];
  slashMenuIndex: number;
  slashMenuMode: "command" | "args";
  slashMenuCommand: SlashCommandDef | null;
  slashMenuArgItems: string[];
  slashMenuExpanded: boolean;
  searchOpen: boolean;
  searchQuery: string;
  pinnedExpanded: boolean;
  accountSelectorOpen: boolean;
  accountSelectorPlatforms: PlatformItem[];
  accountSelectorAccounts: Record<string, AccountItem[]>;
  accountSelectorSelected: Record<string, string>;
  accountSelectorLoginLoading: string | null;
  accountSelectorExpanded: string | null;
  accountSelectorAccountsLoading: string | null;
  accountSelectorError: string | null;
}
```

- [ ] **Step 3: Extend `createChatEphemeralState()`**

Replace `createChatEphemeralState` (lines 179–192) with:

```typescript
function createChatEphemeralState(): ChatEphemeralState {
  return {
    slashMenuOpen: false,
    slashMenuItems: [],
    slashMenuIndex: 0,
    slashMenuMode: "command",
    slashMenuCommand: null,
    slashMenuArgItems: [],
    slashMenuExpanded: false,
    searchOpen: false,
    searchQuery: "",
    pinnedExpanded: false,
    accountSelectorOpen: false,
    accountSelectorPlatforms: PLATFORMS.map((p) => ({ ...p })),
    accountSelectorAccounts: {},
    accountSelectorSelected: {},
    accountSelectorLoginLoading: null,
    accountSelectorExpanded: null,
    accountSelectorAccountsLoading: null,
    accountSelectorError: null,
  };
}
```

- [ ] **Step 4: Add module-level polling reference**

After `const vs = createChatEphemeralState();` (line ~194), add:

```typescript
let accountSelectorPollRef: { current: ReturnType<typeof setInterval> | null } = {
  current: null,
};
```

- [ ] **Step 5: Run typecheck**

```bash
cd /Users/mac/code/sealclaw
pnpm check:changed
```
Expected: no type errors in `chat.ts`.

- [ ] **Step 6: Commit**

```bash
cd /Users/mac/code/sealclaw
git add ui/src/ui/views/chat.ts
git commit -m "feat(ui): add account selector state to chat vs object"
```

---

## Task 4: Chat View — `renderAccountSelector` and Toolbar Button

**Files:**
- Modify: `ui/src/ui/views/chat.ts`

- [ ] **Step 1: Add `renderAccountSelector` function**

Add the following function directly before the `renderChat` function (around line 799). This function is self-contained and uses `vs`, `props.basePath`, and `requestUpdate`:

```typescript
async function refreshAllPlatformStatus(
  basePath: string,
  requestUpdate: () => void,
): Promise<void> {
  const updated = await Promise.all(
    vs.accountSelectorPlatforms.map(async (p) => {
      if (!p.enabled) return p;
      const status = await checkPlatformStatus(basePath, p.platform, p.subPlatform);
      return { ...p, status };
    }),
  );
  vs.accountSelectorPlatforms = updated;
  requestUpdate();
}

async function loadPlatformAccounts(
  basePath: string,
  item: PlatformItem,
  requestUpdate: () => void,
): Promise<void> {
  vs.accountSelectorAccountsLoading = item.key;
  requestUpdate();
  const [list, selections] = await Promise.all([
    fetchPlatformAccounts(basePath, item.platform, item.subPlatform),
    loadAllSelectedAccounts(basePath),
  ]);
  vs.accountSelectorAccounts = { ...vs.accountSelectorAccounts, [item.key]: list };
  vs.accountSelectorSelected = { ...vs.accountSelectorSelected, ...selections };
  if (list.length > 0 && !vs.accountSelectorSelected[item.key]) {
    await saveSelectedAccount(basePath, item.platform, item.subPlatform, list[0].account_id);
    vs.accountSelectorSelected = {
      ...vs.accountSelectorSelected,
      [item.key]: list[0].account_id,
    };
  }
  vs.accountSelectorAccountsLoading = null;
  requestUpdate();
}

function renderAccountSelector(
  props: ChatProps,
  requestUpdate: () => void,
): TemplateResult | typeof nothing {
  const basePath = props.basePath ?? "";
  const open = vs.accountSelectorOpen;
  const platforms = vs.accountSelectorPlatforms;
  const connectedCount = platforms.filter((p) => p.status === "connected").length;

  const openSelector = async () => {
    vs.accountSelectorOpen = true;
    vs.accountSelectorExpanded = null;
    vs.accountSelectorError = null;
    requestUpdate();
    await refreshAllPlatformStatus(basePath, requestUpdate);
  };

  const closeSelector = () => {
    vs.accountSelectorOpen = false;
    vs.accountSelectorExpanded = null;
    requestUpdate();
  };

  const startLogin = async (item: PlatformItem) => {
    if (!item.enabled || vs.accountSelectorLoginLoading) return;
    const existingIds = new Set(
      (vs.accountSelectorAccounts[item.key] ?? []).map((a) => a.account_id),
    );
    vs.accountSelectorLoginLoading = item.key;
    vs.accountSelectorError = null;
    requestUpdate();
    await handlePlatformLogin({
      item,
      basePath,
      existingAccountIds: existingIds,
      pollRef: accountSelectorPollRef,
      onLoginEnd: () => {
        vs.accountSelectorLoginLoading = null;
        requestUpdate();
      },
      onSuccess: async (accountId) => {
        vs.accountSelectorExpanded = item.key;
        await loadPlatformAccounts(basePath, item, requestUpdate);
        await refreshAllPlatformStatus(basePath, requestUpdate);
      },
      onError: (msg) => {
        vs.accountSelectorError = msg;
        requestUpdate();
      },
    });
  };

  const handlePlatformRowClick = async (item: PlatformItem) => {
    if (!item.enabled || vs.accountSelectorLoginLoading) return;
    if (item.status === "disconnected") {
      await startLogin(item);
    } else if (vs.accountSelectorExpanded === item.key) {
      vs.accountSelectorExpanded = null;
      requestUpdate();
    } else {
      vs.accountSelectorExpanded = item.key;
      requestUpdate();
      await loadPlatformAccounts(basePath, item, requestUpdate);
    }
  };

  const handleSwitchAccount = async (item: PlatformItem, accountId: string) => {
    await saveSelectedAccount(basePath, item.platform, item.subPlatform, accountId);
    vs.accountSelectorSelected = { ...vs.accountSelectorSelected, [item.key]: accountId };
    requestUpdate();
  };

  const handleDisconnect = async (item: PlatformItem, accountId: string, e: Event) => {
    e.stopPropagation();
    const ok = await deleteCredential(basePath, item.platform, item.subPlatform, accountId);
    if (!ok) return;
    const remaining = (vs.accountSelectorAccounts[item.key] ?? []).filter(
      (a) => a.account_id !== accountId,
    );
    vs.accountSelectorAccounts = { ...vs.accountSelectorAccounts, [item.key]: remaining };
    if (vs.accountSelectorSelected[item.key] === accountId) {
      if (remaining.length > 0) {
        await saveSelectedAccount(
          basePath,
          item.platform,
          item.subPlatform,
          remaining[0].account_id,
        );
        vs.accountSelectorSelected = {
          ...vs.accountSelectorSelected,
          [item.key]: remaining[0].account_id,
        };
      } else {
        const next = { ...vs.accountSelectorSelected };
        delete next[item.key];
        vs.accountSelectorSelected = next;
      }
    }
    await refreshAllPlatformStatus(basePath, requestUpdate);
  };

  const dropdownHtml = html`
    <div class="platform-accounts-overlay" @click=${closeSelector}></div>
    <div class="platform-accounts-dropdown">
      <div class="platform-accounts-header">连接</div>
      ${vs.accountSelectorError
        ? html`<div class="platform-accounts-error">${vs.accountSelectorError}</div>`
        : nothing}
      ${platforms.map((item) => {
        const statusInfo = STATUS_MAP[item.status];
        const isLoggingIn = vs.accountSelectorLoginLoading === item.key;
        const isExpanded = vs.accountSelectorExpanded === item.key;
        const platformAccounts = vs.accountSelectorAccounts[item.key] ?? [];
        const currentSelectedId = vs.accountSelectorSelected[item.key];
        const isLoadingAccounts = vs.accountSelectorAccountsLoading === item.key;
        const hasAccounts = item.status !== "disconnected";

        return html`
          <div
            class="platform-accounts-platform ${item.enabled && !isLoggingIn
              ? "platform-accounts-platform--enabled"
              : ""}"
            @click=${() => handlePlatformRowClick(item)}
          >
            ${item.enabled && hasAccounts
              ? html`<span
                  class="platform-accounts-chevron ${isExpanded
                    ? "platform-accounts-chevron--open"
                    : ""}"
                  >${icons.chevronDown}</span
                >`
              : nothing}
            <span class="platform-accounts-platform__label">${item.label}</span>
            ${!item.enabled
              ? html`<span class="platform-accounts-badge platform-accounts-badge--disabled"
                    >未开放</span
                  >`
              : nothing}
            ${hasAccounts && !isExpanded && platformAccounts.length > 0
              ? html`<span class="platform-accounts-platform__account-name">
                  ${platformAccounts.find((a) => a.account_id === currentSelectedId)
                    ?.account_name ?? platformAccounts[0]?.account_name}
                </span>`
              : nothing}
            <span
              class="platform-accounts-status"
              style="color:${item.enabled ? statusInfo.color : "#9ca3af"};background:${item.enabled
                ? statusInfo.bg
                : "#f3f4f6"}"
            >
              ${isLoggingIn
                ? html`<span class="platform-accounts-spinner"></span>`
                : item.enabled
                  ? statusInfo.label
                  : "未开放"}
            </span>
          </div>
          ${isExpanded
            ? html`
                <div class="platform-accounts-accounts">
                  ${isLoadingAccounts
                    ? html`<div class="platform-accounts-loading">
                        <span class="platform-accounts-spinner"></span>
                      </div>`
                    : platformAccounts.length === 0
                      ? html`<div class="platform-accounts-empty">暂无账号</div>`
                      : platformAccounts.map((account) => {
                          const isSelected =
                            currentSelectedId === account.account_id ||
                            (!currentSelectedId &&
                              platformAccounts[0]?.account_id === account.account_id);
                          return html`
                            <div
                              class="platform-accounts-account ${isSelected
                                ? "platform-accounts-account--selected"
                                : ""}"
                              @click=${() => {
                                if (!isSelected)
                                  handleSwitchAccount(item, account.account_id);
                              }}
                            >
                              <span class="platform-accounts-account__name">
                                ${account.account_name || account.account_id}
                              </span>
                              ${account.main_cate_name
                                ? html`<span
                                    class="platform-accounts-badge platform-accounts-badge--disabled"
                                    >${account.main_cate_name}</span
                                  >`
                                : nothing}
                              ${isSelected
                                ? html`<span
                                    class="platform-accounts-badge platform-accounts-badge--active"
                                    >使用中</span
                                  >`
                                : nothing}
                              <button
                                class="platform-accounts-account__remove"
                                title="断开连接"
                                @click=${(e: Event) =>
                                  handleDisconnect(item, account.account_id, e)}
                              >
                                ${icons.x}
                              </button>
                            </div>
                          `;
                        })}
                  <div
                    class="platform-accounts-add"
                    @click=${(e: Event) => {
                      e.stopPropagation();
                      startLogin(item);
                    }}
                  >
                    + 添加账号
                  </div>
                </div>
              `
            : nothing}
        `;
      })}
    </div>
  `;

  return html`
    <div class="platform-accounts-wrapper">
      <button
        class="platform-accounts-trigger ${open ? "platform-accounts-trigger--open" : ""}"
        @click=${openSelector}
        title="连接账号"
        aria-label="连接账号"
      >
        ${icons.link}
        <span>连接${connectedCount > 0 ? ` (${connectedCount})` : ""}</span>
        <span
          class="platform-accounts-chevron-down ${open
            ? "platform-accounts-chevron-down--open"
            : ""}"
          >${icons.chevronDown}</span
        >
      </button>
      ${open ? dropdownHtml : nothing}
    </div>
  `;
}
```

- [ ] **Step 2: Insert the button into `agent-chat__toolbar-left`**

In `renderChat`, locate the section starting with `${props.onOpenSkillStore ? html\`` (around line 1325). After the closing of that block (`}: nothing}`), add the account selector:

```typescript
            ${props.onOpenSkillStore
              ? html`
                  <button
                    class="agent-chat__input-btn"
                    @click=${props.onOpenSkillStore}
                    title="使用技能"
                    aria-label="使用技能"
                    ?disabled=${!props.connected}
                  >
                    ${icons.spark}
                  </button>
                `
              : nothing}
            ${renderAccountSelector(props, requestUpdate)}
            ${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
```

(Replace the existing `${tokens ? ...}` line — `renderAccountSelector` goes between the skill button block and the token count.)

- [ ] **Step 3: Run typecheck**

```bash
cd /Users/mac/code/sealclaw
pnpm check:changed
```
Expected: no type errors.

- [ ] **Step 4: Add render test for account selector button**

In `ui/src/ui/views/chat.test.ts`, find the existing test helpers and add a new test at the end of the file:

```typescript
describe("account selector button", () => {
  it("renders account selector trigger in the toolbar", async () => {
    const container = document.createElement("div");
    document.body.appendChild(container);
    resetChatViewState();

    render(
      renderChat({
        ...minimalChatProps(), // use existing helper from file
        connected: true,
        basePath: "/test",
      }),
      container,
    );

    await Promise.resolve();
    const trigger = container.querySelector(".platform-accounts-trigger");
    expect(trigger).not.toBeNull();
    document.body.removeChild(container);
  });
});
```

> **Note:** `minimalChatProps()` refers to whatever helper already creates minimal props in `chat.test.ts`. Check the file for its exact name and signature and use it.

- [ ] **Step 5: Run the test**

```bash
cd /Users/mac/code/sealclaw
pnpm test ui/src/ui/views/chat.test.ts
```
Expected: all existing tests pass, new test passes.

- [ ] **Step 6: Commit**

```bash
cd /Users/mac/code/sealclaw
git add ui/src/ui/views/chat.ts
git commit -m "feat(ui): add renderAccountSelector and toolbar button"
```

---

## Task 5: CSS Styles

**Files:**
- Modify: `ui/src/styles/chat/layout.css`

- [ ] **Step 1: Append styles to `layout.css`**

Add the following block at the end of `ui/src/styles/chat/layout.css`:

```css
/* =============================================
   PLATFORM ACCOUNT SELECTOR
   ============================================= */

.platform-accounts-wrapper {
  position: relative;
  display: inline-flex;
  align-items: center;
}

.platform-accounts-trigger {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 10px;
  border-radius: 9999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-muted, #6b7280);
  font-size: 13px;
  line-height: 1.5;
  cursor: pointer;
  transition:
    background var(--duration-fast, 120ms) ease,
    color var(--duration-fast, 120ms) ease;
  white-space: nowrap;
}

.platform-accounts-trigger svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.platform-accounts-trigger:hover,
.platform-accounts-trigger--open {
  background: var(--bg-hover);
  color: var(--text);
}

.platform-accounts-chevron-down {
  display: inline-flex;
  align-items: center;
  transition: transform var(--duration-fast, 120ms) ease;
}

.platform-accounts-chevron-down svg {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.platform-accounts-chevron-down--open {
  transform: rotate(180deg);
}

.platform-accounts-overlay {
  position: fixed;
  inset: 0;
  z-index: 99;
}

.platform-accounts-dropdown {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  z-index: 100;
  min-width: 240px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-md, 8px);
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
  padding: 4px 0 8px;
}

.platform-accounts-header {
  font-size: 13px;
  font-weight: 500;
  color: var(--text);
  padding: 8px 12px 4px;
}

.platform-accounts-error {
  font-size: 12px;
  color: var(--danger, #ef4444);
  padding: 4px 12px 2px;
}

.platform-accounts-platform {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  cursor: default;
  transition: background var(--duration-fast, 120ms) ease;
}

.platform-accounts-platform--enabled {
  cursor: pointer;
}

.platform-accounts-platform--enabled:hover {
  background: var(--bg-hover);
}

.platform-accounts-platform__label {
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  flex: 1 1 0;
  min-width: 0;
}

.platform-accounts-platform__account-name {
  font-size: 12px;
  color: var(--text-muted, #6b7280);
  max-width: 80px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.platform-accounts-chevron {
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  color: var(--text-muted, #6b7280);
  transition: transform var(--duration-fast, 120ms) ease;
}

.platform-accounts-chevron svg {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.platform-accounts-chevron--open {
  transform: rotate(180deg);
}

.platform-accounts-status {
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 9999px;
  line-height: 1.5;
  flex-shrink: 0;
}

.platform-accounts-badge {
  font-size: 10px;
  padding: 2px 6px;
  border-radius: 4px;
  line-height: 1.5;
  flex-shrink: 0;
}

.platform-accounts-badge--disabled {
  background: var(--bg-hover);
  color: var(--text-muted, #6b7280);
}

.platform-accounts-badge--active {
  background: color-mix(in srgb, var(--accent) 15%, transparent);
  color: var(--accent);
}

.platform-accounts-accounts {
  margin-left: 20px;
  margin-bottom: 4px;
}

.platform-accounts-account {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border-radius: var(--radius-sm, 4px);
  cursor: pointer;
  transition: background var(--duration-fast, 120ms) ease;
}

.platform-accounts-account:hover:not(.platform-accounts-account--selected) {
  background: var(--bg-hover);
}

.platform-accounts-account--selected {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  cursor: default;
}

.platform-accounts-account__name {
  font-size: 13px;
  color: var(--text);
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.platform-accounts-account__remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  padding: 2px;
  color: var(--text-muted, #6b7280);
  cursor: pointer;
  border-radius: var(--radius-sm, 4px);
  transition: color var(--duration-fast, 120ms) ease;
  flex-shrink: 0;
}

.platform-accounts-account__remove:hover {
  color: var(--danger, #ef4444);
}

.platform-accounts-account__remove svg {
  width: 10px;
  height: 10px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.platform-accounts-add {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 6px 12px;
  font-size: 13px;
  color: var(--accent);
  cursor: pointer;
  border-radius: var(--radius-sm, 4px);
  transition: background var(--duration-fast, 120ms) ease;
}

.platform-accounts-add:hover {
  background: color-mix(in srgb, var(--accent) 10%, transparent);
}

.platform-accounts-loading,
.platform-accounts-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 8px 12px;
  font-size: 12px;
  color: var(--text-muted, #6b7280);
  gap: 6px;
}

.platform-accounts-spinner {
  display: inline-block;
  width: 12px;
  height: 12px;
  border: 2px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: platform-accounts-spin 0.6s linear infinite;
  flex-shrink: 0;
}

@keyframes platform-accounts-spin {
  to {
    transform: rotate(360deg);
  }
}
```

- [ ] **Step 2: Run CSS tests**

```bash
cd /Users/mac/code/sealclaw
pnpm test ui/src/styles/chat/layout.test.ts
```
Expected: all layout tests pass (CSS tests only check class existence/properties).

- [ ] **Step 3: Start dev server and visually verify**

```bash
cd /Users/mac/code/sealclaw
pnpm dev
```

Open the chat view. Verify:
1. "连接" pill button appears to the right of the ⚡ skill button
2. Clicking opens the dropdown above the toolbar
3. Platform rows show correct labels and "未连接" / "未开放" badges
4. Clicking outside (overlay) closes the dropdown
5. In non-Tauri browser: clicking "淘宝" row shows error message "请在桌面应用中使用平台登录"

- [ ] **Step 4: Commit**

```bash
cd /Users/mac/code/sealclaw
git add ui/src/styles/chat/layout.css
git commit -m "feat(ui): add platform account selector styles"
```

---

## Self-Review

### Spec Coverage Check

| Spec requirement | Covered in task |
|---|---|
| Button to right of skill button in toolbar | Task 4, Step 2 |
| Dropdown with platform list | Task 4, Step 1 (`renderAccountSelector`) |
| Status badges (disconnected/connected/expired) | Task 2 (`STATUS_MAP`) + Task 4 (`renderAccountSelector`) |
| Connect account (Tauri WebView login) | Task 2 (`handlePlatformLogin`) |
| Web non-Tauri: show message, block login | Task 2 (`isTauriEnv` guard) + Task 4 (error display) |
| Expand platform to see accounts | Task 4 (`handlePlatformRowClick`) |
| Switch account | Task 4 (`handleSwitchAccount`) |
| Disconnect account | Task 4 (`handleDisconnect`) |
| Add account (re-login) | Task 4 (`startLogin` from "添加账号") |
| Selected account stored as workspace file | Task 1 (backend) + Task 2 (`saveSelectedAccount`) |
| Read selected account from workspace file | Task 1 (GET endpoint) + Task 2 (`loadAllSelectedAccounts`) |
| `/credentials/selected-account` NOT used | Not referenced anywhere in the code ✅ |
| CSS styles for all UI elements | Task 5 |
| 4 platforms (taobao enabled, others disabled) | Task 2 (`PLATFORMS`) |

### Type Consistency

- `PlatformItem` defined in Task 2, imported in Task 3 — consistent ✅
- `AccountItem` defined in Task 2, used in Task 3 state fields — consistent ✅  
- `handlePlatformLogin` options interface `HandlePlatformLoginOpts` defined in Task 2, called in Task 4 — consistent ✅
- `accountSelectorPollRef` defined in Task 3, passed to `handlePlatformLogin` in Task 4 — consistent ✅
- `loadAllSelectedAccounts` returns `Record<string, string>`, used as `vs.accountSelectorSelected` which is `Record<string, string>` — consistent ✅
