# 侧边栏「新对话」草稿态 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:** 把侧边栏「+ 新建会话」改造成「新对话」草稿态——点击只进入欢迎页面、不落库；首条消息发送时才创建会话，并用消息前 10 个代码点作为标题。

**Architecture:** 引入 `chatDraftMode` flag + `DRAFT_SESSION_KEY = "__draft__"` 哨兵。点击「+」走 `enterChatDraft`（仅清状态、不调 RPC）；`state.handleSendChat()` 包一层 draft 拦截：在草稿态时先 `sessions.create({label})` + `switchChatSession`，再走原 `handleSendChat`。失败保留输入。

**Tech Stack:** TypeScript / Lit / Vitest (jsdom) / pnpm。

**Spec:** `docs/superpowers/specs/2026-05-20-sidebar-new-chat-draft-design.md`

**Branch:** 在主仓库 worktree 上做即可，无需独立 worktree（变更范围有限，且 main 已有局部脏改动 `ui/src/ui/app-render.*`，按用户既有工作流）。

---

## File Map

| 路径 | 角色 |
| --- | --- |
| `ui/src/ui/chat/session-controls.ts` | 新增 `firstCharsByCodePoint`、新增 `DRAFT_SESSION_KEY` 常量并 export |
| `ui/src/ui/app-view-state.ts` | 增加 `chatDraftMode`、`chatDraftParentSessionKey` 字段 |
| `ui/src/ui/app.ts` | 给 `OpenClawApp` 加这两个 `@state()`/字段初始值；包装 `handleSendChat` 拦截草稿首发 |
| `ui/src/ui/app-render.helpers.ts` | 新增 `enterChatDraft`；`resetChatStateForSessionSwitch` 末尾清 draft 字段；「+」按钮改用 `enterChatDraft` |
| `ui/src/ui/chat/session-sidebar.ts` | 按钮 `title`/`aria-label` 文案 → 「新对话」 |
| `ui/src/ui/app-chat.ts` | 把 line 251 的 `message.trim().slice(0, 10)` 替换为 `firstCharsByCodePoint(message, 10)`，统一裁剪规则 |
| `ui/src/ui/app-settings.ts` | `syncUrlWithSessionKey` 中遇到 sentinel 不写入 URL；URL 反向解析时把 sentinel 视为未指定 |
| `ui/src/ui/chat/session-controls.test.ts`（新建） | `firstCharsByCodePoint` 单测 |
| `ui/src/ui/app-render.helpers.test.ts`（按已有命名可能是 `app-render.test.ts`，按实际现有文件追加） | `enterChatDraft` 单测；按钮文案断言 |
| `ui/src/ui/app-chat.test.ts` | 草稿首发成功 / 失败 / 切走 / 既有 auto-rename 回归 |
| `ui/src/ui/app-settings.test.ts`（追加，若已有） | sentinel URL 同步 |

执行前先确认 `pnpm` 在 ui 子目录可用：`pnpm -F @sealclaw/ui test ui/src/ui/chat/session-controls.test.ts` 之类。仓库根目录 `pnpm test <path>` 也可（仓库根 `package.json` 已经在用 pnpm workspace）。

---

## Task 0: 准备分支与基线

**Files:** 无

- [ ] **Step 1: 确认工作区干净**

Run:
```bash
git status
```
Expected: 只有用户之前的 `ui/src/ui/app-render.helpers.ts`、`ui/src/ui/app-render.ts` 两个未提交修改；docs 那笔已 commit。如有其它脏改动，先和用户确认。

- [ ] **Step 2: 建立功能分支**

Run:
```bash
git switch -c feat/sidebar-new-chat-draft
```
Expected: `Switched to a new branch 'feat/sidebar-new-chat-draft'`

- [ ] **Step 3: 基线测试，确认目前是绿的**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts
```
Expected: 全部 PASS（如果有 fail，先停下来报告，不要继续）。

---

## Task 1: 新增 `firstCharsByCodePoint` 工具函数（TDD）

**Files:**
- Create: `ui/src/ui/chat/session-controls.test.ts`
- Modify: `ui/src/ui/chat/session-controls.ts`（在文件末尾追加 export）

- [ ] **Step 1: 写失败的测试**

Create `ui/src/ui/chat/session-controls.test.ts`:

```ts
import { describe, expect, it } from "vitest";
import { firstCharsByCodePoint } from "./session-controls.ts";

describe("firstCharsByCodePoint", () => {
  it("取前 N 个代码点，按 Unicode 字符计数", () => {
    expect(firstCharsByCodePoint("abcdefghijklmnop", 10)).toBe("abcdefghij");
  });

  it("中文按 1 字 1 个计算", () => {
    expect(firstCharsByCodePoint("中文中文中文中文中文中文", 10)).toBe("中文中文中文中文中文");
  });

  it("emoji（代理对）按 1 个代码点计算，不会切半个码元", () => {
    expect(firstCharsByCodePoint("🚀🚀🚀abcdefg", 10)).toBe("🚀🚀🚀abcdefg");
    expect(firstCharsByCodePoint("🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀", 10)).toBe("🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀");
  });

  it("去除首尾空白后再截", () => {
    expect(firstCharsByCodePoint("   hello world   ", 5)).toBe("hello");
  });

  it("短于 N 时整串返回", () => {
    expect(firstCharsByCodePoint("hi", 10)).toBe("hi");
  });

  it("空串返回空串", () => {
    expect(firstCharsByCodePoint("", 10)).toBe("");
    expect(firstCharsByCodePoint("   ", 10)).toBe("");
  });
});
```

- [ ] **Step 2: 运行测试，确认失败**

Run:
```bash
pnpm test ui/src/ui/chat/session-controls.test.ts
```
Expected: FAIL — `firstCharsByCodePoint is not a function` 或导入错误。

- [ ] **Step 3: 实现函数**

在 `ui/src/ui/chat/session-controls.ts` 末尾追加：

```ts
export function firstCharsByCodePoint(input: string, n: number): string {
  if (n <= 0) return "";
  return [...input.trim()].slice(0, n).join("");
}
```

- [ ] **Step 4: 运行测试，确认通过**

Run:
```bash
pnpm test ui/src/ui/chat/session-controls.test.ts
```
Expected: 6 PASS。

- [ ] **Step 5: Commit**

```bash
scripts/committer "feat(ui): add firstCharsByCodePoint helper" \
  ui/src/ui/chat/session-controls.ts \
  ui/src/ui/chat/session-controls.test.ts
```

---

## Task 2: 新增 `DRAFT_SESSION_KEY` 常量 & 状态字段

**Files:**
- Modify: `ui/src/ui/chat/session-controls.ts`（顶部导出常量）
- Modify: `ui/src/ui/app-view-state.ts`（加字段）
- Modify: `ui/src/ui/app.ts`（加 `@state()` 初值）

- [ ] **Step 1: 增加常量 export**

在 `ui/src/ui/chat/session-controls.ts` 顶部已有 `import` 段之后、`type ChatSessionSwitchHandler` 之前插入：

```ts
export const DRAFT_SESSION_KEY = "__draft__";

export function isDraftSessionKey(key: string | null | undefined): boolean {
  return key === DRAFT_SESSION_KEY;
}
```

- [ ] **Step 2: AppViewState 加字段**

在 `ui/src/ui/app-view-state.ts` 中 `chatDraftBeforeHistory: string | null;` 这一行下面加：

```ts
  chatDraftMode: boolean;
  chatDraftParentSessionKey: string | null;
```

- [ ] **Step 3: OpenClawApp 加初值**

在 `ui/src/ui/app.ts` line 249 的 `chatDraftBeforeHistory` 同段附近加：

```ts
  @state() chatDraftMode = false;
  chatDraftParentSessionKey: string | null = null;
```

> `chatDraftMode` 用 `@state()`，因为渲染要响应；`chatDraftParentSessionKey` 不需要触发渲染。

- [ ] **Step 4: 更新所有已有 `makeHost` / 测试桩**

打开 `ui/src/ui/app-chat.test.ts`，在 `makeHost` 默认值里追加：

```ts
    chatDraftMode: false,
    chatDraftParentSessionKey: null,
```

打开 `ui/src/ui/app-render.assistant-avatar.test.ts`，同样追加这两项到测试桩 host 上。

- [ ] **Step 5: 跑现有测试，确认没破**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts ui/src/ui/app-render.assistant-avatar.test.ts ui/src/ui/chat/session-controls.test.ts
```
Expected: 全部 PASS。如果 TS 报「缺字段」，把缺字段的测试桩文件也补上。

- [ ] **Step 6: Commit**

```bash
scripts/committer "feat(ui): introduce DRAFT_SESSION_KEY + chatDraftMode state" \
  ui/src/ui/chat/session-controls.ts \
  ui/src/ui/app-view-state.ts \
  ui/src/ui/app.ts \
  ui/src/ui/app-chat.test.ts \
  ui/src/ui/app-render.assistant-avatar.test.ts
```

---

## Task 3: 实现 `enterChatDraft`（TDD）

**Files:**
- Modify: `ui/src/ui/app-render.helpers.ts`（新增 `enterChatDraft`，调整 `resetChatStateForSessionSwitch` 重置 draft 字段）
- Modify/Create: `ui/src/ui/app-render.helpers.test.ts` 或追加到现成的 test 文件

### Step 1: 探测现有 helpers 测试文件

Run:
```bash
ls ui/src/ui/app-render*.test.ts
```
若已存在 `app-render.helpers.test.ts`，本任务在里面追加 describe。否则新建。

- [ ] **Step 2: 写失败的测试**

在 `ui/src/ui/app-render.helpers.test.ts`（不存在则创建）追加：

```ts
/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("./app-chat.ts", () => ({
  refreshChat: vi.fn(),
  refreshChatAvatar: vi.fn(),
}));

vi.mock("./app-settings.ts", () => ({
  syncUrlWithSessionKey: vi.fn(),
}));

vi.mock("./controllers/chat.ts", () => ({
  ChatState: class {},
  loadChatHistory: vi.fn(),
}));

vi.mock("./controllers/sessions.ts", () => ({
  createSessionAndRefresh: vi.fn(),
  loadSessions: vi.fn(),
}));

vi.mock("./chat/slash-commands.ts", () => ({
  refreshSlashCommands: vi.fn(),
}));

import { DRAFT_SESSION_KEY } from "./chat/session-controls.ts";
import { enterChatDraft } from "./app-render.helpers.ts";

function makeHostState(overrides?: Record<string, unknown>) {
  return {
    sessionKey: "agent:main",
    chatMessage: "draft text",
    chatAttachments: [{ id: "a1" } as unknown],
    chatMessages: [{ role: "user" } as unknown],
    chatToolMessages: [],
    chatStreamSegments: [],
    chatStream: { id: "x" } as unknown,
    chatSideResult: null,
    chatThinkingLevel: null,
    chatQueue: [],
    chatQueueBySession: {},
    chatRunId: "run-1",
    chatStreamStartedAt: 1,
    chatSideResultTerminalRuns: new Set<string>(),
    compactionStatus: null,
    fallbackStatus: null,
    lastError: null,
    chatAvatarUrl: null,
    chatAvatarSource: null,
    chatAvatarStatus: null,
    chatAvatarReason: null,
    chatDraftMode: false,
    chatDraftParentSessionKey: null,
    sessionsResult: { ts: 0, path: "", count: 1, defaults: {} as unknown, sessions: [{ key: "agent:main", kind: "direct", updatedAt: null }] },
    settings: { sessionKey: "agent:main", lastActiveSessionKey: "agent:main" },
    applySettings: vi.fn(),
    resetChatInputHistoryNavigation: vi.fn(),
    resetToolStream: vi.fn(),
    resetChatScroll: vi.fn(),
    ...overrides,
  };
}

describe("enterChatDraft", () => {
  it("把 sessionKey 设为 DRAFT_SESSION_KEY 并记录 parent", () => {
    const state = makeHostState();
    enterChatDraft(state as never);
    expect(state.sessionKey).toBe(DRAFT_SESSION_KEY);
    expect(state.chatDraftMode).toBe(true);
    expect(state.chatDraftParentSessionKey).toBe("agent:main");
  });

  it("清空输入、消息、流、附件、错误", () => {
    const state = makeHostState({ lastError: "old" });
    enterChatDraft(state as never);
    expect(state.chatMessage).toBe("");
    expect(state.chatAttachments).toEqual([]);
    expect(state.chatMessages).toEqual([]);
    expect(state.chatStream).toBeNull();
    expect(state.chatRunId).toBeNull();
    expect(state.lastError).toBeNull();
  });

  it("不持久化 sentinel 到 settings", () => {
    const state = makeHostState();
    enterChatDraft(state as never);
    expect(state.applySettings).not.toHaveBeenCalled();
  });

  it("parent 不在 sessions 列表时记为 null", () => {
    const state = makeHostState({
      sessionsResult: { ts: 0, path: "", count: 0, defaults: {}, sessions: [] },
    });
    enterChatDraft(state as never);
    expect(state.chatDraftParentSessionKey).toBeNull();
  });
});
```

- [ ] **Step 3: 运行，确认失败**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts
```
Expected: FAIL — `enterChatDraft is not exported`。

- [ ] **Step 4: 实现 `enterChatDraft`**

在 `ui/src/ui/app-render.helpers.ts` 顶部 import 段补：

```ts
import { DRAFT_SESSION_KEY, isDraftSessionKey } from "./chat/session-controls.ts";
```

在文件中（建议放在 `switchChatSession` 上方）新增：

```ts
export function enterChatDraft(state: AppViewState) {
  const host = state as unknown as SessionSwitchHost;
  const previousKey = state.sessionKey;
  saveChatQueueForSession(state, previousKey);
  const parent =
    state.sessionsResult?.sessions.some((row) => row.key === previousKey) ? previousKey : null;
  state.chatMessage = "";
  state.chatAttachments = [];
  state.chatMessages = [];
  state.chatToolMessages = [];
  state.chatStreamSegments = [];
  state.chatThinkingLevel = null;
  state.chatStream = null;
  state.chatSideResult = null;
  state.lastError = null;
  state.compactionStatus = null;
  state.fallbackStatus = null;
  state.chatAvatarUrl = null;
  state.chatAvatarSource = null;
  state.chatAvatarStatus = null;
  state.chatAvatarReason = null;
  state.chatQueue = [];
  host.resetChatInputHistoryNavigation();
  host.chatStreamStartedAt = null;
  state.chatRunId = null;
  host.chatSideResultTerminalRuns.clear();
  host.resetToolStream();
  host.resetChatScroll();
  state.sessionKey = DRAFT_SESSION_KEY;
  state.chatDraftMode = true;
  state.chatDraftParentSessionKey = parent;
  // 注意：不调 applySettings，避免把 sentinel 写到 settings.sessionKey/lastActiveSessionKey
}
```

> 字段清空逻辑与 `resetChatStateForSessionSwitch` 相似但不复用 helper——后者尾部需要 `chatQueue = restoreChatQueueForSession(state, sessionKey)`，草稿态则要 `chatQueue = []`，顺序与语义都有差异，抽提收益小。

在 `resetChatStateForSessionSwitch` 函数末尾（`state.applySettings({...})` 之后）追加：

```ts
  state.chatDraftMode = false;
  state.chatDraftParentSessionKey = null;
```

- [ ] **Step 5: 跑测试，确认通过**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts
```
Expected: 4 PASS。

- [ ] **Step 6: 跑回归**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts ui/src/ui/app-render.assistant-avatar.test.ts
```
Expected: 全部 PASS。如果 `app-render.assistant-avatar.test.ts` 由于 `resetChatStateForSessionSwitch` 重构出问题，按报错修测试桩。

- [ ] **Step 7: Commit**

```bash
scripts/committer "feat(ui): add enterChatDraft helper" \
  ui/src/ui/app-render.helpers.ts \
  ui/src/ui/app-render.helpers.test.ts
```

---

## Task 4: 「+」按钮改文案与改连 `enterChatDraft`

**Files:**
- Modify: `ui/src/ui/chat/session-sidebar.ts`（line 200-209 文案）
- Modify: `ui/src/ui/app-render.helpers.ts`（line 282 处 onNewSession 改连 `enterChatDraft`）
- Test: 追加到 `ui/src/ui/app-render.helpers.test.ts`

- [ ] **Step 1: 写失败的测试**

在 `ui/src/ui/app-render.helpers.test.ts` 追加：

```ts
import { html, render } from "lit";
import { renderSidebarSessionList as renderSidebarSessionListBase } from "./chat/session-sidebar.ts";

describe("sidebar new chat button", () => {
  it("title 与 aria-label 都是「新对话」", () => {
    const state = makeHostState({
      sessionsResult: { ts: 0, path: "", count: 0, defaults: {} as unknown, sessions: [] },
      connected: true,
      sidebarDeleteConfirmKey: null,
      sidebarEditingSessionKey: null,
      sidebarMoreMenuKey: null,
      tab: "chat",
    });
    const container = document.createElement("div");
    render(
      renderSidebarSessionListBase(
        state as never,
        () => undefined,
        () => undefined,
      ),
      container,
    );
    const newBtn = container.querySelector(".sidebar-session-list__new-btn");
    expect(newBtn?.getAttribute("title")).toBe("新对话");
    expect(newBtn?.getAttribute("aria-label")).toBe("新对话");
  });
});
```

- [ ] **Step 2: 运行，确认失败**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts -t "sidebar new chat button"
```
Expected: FAIL（assert 看到「新建会话」）。

- [ ] **Step 3: 改 session-sidebar.ts**

把 `ui/src/ui/chat/session-sidebar.ts` line 200-208 改为：

```ts
          <button
            class="btn btn--sm btn--icon sidebar-session-list__new-btn"
            ?disabled=${!state.connected}
            title="新对话"
            aria-label="新对话"
            @click=${() => onNewSession()}
          >
            ${icons.plus}
          </button>
```

- [ ] **Step 4: 跑测试，确认通过**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts -t "sidebar new chat button"
```
Expected: PASS。

- [ ] **Step 5: 把 `+` 按钮 onNewSession 连到 enterChatDraft**

在 `ui/src/ui/app-render.helpers.ts` 中：

- 顶部 import 段加入 `enterChatDraft`（其实是同文件，无需新 import；但要保证 `renderSidebarSessionList` 那行能拿到它）
- 把 line 282 的 `() => createChatSession(state)` 改为 `() => enterChatDraft(state)`

旧的 `createChatSession` 函数（line 626 开始）保留为内部 dead code 暂不动，避免该 commit 牵连过广；下个任务一并清理。

- [ ] **Step 6: 加集成断言**

在同一个 describe 里再加：

```ts
it("点击 + 按钮触发 enterChatDraft 路径（不调 sessions.create）", () => {
  const state = makeHostState({
    sessionsResult: { ts: 0, path: "", count: 0, defaults: {} as unknown, sessions: [] },
    connected: true,
    sidebarDeleteConfirmKey: null,
    sidebarEditingSessionKey: null,
    sidebarMoreMenuKey: null,
    tab: "chat",
    client: { request: vi.fn() },
  });
  const container = document.createElement("div");
  render(renderSidebarSessionList(state as never), container);
  const newBtn = container.querySelector(".sidebar-session-list__new-btn") as HTMLButtonElement;
  newBtn.click();
  expect(state.chatDraftMode).toBe(true);
  expect(state.sessionKey).toBe(DRAFT_SESSION_KEY);
  expect(state.client.request).not.toHaveBeenCalled();
});
```

（注意：上面 `renderSidebarSessionList` 用的是 helpers.ts 中导出的封装版本，而非 base 版；需要在 test 顶部加 `import { renderSidebarSessionList } from "./app-render.helpers.ts";`）

- [ ] **Step 7: 跑测试**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts
```
Expected: 所有相关用例 PASS。

- [ ] **Step 8: Commit**

```bash
scripts/committer "feat(ui): wire + button to draft mode" \
  ui/src/ui/chat/session-sidebar.ts \
  ui/src/ui/app-render.helpers.ts \
  ui/src/ui/app-render.helpers.test.ts
```

---

## Task 5: URL 同步处理 sentinel

**Files:**
- Modify: `ui/src/ui/app-settings.ts`（`syncUrlWithSessionKey` 及 URL 解析）
- Test: 现有 `ui/src/ui/app-settings.test.ts`（若有；若无，新建小测试）

- [ ] **Step 1: 探测现有测试**

Run:
```bash
ls ui/src/ui/app-settings*.test.ts
```

如存在则在里面追加 describe；如不存在，新建 `ui/src/ui/app-settings.draft.test.ts`：

- [ ] **Step 2: 写失败的测试**

```ts
/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it } from "vitest";
import { DRAFT_SESSION_KEY } from "./chat/session-controls.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";

function makeHost() {
  return { basePath: "" } as unknown as Parameters<typeof syncUrlWithSessionKey>[0];
}

describe("syncUrlWithSessionKey draft sentinel", () => {
  beforeEach(() => {
    window.history.replaceState({}, "", "/?session=old");
  });

  it("sentinel 不写入 URL", () => {
    syncUrlWithSessionKey(makeHost(), DRAFT_SESSION_KEY, true);
    const params = new URL(window.location.href).searchParams;
    expect(params.has("session")).toBe(false);
  });

  it("普通 key 仍正常写入", () => {
    syncUrlWithSessionKey(makeHost(), "agent:main", true);
    const params = new URL(window.location.href).searchParams;
    expect(params.get("session")).toBe("agent:main");
  });
});
```

- [ ] **Step 3: 运行，确认失败**

Run:
```bash
pnpm test ui/src/ui/app-settings.draft.test.ts
```
Expected: 第一个测试 FAIL。

- [ ] **Step 4: 改 `syncUrlWithSessionKey`**

在 `ui/src/ui/app-settings.ts` 找到 `syncUrlWithSessionKey` 函数（line ~643），在函数体顶部加：

```ts
  if (sessionKey === DRAFT_SESSION_KEY) {
    // Draft sentinel：从 URL 移除 session 参数，不暴露给地址栏。
    const href = typeof window === "undefined" ? undefined : window.location?.href;
    if (!href) return;
    const url = new URL(href);
    if (url.searchParams.has("session")) {
      url.searchParams.delete("session");
      updateBrowserHistory(url, replace);
    }
    return;
  }
```

文件顶部 import 段加：

```ts
import { DRAFT_SESSION_KEY } from "./chat/session-controls.ts";
```

如 `app-settings.ts` 在 URL 反向读取入口（搜 `params.get("session")` / `hashParams.get("session")`）拿到 sentinel，对应位置加：

```ts
if (rawSession === DRAFT_SESSION_KEY) rawSession = null;
```

> 实际位置和变量名以代码为准；用 Grep 找 `params.get("session")` 定位。

- [ ] **Step 5: 跑测试，确认通过**

Run:
```bash
pnpm test ui/src/ui/app-settings.draft.test.ts
```
Expected: PASS。

- [ ] **Step 6: Commit**

```bash
scripts/committer "feat(ui): suppress draft sentinel in URL sync" \
  ui/src/ui/app-settings.ts \
  ui/src/ui/app-settings.draft.test.ts
```

---

## Task 6: 拦截 `handleSendChat`，草稿首发先 create 再 send

**Files:**
- Modify: `ui/src/ui/app.ts`（包装 `handleSendChat`）
- Test: `ui/src/ui/app-chat.test.ts` 增 describe（直接测 wrapper 逻辑可以更轻：直接测 helper 函数）

为便于测试，先把 wrapper 内部逻辑抽成独立函数 `commitChatDraftBeforeSend`，放进 `app-render.helpers.ts`。

- [ ] **Step 1: 写失败的测试**

在 `ui/src/ui/app-render.helpers.test.ts` 追加：

```ts
import { createSessionAndRefresh as createSessionAndRefreshMock } from "./controllers/sessions.ts";

describe("commitChatDraftBeforeSend", () => {
  beforeEach(() => {
    vi.mocked(createSessionAndRefreshMock).mockReset();
  });

  it("非草稿态：直接返回 ok=true，不调 sessions.create", async () => {
    const state = makeHostState();
    const { commitChatDraftBeforeSend } = await import("./app-render.helpers.ts");
    const result = await commitChatDraftBeforeSend(state as never, "hello");
    expect(result).toBe(true);
    expect(createSessionAndRefreshMock).not.toHaveBeenCalled();
  });

  it("草稿态：以消息前 10 个代码点为 label 创建会话，并切换", async () => {
    const state = makeHostState({
      chatDraftMode: true,
      sessionKey: DRAFT_SESSION_KEY,
      chatDraftParentSessionKey: "agent:main",
      chatMessage: "你好世界这条消息超过十个字符",
      chatAttachments: [],
    });
    vi.mocked(createSessionAndRefreshMock).mockResolvedValue("agent:main:new");
    const { commitChatDraftBeforeSend } = await import("./app-render.helpers.ts");
    const result = await commitChatDraftBeforeSend(state as never, "你好世界这条消息超过十个字符");
    expect(result).toBe(true);
    expect(createSessionAndRefreshMock).toHaveBeenCalledWith(
      state,
      expect.objectContaining({ label: "你好世界这条消息超" }),
      expect.any(Object),
    );
    expect(state.sessionKey).toBe("agent:main:new");
    expect(state.chatDraftMode).toBe(false);
    // 输入应在 switch 之后被恢复（switchChatSession 内会清空）
    expect(state.chatMessage).toBe("你好世界这条消息超过十个字符");
  });

  it("草稿态创建失败：保留 chatMessage、设置 lastError、保持 draft 态", async () => {
    const state = makeHostState({
      chatDraftMode: true,
      sessionKey: DRAFT_SESSION_KEY,
      chatDraftParentSessionKey: "agent:main",
      chatMessage: "hello",
      chatAttachments: [],
    });
    vi.mocked(createSessionAndRefreshMock).mockResolvedValue(null);
    const { commitChatDraftBeforeSend } = await import("./app-render.helpers.ts");
    const result = await commitChatDraftBeforeSend(state as never, "hello");
    expect(result).toBe(false);
    expect(state.chatDraftMode).toBe(true);
    expect(state.sessionKey).toBe(DRAFT_SESSION_KEY);
    expect(state.chatMessage).toBe("hello");
    expect(state.lastError).toBeTruthy();
  });

  it("草稿态附件-only：label 为空字符串不传给 sessions.create", async () => {
    const state = makeHostState({
      chatDraftMode: true,
      sessionKey: DRAFT_SESSION_KEY,
      chatDraftParentSessionKey: null,
      chatMessage: "",
      chatAttachments: [{ id: "a" }],
    });
    vi.mocked(createSessionAndRefreshMock).mockResolvedValue("agent:main:new");
    const { commitChatDraftBeforeSend } = await import("./app-render.helpers.ts");
    await commitChatDraftBeforeSend(state as never, "");
    const callArgs = vi.mocked(createSessionAndRefreshMock).mock.calls[0]?.[1];
    expect(callArgs?.label).toBeUndefined();
  });
});
```

- [ ] **Step 2: 运行，确认失败**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts -t "commitChatDraftBeforeSend"
```
Expected: FAIL — 函数不存在。

- [ ] **Step 3: 实现 `commitChatDraftBeforeSend`**

在 `ui/src/ui/app-render.helpers.ts` 顶部 import 段补：

```ts
import { firstCharsByCodePoint } from "./chat/session-controls.ts";
```

在 `enterChatDraft` 下方新增：

```ts
const NEW_CHAT_DRAFT_FAIL_MESSAGE = "新对话创建失败，请稍后重试。";

export async function commitChatDraftBeforeSend(
  state: AppViewState,
  message: string,
): Promise<boolean> {
  if (!state.chatDraftMode) {
    return true;
  }
  if (!state.client || !state.connected) {
    state.lastError = NEW_CHAT_DRAFT_FAIL_MESSAGE;
    return false;
  }
  const label = firstCharsByCodePoint(message, 10);
  const parentKey = state.chatDraftParentSessionKey ?? "";
  const createdKey = await createSessionAndRefresh(
    state as unknown as Parameters<typeof createSessionAndRefresh>[0],
    {
      agentId: resolveAgentIdFromSessionKey(parentKey),
      parentSessionKey: parentKey || undefined,
      label: label || undefined,
    },
    {
      activeMinutes: 0,
      limit: 0,
      includeGlobal: true,
      includeUnknown: true,
      showArchived: state.sessionsShowArchived,
    },
  );
  if (!createdKey) {
    state.lastError = state.sessionsError ?? NEW_CHAT_DRAFT_FAIL_MESSAGE;
    return false;
  }
  const preservedMessage = state.chatMessage;
  const preservedAttachments = state.chatAttachments;
  switchChatSession(state, createdKey);
  state.chatMessage = preservedMessage;
  state.chatAttachments = preservedAttachments;
  return true;
}
```

- [ ] **Step 4: 跑测试，确认通过**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts -t "commitChatDraftBeforeSend"
```
Expected: 4 PASS。

- [ ] **Step 5: 包装 app.ts 的 handleSendChat**

在 `ui/src/ui/app.ts` 顶部 import 段补：

```ts
import { commitChatDraftBeforeSend } from "./app-render.helpers.ts";
```

把 `OpenClawApp.handleSendChat` 改为：

```ts
  async handleSendChat(
    messageOverride?: string,
    opts?: Parameters<typeof handleSendChatInternal>[2],
  ) {
    const messageForLabel = (messageOverride ?? this.chatMessage).trim();
    const ok = await commitChatDraftBeforeSend(
      this as unknown as Parameters<typeof commitChatDraftBeforeSend>[0],
      messageForLabel,
    );
    if (!ok) return;
    await handleSendChatInternal(
      this as unknown as Parameters<typeof handleSendChatInternal>[0],
      messageOverride,
      opts,
    );
  }
```

- [ ] **Step 6: 跑回归**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.test.ts
```
Expected: 全部 PASS。

- [ ] **Step 7: Commit**

```bash
scripts/committer "feat(ui): commit draft chat before first send" \
  ui/src/ui/app-render.helpers.ts \
  ui/src/ui/app-render.helpers.test.ts \
  ui/src/ui/app.ts
```

---

## Task 7: 既有自动重命名统一用 `firstCharsByCodePoint`

**Files:**
- Modify: `ui/src/ui/app-chat.ts` line 251
- Test: 加测试覆盖 emoji 不被切半

- [ ] **Step 1: 写失败的测试**

在 `ui/src/ui/app-chat.test.ts` 末尾追加 describe（沿用现有 `makeHost`/`createSessionsResult` 工具）：

```ts
describe("auto-rename on first message uses code points", () => {
  beforeAll(async () => {
    await loadChatHelpers();
  });

  it("emoji 不会被切半个码元", async () => {
    const patchCalls: Array<{ key: string; label: string }> = [];
    const host = makeHost({
      sessionKey: "agent:main",
      sessionsResult: createSessionsResult([row("agent:main", { label: undefined })]),
      chatMessage: "🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀",
      client: {
        request: vi.fn(async (method: string, params: unknown) => {
          if (method === "sessions.patch") {
            const p = params as { key: string; label: string };
            patchCalls.push(p);
          }
          if (method === "chat.send") return { runId: "r1" };
          return {};
        }),
      } as unknown,
    });
    await handleSendChat(host);
    const lastPatch = patchCalls.at(-1);
    expect(lastPatch?.label).toBe("🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀");
  });
});
```

> 如果 `chat.send` 的实际 RPC 名/payload 与此不符，按当前 `sendChatMessage` 的实现调整 mock；可参考同文件已有用例。

- [ ] **Step 2: 运行，确认失败（emoji 被切半）**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts -t "auto-rename on first message uses code points"
```
Expected: FAIL，因 `slice(0, 10)` 把 11 个 code unit 截到第 5 个 emoji 中间。

- [ ] **Step 3: 改 `sendChatMessageNow`**

`ui/src/ui/app-chat.ts` 顶部 import：

```ts
import { firstCharsByCodePoint } from "./chat/session-controls.ts";
```

把 line 251 的 `const newLabel = message.trim().slice(0, 10);` 改为：

```ts
const newLabel = firstCharsByCodePoint(message, 10);
```

- [ ] **Step 4: 跑测试，确认通过**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts -t "auto-rename on first message uses code points"
```
Expected: PASS。

- [ ] **Step 5: 全量回归**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.test.ts ui/src/ui/chat/session-controls.test.ts
```
Expected: 全部 PASS。

- [ ] **Step 6: Commit**

```bash
scripts/committer "refactor(ui): unify session auto-rename slicing on code points" \
  ui/src/ui/app-chat.ts \
  ui/src/ui/app-chat.test.ts
```

---

## Task 8: sessions.subscribe / 周边只对真实 key 生效

**Files:**
- Modify: 任何在 `state.sessionKey === DRAFT_SESSION_KEY` 时仍可能触发后台请求的入口（grep 搜 `sessions.subscribe` 等）

- [ ] **Step 1: 定位订阅入口**

Run:
```bash
rg -n "sessions\\.subscribe|sessions\\.list" ui/src/ui --type ts
```

记录所有调用点。

- [ ] **Step 2: 评估每个入口**

逐个判断：
- 仅在 `state.connected && state.client` 时触发 → 不需要改（草稿态依然可以订阅全列表）
- 直接以 `state.sessionKey` 为参数 → 需要在 sentinel 时跳过

通常只有以 `state.sessionKey` 为参数的请求才需要保护。`subscribeSessions` 本身订阅全列表，不需要。

- [ ] **Step 3: 给受影响的入口加 sentinel 早返**

例如若 `controllers/chat.ts` 中有 `loadChatHistory(state)` 等依赖 `state.sessionKey` 的请求，在函数开头加：

```ts
import { isDraftSessionKey } from "../chat/session-controls.ts";
...
if (isDraftSessionKey(state.sessionKey)) return;
```

（仅在该入口为「针对当前 session 的请求」时加。）

- [ ] **Step 4: 加针对性单测**

如果改了 `loadChatHistory`，在 `controllers/chat.test.ts` 加一个用例验证 sentinel 时不发 RPC。

- [ ] **Step 5: 跑回归**

Run:
```bash
pnpm test ui/src/ui/controllers/chat.test.ts ui/src/ui/app-chat.test.ts
```
Expected: 全 PASS。

- [ ] **Step 6: Commit**

```bash
scripts/committer "feat(ui): skip session-bound RPCs while in draft mode" \
  ui/src/ui/controllers/chat.ts \
  ui/src/ui/controllers/chat.test.ts
```

---

## Task 9: E2E 行为脚本（jsdom）

**Files:**
- Modify: `ui/src/ui/app-render.helpers.test.ts`（追加完整流程用例）

- [ ] **Step 1: 加端到端流程测试**

```ts
describe("草稿态端到端", () => {
  it("点 + → 欢迎页 → 发首条消息 → 创建会话 → 草稿态退出", async () => {
    vi.mocked(createSessionAndRefreshMock).mockResolvedValue("agent:main:created");
    const state = makeHostState({
      sessionKey: "agent:main",
      connected: true,
      tab: "chat",
      sidebarDeleteConfirmKey: null,
      sidebarEditingSessionKey: null,
      sidebarMoreMenuKey: null,
      sessionsResult: {
        ts: 0,
        path: "",
        count: 1,
        defaults: {} as unknown,
        sessions: [{ key: "agent:main", kind: "direct", updatedAt: null }],
      },
      client: { request: vi.fn(async () => ({})) },
    });
    // 点 +
    enterChatDraft(state as never);
    expect(state.chatDraftMode).toBe(true);
    expect(state.sessionKey).toBe(DRAFT_SESSION_KEY);
    expect(state.chatDraftParentSessionKey).toBe("agent:main");
    // 发首条
    state.chatMessage = "你好我是新对话";
    const ok = await commitChatDraftBeforeSend(state as never, state.chatMessage);
    expect(ok).toBe(true);
    expect(state.chatDraftMode).toBe(false);
    expect(state.sessionKey).toBe("agent:main:created");
    expect(state.chatMessage).toBe("你好我是新对话");
    // create 调用应带 label
    const callArgs = vi.mocked(createSessionAndRefreshMock).mock.calls.at(-1)?.[1];
    expect(callArgs?.label).toBe("你好我是新对话");
    expect(callArgs?.parentSessionKey).toBe("agent:main");
  });

  it("草稿态点击其他 session → 草稿丢弃", () => {
    const state = makeHostState({
      chatDraftMode: true,
      sessionKey: DRAFT_SESSION_KEY,
      chatDraftParentSessionKey: "agent:main",
      chatMessage: "未发送内容",
      chatAttachments: [{ id: "a" }],
      sessionsResult: {
        ts: 0,
        path: "",
        count: 2,
        defaults: {} as unknown,
        sessions: [
          { key: "agent:main", kind: "direct", updatedAt: null },
          { key: "agent:other", kind: "direct", updatedAt: null },
        ],
      },
      client: { request: vi.fn(async () => ({})) },
    });
    switchChatSession(state as never, "agent:other");
    expect(state.chatDraftMode).toBe(false);
    expect(state.chatDraftParentSessionKey).toBeNull();
    expect(state.chatMessage).toBe("");
    expect(state.chatAttachments).toEqual([]);
    expect(state.sessionKey).toBe("agent:other");
  });
});
```

需要在文件顶部多 import `switchChatSession`、`enterChatDraft`、`commitChatDraftBeforeSend`、`DRAFT_SESSION_KEY`。

- [ ] **Step 2: 跑测试**

Run:
```bash
pnpm test ui/src/ui/app-render.helpers.test.ts
```
Expected: 全 PASS。

- [ ] **Step 3: Commit**

```bash
scripts/committer "test(ui): cover draft session end-to-end flow" \
  ui/src/ui/app-render.helpers.test.ts
```

---

## Task 10: 清理与最终验证

**Files:**
- Modify: `ui/src/ui/app-render.helpers.ts`（删除已无引用的 `createChatSession`、相关常量 `NEW_CHAT_ACTIVE_RUN_MESSAGE` 等若同时无人用则一并）

- [ ] **Step 1: 探测引用**

Run:
```bash
rg -n "createChatSession\\b|NEW_CHAT_ACTIVE_RUN_MESSAGE|NEW_CHAT_SESSIONS_LOADING_MESSAGE|NEW_CHAT_CREATE_FAILED_MESSAGE" ui/src
```

- [ ] **Step 2: 删除孤儿代码**

如果上一步报零外部引用：删掉 `createChatSession` 函数及只服务于它的常量。保守起见可以保留 `NEW_CHAT_CREATE_FAILED_MESSAGE` 供 `commitChatDraftBeforeSend` fallback 复用。

- [ ] **Step 3: 跑端到端测试**

Run:
```bash
pnpm test ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.test.ts ui/src/ui/chat/session-controls.test.ts ui/src/ui/app-settings.draft.test.ts
```
Expected: 全 PASS。

- [ ] **Step 4: changed gate**

Run:
```bash
pnpm check:changed
```
Expected: 全 PASS。本地若 fan-out 过大就停下交给 Testbox（按 CLAUDE.md gates 规则）。

- [ ] **Step 5: 手动 UI 走查（dev server）**

Run:
```bash
pnpm dev
```

打开浏览器，逐项验证：

1. 侧边栏「+」按钮 hover 显示「新对话」。
2. 点「+」：主区域显示欢迎页（万能玺虾 + 技能清单）；侧边栏当前不高亮任何条目；侧边栏列表数量不变。
3. 在输入框打字「你好我是新对话」回车发送：侧边栏顶部出现新条目，标题「你好我是新对话」；主区域开始流式响应。
4. 再点「+」进入草稿态；输入「abc」后点击侧边栏旧条目：草稿内容消失，跳到旧会话。
5. 在草稿态先断网（开发者工具 throttling=offline）→ 输入消息 → 发送：顶部出现红色错误提示，输入仍在；恢复网络再发送成功。

将走查结果记在 commit message / PR body 中。

- [ ] **Step 6: Commit & 收尾**

```bash
scripts/committer "chore(ui): remove dead createChatSession" \
  ui/src/ui/app-render.helpers.ts
```

如有未提交的 `ui/src/ui/app-render.ts` 与 `ui/src/ui/app-render.helpers.ts` 旧脏改动（main 一开始就有的），先和用户确认是否纳入或重置，再决定是否一并提交。

---

## 自检清单（执行人在最后再过一遍）

- [ ] 「+」按钮 hover 文案为「新对话」
- [ ] 点击「+」不产生 `sessions.create` 请求（开发者工具 Network 验证）
- [ ] 主区域进入欢迎页（chat-welcome）
- [ ] 首条消息发出后才在侧边栏出现新条目，且 label = 消息前 10 个代码点
- [ ] 创建失败时输入保留，且顶部 lastError 显示
- [ ] 草稿态切到其他 session → 草稿输入与附件被丢弃
- [ ] URL 不出现 `__draft__`
- [ ] 既有所有测试全 PASS

## 后续可选

- 在 spec 列为非目标但用户可能反馈的需求：草稿态在侧边栏顶部出现「新对话…」占位项的 UI；可在后续 PR 引入，不在本 plan 范围。
- 进一步把 `enterChatDraft` 暴露到 `app.ts` 的 `state.onSlashAction("new-session")` 链路，让 `/new` 命令也走草稿态；同上，单独 PR。
