From 9bbc49f760b21603b69109fd758e6b8bc63f24c9 Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 15:01:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(sidepanel):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=BC=80=20tab=20=E8=A2=AB=E8=AF=AF=E8=AF=86=E4=B8=BA=20AI=20?= =?UTF-8?q?=E5=BC=80=20=E2=80=94=20=E6=94=B6=E7=B4=A7=E5=88=B0=201500ms=20?= =?UTF-8?q?tool=20=E6=B4=BB=E8=B7=83=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.0.14 的修复只 gate 在 status ∈ {running, streaming},widget 上线 后问题回归:widget 与 sidepanel 各有 zustand 实例,widget 在跑时 broadcast status="streaming" 也同步到 sidepanel;此时用户在 session tab 上 Ctrl+click 链接 → tabs.spawned 带 openerTabId → cross-tab-events 看到 streaming → 错误归给 AI ("AI 在 #200 打开了...")。 真信号:AI 的 openTab 用 chrome.tabs.create 不带 opener,唯一会带 openerTabId 的 AI 行为是 click 工具打 target=_blank 的链接 — 发生 在 tool_running 后毫秒级内。 修: - SessionData 加 _lastToolRunningAt 时间戳 - setStatus(tabId, "running") 顺带盖 _lastToolRunningAt = Date.now() - cross-tab-events 的 tabs.spawned gate 改成 now - _lastToolRunningAt < 1500ms;更严格,streaming 但无近期工具活跃时 = 用户开的 新增回归 test:streaming + stale _lastToolRunningAt(5s前)不归 AI --- .../src/sidepanel/chat/cross-tab-events.ts | 18 ++++++--- .../src/sidepanel/chat/session-store.ts | 15 +++++++- .../sidepanel/chat/cross-tab-events.test.ts | 37 ++++++++++++++++++- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/extension/src/sidepanel/chat/cross-tab-events.ts b/packages/extension/src/sidepanel/chat/cross-tab-events.ts index 5832d4d..b5e69c1 100644 --- a/packages/extension/src/sidepanel/chat/cross-tab-events.ts +++ b/packages/extension/src/sidepanel/chat/cross-tab-events.ts @@ -18,11 +18,19 @@ export function handleTabEvent(ev: TabEvent): void { sid === ev.openerTabId || s.attachedTabs.some((a) => a.tabId === ev.openerTabId); if (!owns) continue; - // Only attribute opener-matched spawns to AI when the session is - // actively running. Otherwise the user opened the tab manually - // (Ctrl/middle/right-click on a link in the session tab), and - // chrome.tabs.create from the openTab tool doesn't set openerTabId. - if (s.status !== "running" && s.status !== "streaming") continue; + // Only attribute opener-matched spawns to AI when a tool JUST ran + // (within the last 1500ms). The prior `status ∈ {running, streaming}` + // gate was too loose — during a widget/sidepanel run the sidepanel + // saw broadcast status="streaming" even while the AI was quiescent + // between rounds, so a user Ctrl+click on the page got misattributed. + // + // AI's `openTab` tool uses `chrome.tabs.create` (no opener). The only + // AI-caused spawn with openerTabId is a `click` tool hitting a + // target=_blank link — which fires within milliseconds of tool_running. + const now = Date.now(); + const recentAi = + s._lastToolRunningAt != null && now - s._lastToolRunningAt < 1500; + if (!recentAi) continue; attachTab(sid, { tabId: ev.tabId, windowId: ev.windowId, diff --git a/packages/extension/src/sidepanel/chat/session-store.ts b/packages/extension/src/sidepanel/chat/session-store.ts index ace6b7c..aac785f 100644 --- a/packages/extension/src/sidepanel/chat/session-store.ts +++ b/packages/extension/src/sidepanel/chat/session-store.ts @@ -97,6 +97,12 @@ export type SessionData = { chatMode: "compact" | "full"; /** 广播冲突仲裁字段;每次 mutation 后自增(Task 7)。初始 0。 */ _rev: number; + /** + * 最近一次 tool_running 事件的时间戳(Date.now())。cross-tab-events + * 用来判定新 spawn 的 tab 是否 AI 打开的:只有窗口 <1500ms 内有过 + * tool_running 才归 AI,否则一律视为用户 Ctrl+click。 + */ + _lastToolRunningAt?: number; }; export function makeEmptySession(tabId: number, url = ""): SessionData { @@ -339,7 +345,14 @@ export function addLlmExchange(tabId: number, ex: LlmExchange): void { } export function setStatus(tabId: number, status: SessionStatus): void { - mutateSession(tabId, (s) => ({ ...s, status })); + mutateSession(tabId, (s) => ({ + ...s, + status, + // Stamp AI-activity time when a tool starts running. cross-tab-events + // uses this to distinguish AI-opened tabs from user Ctrl+click (both + // arrive with `openerTabId` set;only recent AI activity attributes AI). + ...(status === "running" ? { _lastToolRunningAt: Date.now() } : {}), + })); } export function setError(tabId: number, errorMessage: string | null): void { diff --git a/packages/extension/tests/sidepanel/chat/cross-tab-events.test.ts b/packages/extension/tests/sidepanel/chat/cross-tab-events.test.ts index d7e1110..8319c9c 100644 --- a/packages/extension/tests/sidepanel/chat/cross-tab-events.test.ts +++ b/packages/extension/tests/sidepanel/chat/cross-tab-events.test.ts @@ -89,7 +89,9 @@ describe("handleTabEvent", () => { it("tabs.spawned auto-attaches to session whose attached tab is opener", () => { ensureSession(100, "https://main"); - setStatus(100, "streaming"); + // "running" stamps _lastToolRunningAt (recent tool activity), + // which is what actually gates AI-attribution now. + setStatus(100, "running"); attachTab(100, { tabId: 150, windowId: 1, source: "mention", lastSeenUrl: "u", lastSeenTitle: "t" }); @@ -105,6 +107,39 @@ describe("handleTabEvent", () => { expect(a.find((x) => x.tabId === 200)).toMatchObject({ source: "ai-open" }); }); + it("tabs.spawned during streaming (but no recent tool activity) is NOT attributed to AI", () => { + // Regression test for the widget-era misattribution: user Ctrl+click on a + // link during AI text-streaming (between tool runs) used to attach the + // new tab as `ai-open`. Now attribution requires a tool_running event + // within the last 1500ms. + ensureSession(100, "https://main"); + setCurrentTab(100); + // Simulate: AI ran a tool a while ago, now just streaming text. + useStore.setState((state) => ({ + ...state, + sessionsByTab: { + ...state.sessionsByTab, + 100: { + ...state.sessionsByTab[100], + status: "streaming", + _lastToolRunningAt: Date.now() - 5000, // 5 s ago — stale + messages: [{ role: "user", content: "hi" }] + } + } + })); + handleTabEvent({ + type: "tabs.spawned", + tabId: 200, + openerTabId: 100, + windowId: 1, + url: "https://child", + title: "Child" + }); + expect(getSessionFor(100).attachedTabs).toEqual([]); + const last = getSessionFor(100).messages.at(-1); + expect(JSON.stringify(last)).not.toMatch(/AI 在 #200/); + }); + it("tabs.urlChanged on an attached tab sets urlChanged", () => { ensureSession(100, "https://main"); attachTab(100, { From 747d94cc8340ec56d49c72905fb936b6d45a873c Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 15:05:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(widget):=20=E6=96=B0=E5=BB=BA=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=8C=89=E9=92=AE=20+=20=E6=8A=BD=20newChatForTab=20?= =?UTF-8?q?=E5=85=B1=E7=94=A8=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:widget 里没入口新建会话,只能去 sidepanel。 - 新增 sidepanel/chat/new-chat.ts:newChatForTab(tabId) 封装 flush → archive → prune → cascade delete → startNewSession → clear persist,和 sidepanel header 的 [新建] 走同一条路径 - app-shell.tsx onNewChat 改为 dynamic import 共用 helper - widget Panel header 增加 MessageSquarePlus 按钮: - 会话有内容时 window.confirm 二次确认(避免误删) - 复用 newChatForTab + 清 Panel 本地 input state --- .../extension/src/content/widget/panel.tsx | 23 ++++++++++++- .../extension/src/sidepanel/chat/new-chat.ts | 32 +++++++++++++++++++ .../src/sidepanel/shell/app-shell.tsx | 20 ++---------- 3 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 packages/extension/src/sidepanel/chat/new-chat.ts diff --git a/packages/extension/src/content/widget/panel.tsx b/packages/extension/src/content/widget/panel.tsx index c48cc04..e312ea3 100644 --- a/packages/extension/src/content/widget/panel.tsx +++ b/packages/extension/src/content/widget/panel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { X, Minus, ExternalLink } from "lucide-react"; +import { X, Minus, ExternalLink, MessageSquarePlus } from "lucide-react"; import { ChatView } from "@/sidepanel/components/chat-view"; import { EmptySuggestions } from "@/sidepanel/chat/empty-suggestions"; import { InputBox } from "@/sidepanel/input/input-box"; @@ -69,6 +69,20 @@ export function Panel({ onClose, onMinimize }: Props) { await rpc.widgetOpenSidepanel({ tabId }).catch(() => {}); } + async function handleNewChat() { + if (!tabId) return; + const hasContent = + session.messages.length > 0 || session.streamingAssistantText.length > 0; + if (hasContent && !window.confirm("新建对话会归档当前会话,确定?")) return; + try { + const { newChatForTab } = await import("@/sidepanel/chat/new-chat"); + await newChatForTab(tabId); + setInput(""); + } catch (e) { + console.warn("[atwebpilot-widget] newChat failed:", e); + } + } + const handleApprove = useCallback( ( id: string, @@ -103,6 +117,13 @@ export function Panel({ onClose, onMinimize }: Props) { {/* Header */}
⚡ AtWebPilot +