Selaa lähdekoodia

feat(codex): 空白会话防重,启动时清理历史空会话

用户反馈会话可以无限新建(没有聊天记录也能一直建)。新增 store.startNewSession 防重护栏:当前会话为空则不再新建;列表里已有空会话则直接切换过去;都没有才真正创建——保证任意时刻至多一个空会话。侧栏「新建会话」改走该入口,未新建时提示「已有空白会话,直接输入问题即可」。

另在 bootstrap 中新增 pruneEmptySessions:历史版本遗留的空会话在启动时自动清理(只保留最新一个,从本机列表移除,不碰 Codex 侧落盘历史)。send 的 ensureSession 懒建路径不受影响。

验证:pnpm type:check 89 条基线 / 本包 0 报错;pnpm build exit=0。
cc 2 viikkoa sitten
vanhempi
commit
137a2e01c7

+ 15 - 0
.workbuddy/memory/2026-09-24.md

@@ -262,3 +262,18 @@ composer 的 `cwd` prop 仅用于「切会话清空草稿」的 watch,不渲
 - **顶栏**:右上角「新建会话」按钮删除,emit `'create'` 一并移除;视图里工具栏的 `@create` 解绑。
   侧栏按钮成为唯一入口。
 验证:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0。
+
+## 会话防重:不允许堆空白会话
+
+用户反馈「会话可以一直建,没有聊天记录也能一直建」。优化规则(store 新增 `startNewSession`):
+1. 当前会话是空的(`isSessionEmpty` = 无提问无回复)→ 不再新建,提示「已有空白会话,直接输入问题即可」(info);
+2. 列表里已有空会话 → 直接切过去,不新建;
+3. 都没有才真正 `createSession`。
+——保证任意时刻**至多存在一个空会话**。侧栏「新建会话」改走 `startNewSession`;
+`send()` 的 `ensureSession` 仍直接 `createSession`(首条消息的懒建是合法路径,不受护栏影响)。
+
+**启动清账**:`bootstrap` 里新增 `pruneEmptySessions()` —— 历史版本允许无限建,
+存量的一堆空会话在下次打开页面时自动清理(只保留最新一个,其余从列表移除,
+不碰 Codex 侧落盘历史,与 removeSession 同一原则)。
+
+验证:`pnpm type:check` 89 基线 / 本包 0 报错;`pnpm build` exit=0。

+ 6 - 2
ai-electron/frontend/src/codex/components/codexSessionSidebar.vue

@@ -104,9 +104,13 @@
     if (creating.value) return;
     creating.value = true;
     try {
-      const meta = await store.createSession();
+      const result = await store.startNewSession();
       emit('create');
-      showMessage('已创建会话', 'success');
+      // created=false 表示已有空白会话(没有真正新建),提示用户直接提问即可
+      showMessage(
+        result.created ? '已创建会话' : '已有空白会话,直接输入问题即可',
+        result.created ? 'success' : 'info',
+      );
     } catch (error: any) {
       showMessage(error?.message || '创建会话失败', 'error');
     } finally {

+ 52 - 0
ai-electron/frontend/src/codex/store/codexChatStore.ts

@@ -314,6 +314,8 @@ export const useCodexChatStore = defineStore('codex-chat', {
       this.runDefaults = readRunDefaults();
       this.board = readBoard();
       this.syncCaseContext();
+      // 历史版本允许无限建空会话,这里清一次账:空会话只保留最新的一个
+      this.pruneEmptySessions();
       const remembered = window.localStorage.getItem(ACTIVE_KEY) || '';
       this.activeThreadId = this.sessions.some((s) => s.threadId === remembered)
         ? remembered
@@ -523,6 +525,56 @@ export const useCodexChatStore = defineStore('codex-chat', {
     },
 
     /** 新建会话:立刻向主进程开一个 thread,拿到真实 cwd 后落进列表 */
+    /** 会话是否为空(还没有任何提问与回复) */
+    isSessionEmpty(threadId: string): boolean {
+      if (!threadId) return true;
+      return buildCodexMessages(this.events[threadId] ?? [], this.prompts[threadId] ?? []).length === 0;
+    },
+
+    /**
+     * 新建会话(带防重护栏)。
+     *
+     * 空会话没有任何保留价值,放任新建只会堆出一列「新会话」。
+     * 规则:当前会话是空的就不再新建;列表里已有空会话就切过去;都没有才真正创建。
+     * —— 保证任意时刻至多存在一个空会话。
+     */
+    async startNewSession(options?: {
+      cwd?: string | null;
+      model?: string | null;
+      runOptions?: Partial<CodexRunOptions>;
+    }): Promise<{ created: boolean; reason?: 'current-empty' | 'switched-empty'; meta?: CodexSessionMeta }> {
+      if (this.activeThreadId && this.isSessionEmpty(this.activeThreadId)) {
+        return { created: false, reason: 'current-empty' };
+      }
+      const empty = this.sessions.find((item) => this.isSessionEmpty(item.threadId));
+      if (empty) {
+        await this.selectSession(empty.threadId);
+        return { created: false, reason: 'switched-empty' };
+      }
+      const meta = await this.createSession(options);
+      return { created: true, meta };
+    },
+
+    /**
+     * 清理历史遗留的空会话:只保留最新的一个,其余从列表移除
+     * (不碰 Codex 侧已落盘的历史,与 removeSession 同一原则)。
+     */
+    pruneEmptySessions() {
+      const empty = this.sessions.filter((item) => this.isSessionEmpty(item.threadId));
+      if (empty.length <= 1) return;
+      const keep = [...empty].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
+      const removed = empty.filter((item) => item.threadId !== keep.threadId);
+      this.sessions = this.sessions.filter((item) => !removed.some((r) => r.threadId === item.threadId));
+      removed.forEach((item) => {
+        delete this.events[item.threadId];
+        delete this.prompts[item.threadId];
+      });
+      if (removed.some((item) => item.threadId === this.activeThreadId)) {
+        this.activeThreadId = keep.threadId;
+      }
+      this.persistSessions();
+    },
+
     async createSession(options?: {
       cwd?: string | null;
       model?: string | null;