Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion packages/extension/src/content/widget/panel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -103,6 +117,13 @@ export function Panel({ onClose, onMinimize }: Props) {
{/* Header */}
<header className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 text-xs shrink-0">
<b className="flex-1 select-none">⚡ AtWebPilot</b>
<button
className="p-1 hover:bg-zinc-800 rounded"
title="新建对话"
onClick={handleNewChat}
>
<MessageSquarePlus size={14} />
</button>
<button
className="p-1 hover:bg-zinc-800 rounded"
title="打开扩展面板"
Expand Down
18 changes: 13 additions & 5 deletions packages/extension/src/sidepanel/chat/cross-tab-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions packages/extension/src/sidepanel/chat/new-chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* "新建对话" 共用流程。sidepanel header 的 [+] 按钮和 widget panel header
* 的 [+] 按钮都走这里,保证归档/清理路径一致。
*
* 1. flush 所有待落盘的 persist 队列(避免半写状态)
* 2. 当前 tab 若有 active session,archive 到 IDB(可从历史 drawer 恢复)
* 3. 该 URL 下的 archived 会话 pruning(每 URL ≤20)+ cascade 删对应 runs
* 4. startNewSession(tabId)
* 5. 清 auto-persist 的追踪状态
*
* 调用方自行清空自己的输入框 draft(sidepanel / widget 各持一份)。
*/
import { startNewSession } from "./session-store";
import {
archiveActive,
cascadeDeleteRuns,
getActiveByTabId,
pruneOverLimit,
} from "./persistence/sessions-storage";
import { flushAllPending, clearPersistStateFor } from "./persistence/auto-persist";

export async function newChatForTab(tabId: number): Promise<void> {
await flushAllPending();
const cur = await getActiveByTabId(tabId);
if (cur) {
await archiveActive(cur.id);
const evicted = await pruneOverLimit(cur.url);
if (evicted.length) await cascadeDeleteRuns(evicted);
}
startNewSession(tabId);
clearPersistStateFor(tabId);
}
15 changes: 14 additions & 1 deletion packages/extension/src/sidepanel/chat/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 3 additions & 17 deletions packages/extension/src/sidepanel/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,11 @@ import {
setPermissionMode,
setChatMode,
setDebugBadge,
startNewSession,
useCurrentTabId,
useSession,
useStore,
} from "@/sidepanel/chat/session-store";
import {
archiveActive,
cascadeDeleteRuns,
getActiveByTabId,
pruneOverLimit,
} from "@/sidepanel/chat/persistence/sessions-storage";
import { flushAllPending, clearPersistStateFor } from "@/sidepanel/chat/persistence/auto-persist";
import { getActiveByTabId } from "@/sidepanel/chat/persistence/sessions-storage";
import { handleTabEvent } from "@/sidepanel/chat/cross-tab-events";
import { useSettings, installSettingsSyncListener } from "@/sidepanel/chat/settings-store";
import { useUi } from "@/sidepanel/chat/ui-store";
Expand Down Expand Up @@ -645,15 +638,8 @@ export function AppShell() {
async function onNewChat() {
const tabId = useStore.getState().currentTabId;
if (tabId == null) return;
await flushAllPending();
const cur = await getActiveByTabId(tabId);
if (cur) {
await archiveActive(cur.id);
const evicted = await pruneOverLimit(cur.url);
if (evicted.length) await cascadeDeleteRuns(evicted);
}
startNewSession(tabId);
clearPersistStateFor(tabId);
const { newChatForTab } = await import("@/sidepanel/chat/new-chat");
await newChatForTab(tabId);
setInput("");
}

Expand Down
37 changes: 36 additions & 1 deletion packages/extension/tests/sidepanel/chat/cross-tab-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
});
Expand All @@ -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, {
Expand Down
Loading