From cff4d12f23c8f9ab49f4b1d7311cdbe6f4308c43 Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 15:57:40 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20StepCardState.=5FrunningStartAt=20+?= =?UTF-8?q?=20widget.openSidepanelWithSave=20RPC=20+=20pendingSave=20?= =?UTF-8?q?=E4=B8=AD=E7=BB=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extension/src/background/rpc-handlers.ts | 7 ++++ .../src/sidepanel/chat/session-store.ts | 25 ++++++++---- packages/extension/src/sidepanel/rpc.ts | 2 + .../src/sidepanel/shell/app-shell.tsx | 17 ++++++++ .../tests/background/widget-save-rpc.test.ts | 36 +++++++++++++++++ .../session-store-running-start-at.test.ts | 39 +++++++++++++++++++ packages/shared/src/messages.ts | 4 ++ 7 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 packages/extension/tests/background/widget-save-rpc.test.ts create mode 100644 packages/extension/tests/sidepanel/chat/session-store-running-start-at.test.ts diff --git a/packages/extension/src/background/rpc-handlers.ts b/packages/extension/src/background/rpc-handlers.ts index 86a2996..5771bf9 100644 --- a/packages/extension/src/background/rpc-handlers.ts +++ b/packages/extension/src/background/rpc-handlers.ts @@ -162,6 +162,13 @@ export async function dispatch(req: RpcRequest): Promise { } return null; } + case "widget.openSidepanelWithSave": { + await chrome.sidePanel.open({ tabId: req.tabId }); + await chrome.storage.session.set({ + "caiji.pendingSave": { tabId: req.tabId, ts: Date.now() } + }); + return null; + } case "widget.markHostHidden": { const KEY = "caiji.widget.hiddenHosts"; const raw = (await chrome.storage.local.get([KEY]))[KEY]; diff --git a/packages/extension/src/sidepanel/chat/session-store.ts b/packages/extension/src/sidepanel/chat/session-store.ts index aac785f..70f1f90 100644 --- a/packages/extension/src/sidepanel/chat/session-store.ts +++ b/packages/extension/src/sidepanel/chat/session-store.ts @@ -43,6 +43,8 @@ export type StepCardState = { output?: Json; error?: string; ms?: number; + /** Widget round 2:tool 进入 running 时的时间戳 (Date.now())。widget 状态条计时器用。 */ + _runningStartAt?: number; }; export type LogEntry = { @@ -280,15 +282,22 @@ export function upsertCard( export function setCardStatus( tabId: number, toolUseId: string, - patch: Partial> + patch: Partial> ): void { - mutateSession(tabId, (s) => { - const idx = s.cards.findIndex((c) => c.toolUseId === toolUseId); - if (idx === -1) return s; - const cards = s.cards.slice(); - cards[idx] = { ...cards[idx], ...patch }; - return { ...s, cards }; - }); + mutateSession(tabId, (s) => ({ + ...s, + cards: s.cards.map((c) => + c.toolUseId === toolUseId + ? { + ...c, + ...patch, + ...(patch.status === "running" && c.status !== "running" + ? { _runningStartAt: Date.now() } + : {}), + } + : c + ), + })); } export function appendToolResults( diff --git a/packages/extension/src/sidepanel/rpc.ts b/packages/extension/src/sidepanel/rpc.ts index 14caccf..0cbfcb7 100644 --- a/packages/extension/src/sidepanel/rpc.ts +++ b/packages/extension/src/sidepanel/rpc.ts @@ -96,6 +96,8 @@ export const rpc = { // widget RPCs widgetOpenSidepanel: (input: { tabId: number; pendingApprovalId?: string }) => call({ type: "widget.openSidepanel", ...input }), + widgetOpenSidepanelWithSave: (input: { tabId: number }) => + call({ type: "widget.openSidepanelWithSave", tabId: input.tabId }), widgetMarkHostHidden: (host: string) => call({ type: "widget.markHostHidden", host }), diff --git a/packages/extension/src/sidepanel/shell/app-shell.tsx b/packages/extension/src/sidepanel/shell/app-shell.tsx index 028980f..d6f84fe 100644 --- a/packages/extension/src/sidepanel/shell/app-shell.tsx +++ b/packages/extension/src/sidepanel/shell/app-shell.tsx @@ -23,6 +23,7 @@ import { setPermissionMode, setChatMode, setDebugBadge, + showSave, useCurrentTabId, useSession, useStore, @@ -160,6 +161,22 @@ export function AppShell() { }); }, []); + // Widget 通过 widget.openSidepanelWithSave 唤起本面板时,BG 会在 chrome.storage.session + // 存 caiji.pendingSave;这里读到就调 showSave(tabId) 弹保存对话框。 + useEffect(() => { + const KEY = "caiji.pendingSave"; + void chrome.storage.session.get([KEY]).then(async (res) => { + const p = (res as any)[KEY] as { tabId: number; ts: number } | undefined; + if (!p) return; + if (Date.now() - p.ts > 30_000) { + await chrome.storage.session.remove([KEY]); + return; + } + showSave(p.tabId); + await chrome.storage.session.remove([KEY]); + }); + }, []); + // Self-heal event listener: surface heal status as inline system notes in the chat thread useEffect(() => { function onHealEvent(msg: unknown) { diff --git a/packages/extension/tests/background/widget-save-rpc.test.ts b/packages/extension/tests/background/widget-save-rpc.test.ts new file mode 100644 index 0000000..0f3ea22 --- /dev/null +++ b/packages/extension/tests/background/widget-save-rpc.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +(globalThis as any).chrome = { + sidePanel: { open: vi.fn().mockResolvedValue(undefined) }, + storage: { + session: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined) + }, + local: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined) + } + }, + tabs: { get: vi.fn(), sendMessage: vi.fn(), onUpdated: { addListener: vi.fn() }, onRemoved: { addListener: vi.fn() }, onCreated: { addListener: vi.fn() } }, + webNavigation: { onHistoryStateUpdated: { addListener: vi.fn() } }, + runtime: { sendMessage: vi.fn(), onMessage: { addListener: vi.fn() } }, + action: { setBadgeText: vi.fn(), setBadgeBackgroundColor: vi.fn() }, + scripting: { executeScript: vi.fn() } +}; + +describe("widget.openSidepanelWithSave", () => { + beforeEach(() => vi.clearAllMocks()); + + it("opens sidepanel and stores pendingSave in session storage", async () => { + const { dispatch } = await import("@/background/rpc-handlers"); + await dispatch({ type: "widget.openSidepanelWithSave", tabId: 42 } as any); + expect(chrome.sidePanel.open).toHaveBeenCalledWith({ tabId: 42 }); + expect(chrome.storage.session.set).toHaveBeenCalledWith( + expect.objectContaining({ + "caiji.pendingSave": expect.objectContaining({ tabId: 42 }) + }) + ); + }); +}); diff --git a/packages/extension/tests/sidepanel/chat/session-store-running-start-at.test.ts b/packages/extension/tests/sidepanel/chat/session-store-running-start-at.test.ts new file mode 100644 index 0000000..ecc6290 --- /dev/null +++ b/packages/extension/tests/sidepanel/chat/session-store-running-start-at.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + ensureSession, upsertCard, setCardStatus, useStore +} from "@/sidepanel/chat/session-store"; + +(globalThis as any).chrome = { + runtime: { sendMessage: vi.fn() } +}; + +describe("setCardStatus stamps _runningStartAt on running", () => { + beforeEach(() => { + useStore.setState({ sessionsByTab: {}, currentTabId: null }); + }); + + it("stamps _runningStartAt when status transitions to running", () => { + ensureSession(1, "https://x/"); + upsertCard(1, { + toolUseId: "u1", name: "snapshotDOM", input: {} as any, + partialJson: "", inputReady: true, status: "awaiting" + }); + const before = Date.now(); + setCardStatus(1, "u1", { status: "running" }); + const after = Date.now(); + const c = useStore.getState().sessionsByTab[1].cards.find((x) => x.toolUseId === "u1")!; + expect(c._runningStartAt).toBeGreaterThanOrEqual(before); + expect(c._runningStartAt).toBeLessThanOrEqual(after); + }); + + it("does not stamp on non-running transitions", () => { + ensureSession(2, "https://y/"); + upsertCard(2, { + toolUseId: "u2", name: "click", input: {} as any, + partialJson: "", inputReady: true, status: "awaiting" + }); + setCardStatus(2, "u2", { status: "ok" }); + const c = useStore.getState().sessionsByTab[2].cards.find((x) => x.toolUseId === "u2")!; + expect(c._runningStartAt).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/messages.ts b/packages/shared/src/messages.ts index f06e231..3ad5c88 100644 --- a/packages/shared/src/messages.ts +++ b/packages/shared/src/messages.ts @@ -223,6 +223,10 @@ export const RpcRequest = z.discriminatedUnion("type", [ tabId: z.number().int(), pendingApprovalId: z.string().optional() }), + z.object({ + type: z.literal("widget.openSidepanelWithSave"), + tabId: z.number().int() + }), z.object({ type: z.literal("widget.markHostHidden"), host: z.string().min(1) From f716b5a6cd0d377b791bbd0634ceaacc08fc5754 Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 16:01:59 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(widget):=20=E7=8A=B6=E6=80=81=E6=9D=A1?= =?UTF-8?q?=20+=20=E9=94=99=E8=AF=AF=E6=9D=A1=20+=20=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/content/widget/error-banner.tsx | 25 +++++++ .../extension/src/content/widget/panel.tsx | 10 +++ .../src/content/widget/save-entry.tsx | 36 +++++++++ .../src/content/widget/status-bar.tsx | 63 ++++++++++++++++ .../content/widget/error-banner.test.tsx | 62 +++++++++++++++ .../tests/content/widget/save-entry.test.tsx | 75 +++++++++++++++++++ .../tests/content/widget/status-bar.test.tsx | 74 ++++++++++++++++++ 7 files changed, 345 insertions(+) create mode 100644 packages/extension/src/content/widget/error-banner.tsx create mode 100644 packages/extension/src/content/widget/save-entry.tsx create mode 100644 packages/extension/src/content/widget/status-bar.tsx create mode 100644 packages/extension/tests/content/widget/error-banner.test.tsx create mode 100644 packages/extension/tests/content/widget/save-entry.test.tsx create mode 100644 packages/extension/tests/content/widget/status-bar.test.tsx diff --git a/packages/extension/src/content/widget/error-banner.tsx b/packages/extension/src/content/widget/error-banner.tsx new file mode 100644 index 0000000..c630d82 --- /dev/null +++ b/packages/extension/src/content/widget/error-banner.tsx @@ -0,0 +1,25 @@ +import { X, AlertTriangle } from "lucide-react"; +import { setError } from "@/sidepanel/chat/session-store"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +type Props = { session: SessionData; tabId: number }; + +export function ErrorBanner({ session, tabId }: Props) { + if (!session.errorMessage) return null; + return ( +
+ + {session.errorMessage} + +
+ ); +} diff --git a/packages/extension/src/content/widget/panel.tsx b/packages/extension/src/content/widget/panel.tsx index e312ea3..1ba59ef 100644 --- a/packages/extension/src/content/widget/panel.tsx +++ b/packages/extension/src/content/widget/panel.tsx @@ -3,6 +3,9 @@ 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"; +import { StatusBar } from "./status-bar"; +import { ErrorBanner } from "./error-banner"; +import { SaveEntry } from "./save-entry"; import { useSession, appendUserMessage, @@ -147,6 +150,12 @@ export function Panel({ onClose, onMinimize }: Props) { + {/* Error banner (only when session.errorMessage exists) */} + + + {/* Sticky status bar (only when session non-idle) */} + + {/* Body */}
{session.messages.length === 0 && !isBusy ? ( @@ -161,6 +170,7 @@ export function Panel({ onClose, onMinimize }: Props) { ) : ( )} + {tabId != null && }
{/* Footer: token usage */} diff --git a/packages/extension/src/content/widget/save-entry.tsx b/packages/extension/src/content/widget/save-entry.tsx new file mode 100644 index 0000000..b3dca50 --- /dev/null +++ b/packages/extension/src/content/widget/save-entry.tsx @@ -0,0 +1,36 @@ +import { Save } from "lucide-react"; +import { rpc } from "@/sidepanel/rpc"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +type Props = { session: SessionData; tabId: number }; + +/** + * Chat body 尾部小条:执行完 N 步、状态 done 时露"保存为工具"入口。 + * 点击调 widget.openSidepanelWithSave RPC — BG 打开 sidepanel + 存 pendingSave, + * sidepanel focus effect 读到就调 showSave(tabId)。 + */ +export function SaveEntry({ session, tabId }: Props) { + const canSave = + session.executedSteps.length > 0 && session.status === "done"; + if (!canSave) return null; + async function onClick() { + await rpc.widgetOpenSidepanelWithSave({ tabId }).catch(() => {}); + } + return ( +
+ + + 已执行 {session.executedSteps.length} 步 + + +
+ ); +} diff --git a/packages/extension/src/content/widget/status-bar.tsx b/packages/extension/src/content/widget/status-bar.tsx new file mode 100644 index 0000000..99c9cda --- /dev/null +++ b/packages/extension/src/content/widget/status-bar.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { Wrench, Brain, PauseCircle } from "lucide-react"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +type Props = { session: SessionData }; + +/** + * Sticky 24px 状态条,session.status !== idle/done/aborted 时渲染。 + * - running: 🔧 {tool} · {elapsed}s ⟳(每 250ms 刷新) + * - streaming: 💭 AI 思考中... + * - awaiting: ⏸ 等待你确认下一步 + */ +export function StatusBar({ session }: Props) { + const [, forceTick] = useState(0); + + const runningCard = session.cards.find((c) => c.status === "running"); + const shouldTick = runningCard != null; + + useEffect(() => { + if (!shouldTick) return; + const id = setInterval(() => forceTick((n) => n + 1), 250); + return () => clearInterval(id); + }, [shouldTick]); + + const st = session.status; + if (st === "idle" || st === "done" || st === "aborted" || st === "error") return null; + + if (runningCard) { + const startedAt = runningCard._runningStartAt; + const secs = startedAt ? ((Date.now() - startedAt) / 1000).toFixed(1) : "?"; + return ( +
+ + {runningCard.name} + · {secs}s + +
+ ); + } + if (st === "awaiting") { + return ( +
+ + 等待你确认下一步 +
+ ); + } + return ( +
+ + AI 思考中… +
+ ); +} diff --git a/packages/extension/tests/content/widget/error-banner.test.tsx b/packages/extension/tests/content/widget/error-banner.test.tsx new file mode 100644 index 0000000..7860e01 --- /dev/null +++ b/packages/extension/tests/content/widget/error-banner.test.tsx @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ErrorBanner } from "@/content/widget/error-banner"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +const mockSetError = vi.fn(); +vi.mock("@/sidepanel/chat/session-store", async (orig) => { + const actual = await orig(); + return { ...actual, setError: (tabId: number, msg: string | null) => mockSetError(tabId, msg) }; +}); + +function makeSession(patch: Partial): SessionData { + return { + tabId: 1, url: "", runRecordId: null, + messages: [], streamingAssistantText: "", cards: [], + status: "idle", errorMessage: null, roundCount: 0, + tokenUsage: { input: 0, output: 0 }, + executedSteps: [], lastOutput: null, showSaveDialog: false, + abortController: null, logs: [], logsOpen: false, + inputDraft: "", attachedTabs: [], llmExchanges: [], + permissionMode: "default", debugBadge: null, + chatMode: "compact", _rev: 0, + ...patch, + } as SessionData; +} + +describe("ErrorBanner", () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("renders nothing when errorMessage is null", async () => { + await act(async () => + root.render() + ); + expect(container.querySelector("[data-testid=widget-error-banner]")).toBeNull(); + }); + + it("shows message and calls setError(tabId, null) on close", async () => { + await act(async () => + root.render( + + ) + ); + const bar = container.querySelector("[data-testid=widget-error-banner]")!; + expect(bar.textContent).toContain("未配置 API Key"); + const closeBtn = bar.querySelector("button[aria-label='关闭错误提示']") as HTMLButtonElement; + await act(async () => closeBtn.click()); + expect(mockSetError).toHaveBeenCalledWith(42, null); + }); +}); diff --git a/packages/extension/tests/content/widget/save-entry.test.tsx b/packages/extension/tests/content/widget/save-entry.test.tsx new file mode 100644 index 0000000..3baf0a1 --- /dev/null +++ b/packages/extension/tests/content/widget/save-entry.test.tsx @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { SaveEntry } from "@/content/widget/save-entry"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +const mockOpenSidepanelWithSave = vi.fn().mockResolvedValue(null); +vi.mock("@/sidepanel/rpc", () => ({ + rpc: { widgetOpenSidepanelWithSave: (input: any) => mockOpenSidepanelWithSave(input) }, +})); + +function makeSession(patch: Partial): SessionData { + return { + tabId: 1, url: "", runRecordId: null, + messages: [], streamingAssistantText: "", cards: [], + status: "idle", errorMessage: null, roundCount: 0, + tokenUsage: { input: 0, output: 0 }, + executedSteps: [], lastOutput: null, showSaveDialog: false, + abortController: null, logs: [], logsOpen: false, + inputDraft: "", attachedTabs: [], llmExchanges: [], + permissionMode: "default", debugBadge: null, + chatMode: "compact", _rev: 0, + ...patch, + } as SessionData; +} + +describe("SaveEntry", () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("renders nothing when no executedSteps", async () => { + await act(async () => + root.render() + ); + expect(container.querySelector("[data-testid=widget-save-entry]")).toBeNull(); + }); + + it("renders nothing when status not done", async () => { + await act(async () => + root.render( + + ) + ); + expect(container.querySelector("[data-testid=widget-save-entry]")).toBeNull(); + }); + + it("shows entry when done + steps > 0 and calls RPC on click", async () => { + await act(async () => + root.render( + + ) + ); + const el = container.querySelector("[data-testid=widget-save-entry]")!; + expect(el.textContent).toContain("已执行 3 步"); + const btn = el.querySelector("button")! as HTMLButtonElement; + await act(async () => btn.click()); + expect(mockOpenSidepanelWithSave).toHaveBeenCalledWith({ tabId: 99 }); + }); +}); diff --git a/packages/extension/tests/content/widget/status-bar.test.tsx b/packages/extension/tests/content/widget/status-bar.test.tsx new file mode 100644 index 0000000..97abc93 --- /dev/null +++ b/packages/extension/tests/content/widget/status-bar.test.tsx @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { StatusBar } from "@/content/widget/status-bar"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +function makeSession(patch: Partial): SessionData { + return { + tabId: 1, url: "", runRecordId: null, + messages: [], streamingAssistantText: "", cards: [], + status: "idle", errorMessage: null, roundCount: 0, + tokenUsage: { input: 0, output: 0 }, + executedSteps: [], lastOutput: null, showSaveDialog: false, + abortController: null, logs: [], logsOpen: false, + inputDraft: "", attachedTabs: [], llmExchanges: [], + permissionMode: "default", debugBadge: null, + chatMode: "compact", _rev: 0, + ...patch, + } as SessionData; +} + +describe("StatusBar", () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("renders nothing on idle", async () => { + await act(async () => + root.render() + ); + expect(container.querySelector("[data-testid=widget-status-bar]")).toBeNull(); + }); + + it("shows running tool name + elapsed when a card is running", async () => { + const started = Date.now() - 2300; + const sess = makeSession({ + status: "running", + cards: [{ + toolUseId: "u1", name: "snapshotDOM", input: {} as any, + partialJson: "", inputReady: true, status: "running", + _runningStartAt: started, + }], + }); + await act(async () => root.render()); + const bar = container.querySelector("[data-testid=widget-status-bar]")!; + expect(bar.textContent).toContain("snapshotDOM"); + expect(bar.textContent).toMatch(/2\.\d+s/); + }); + + it("shows 思考中 on streaming without a running card", async () => { + await act(async () => + root.render() + ); + const bar = container.querySelector("[data-testid=widget-status-bar]")!; + expect(bar.textContent).toContain("AI 思考"); + }); + + it("shows 等待确认 on awaiting", async () => { + await act(async () => + root.render() + ); + const bar = container.querySelector("[data-testid=widget-status-bar]")!; + expect(bar.textContent).toContain("等待你确认"); + }); +}); From a67ed5fe91b27d75dd859a732b010574f96e550d Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 16:05:47 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(widget):=20=E7=A9=BA=E6=80=81=20preset?= =?UTF-8?q?=20chip=20+=20QuickActions=20=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/content/widget/empty-state.tsx | 44 +++++++++++ .../extension/src/content/widget/panel.tsx | 11 +-- .../tests/content/widget/empty-state.test.tsx | 78 +++++++++++++++++++ 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 packages/extension/src/content/widget/empty-state.tsx create mode 100644 packages/extension/tests/content/widget/empty-state.test.tsx diff --git a/packages/extension/src/content/widget/empty-state.tsx b/packages/extension/src/content/widget/empty-state.tsx new file mode 100644 index 0000000..b9af7a0 --- /dev/null +++ b/packages/extension/src/content/widget/empty-state.tsx @@ -0,0 +1,44 @@ +import { EmptySuggestions } from "@/sidepanel/chat/empty-suggestions"; +import { QuickActions } from "@/sidepanel/chat/quick-actions"; +import { matchPresetsByUrl } from "@atwebpilot/shared/match-presets"; +import type { Preset } from "@atwebpilot/shared/preset"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +type Props = { + session: SessionData; + onFillInput: (text: string) => void; +}; + +/** + * Widget 空态:URL 命中 preset 时展示 chip 卡片 + QuickActions 默认 3 条。 + * 点击任何一条 → 把对应文本塞进 input(不 auto-send)让用户可修改。 + */ +export function EmptyState({ session, onFillInput }: Props) { + const url = session.url; + const presets = url ? matchPresetsByUrl(url) : []; + + function onPresetPick(p: Preset) { + // tool-form preset:首版降级为让 AI 自主挑对应保存工具 + if (p.kind === "prompt") { + onFillInput(p.prompt); + } else { + onFillInput(`运行 preset "${p.name}"`); + } + } + + return ( +
+ {presets.length > 0 && ( + {}} + onDetail={() => {}} + presets={presets} + onPresetPick={onPresetPick} + /> + )} + +
告诉 AI 你想让它做什么
+
+ ); +} diff --git a/packages/extension/src/content/widget/panel.tsx b/packages/extension/src/content/widget/panel.tsx index 1ba59ef..f95037a 100644 --- a/packages/extension/src/content/widget/panel.tsx +++ b/packages/extension/src/content/widget/panel.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { X, Minus, ExternalLink, MessageSquarePlus } from "lucide-react"; import { ChatView } from "@/sidepanel/components/chat-view"; -import { EmptySuggestions } from "@/sidepanel/chat/empty-suggestions"; +import { EmptyState } from "./empty-state"; import { InputBox } from "@/sidepanel/input/input-box"; import { StatusBar } from "./status-bar"; import { ErrorBanner } from "./error-banner"; @@ -159,14 +159,7 @@ export function Panel({ onClose, onMinimize }: Props) { {/* Body */}
{session.messages.length === 0 && !isBusy ? ( -
- {}} - onDetail={() => {}} - presets={[]} - /> -
+ ) : ( )} diff --git a/packages/extension/tests/content/widget/empty-state.test.tsx b/packages/extension/tests/content/widget/empty-state.test.tsx new file mode 100644 index 0000000..3fe8582 --- /dev/null +++ b/packages/extension/tests/content/widget/empty-state.test.tsx @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { EmptyState } from "@/content/widget/empty-state"; +import type { SessionData } from "@/sidepanel/chat/session-store"; + +const mockMatch = vi.fn(); +vi.mock("@atwebpilot/shared/match-presets", () => ({ + matchPresetsByUrl: (url: string) => mockMatch(url), +})); + +function makeSession(url: string): SessionData { + return { + tabId: 1, url, runRecordId: null, + messages: [], streamingAssistantText: "", cards: [], + status: "idle", errorMessage: null, roundCount: 0, + tokenUsage: { input: 0, output: 0 }, + executedSteps: [], lastOutput: null, showSaveDialog: false, + abortController: null, logs: [], logsOpen: false, + inputDraft: "", attachedTabs: [], llmExchanges: [], + permissionMode: "default", debugBadge: null, + chatMode: "compact", _rev: 0, + } as SessionData; +} + +describe("EmptyState", () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("shows QuickActions when no presets match", async () => { + mockMatch.mockReturnValue([]); + await act(async () => + root.render( + {}} + /> + ) + ); + // QuickActions renders buttons (总结 / 抽重点 / 抽评论) + expect(container.textContent).toContain("告诉 AI 你想让它做什么"); + }); + + it("calls onFillInput with prompt when preset chip clicked", async () => { + mockMatch.mockReturnValue([ + { + id: "p1", name: "知乎摘要", description: "", category: "content", + urlPatterns: ["https://zhihu.com/**"], version: 1, + kind: "prompt", prompt: "总结这个问题下的高赞回答", + }, + ]); + let filled = ""; + await act(async () => + root.render( + (filled = t)} + /> + ) + ); + // find button labeled 知乎摘要 and click + const btns = Array.from(container.querySelectorAll("button")) as HTMLButtonElement[]; + const target = btns.find((b) => b.textContent?.includes("知乎摘要")); + expect(target).toBeTruthy(); + await act(async () => target!.click()); + expect(filled).toBe("总结这个问题下的高赞回答"); + }); +}); From 1bf06925019b2f6723e0fac5b0f9c312fb324013 Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 16:09:51 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(widget):=20input=20row=20=E2=80=94=20p?= =?UTF-8?q?ill=20+=20=E5=9B=BE=E7=89=87=E7=B2=98=E8=B4=B4=20+=20=E5=8F=91?= =?UTF-8?q?=E9=80=81/=E5=81=9C=E6=AD=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/content/widget/input-row.tsx | 95 ++++++++++++ .../extension/src/content/widget/panel.tsx | 43 ++++-- .../tests/content/widget/input-row.test.tsx | 137 ++++++++++++++++++ 3 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 packages/extension/src/content/widget/input-row.tsx create mode 100644 packages/extension/tests/content/widget/input-row.test.tsx diff --git a/packages/extension/src/content/widget/input-row.tsx b/packages/extension/src/content/widget/input-row.tsx new file mode 100644 index 0000000..c8d2139 --- /dev/null +++ b/packages/extension/src/content/widget/input-row.tsx @@ -0,0 +1,95 @@ +import { Send, Square } from "lucide-react"; +import { PermissionModePill } from "@/sidepanel/input/permission-mode-pill"; +import { StagedImages } from "@/sidepanel/components/staged-images"; +import { InputBox } from "@/sidepanel/input/input-box"; +import { fileToImagePart, MAX_IMAGE_BYTES, MAX_IMAGES_PER_TURN } from "@/sidepanel/lib/image-utils"; +import { setPermissionMode } from "@/sidepanel/chat/session-store"; +import { useSettings } from "@/sidepanel/chat/settings-store"; +import type { ImagePart } from "@atwebpilot/shared/types"; +import type { SessionData } from "@/sidepanel/chat/session-store"; +import type { PermissionMode } from "@/sidepanel/chat/severity"; + +type Props = { + session: SessionData; + tabId: number; + input: string; + onInputChange: (v: string) => void; + onSubmit: () => void; + onStop: () => void; + stagedImages: ImagePart[]; + onSetStagedImages: (imgs: ImagePart[]) => void; + disabled: boolean; + isBusy: boolean; +}; + +export function InputRow({ + session, tabId, input, onInputChange, + onSubmit, onStop, stagedImages, onSetStagedImages, + disabled, isBusy, +}: Props) { + const trustedDangerTools = useSettings((s) => s.trustedDangerTools); + const saveSettings = useSettings((s) => s.save); + + const canSend = !isBusy && (input.trim().length > 0 || stagedImages.length > 0); + + async function handleImageFiles(files: File[]) { + const room = Math.max(0, MAX_IMAGES_PER_TURN - stagedImages.length); + const accepted = files + .filter((f) => f.size <= MAX_IMAGE_BYTES) + .slice(0, room); + const parts = await Promise.all(accepted.map(fileToImagePart)); + onSetStagedImages([...stagedImages, ...parts]); + } + + return ( +
+ {/* Pill row */} +
+ setPermissionMode(tabId, m)} + trustedDangerTools={trustedDangerTools} + onTrustedChange={(next) => void saveSettings({ trustedDangerTools: next })} + /> +
+ {/* Staged images (renders null if empty) */} + onSetStagedImages(stagedImages.filter((_, i) => i !== idx))} + /> + {/* Input + send/stop */} +
+
+ +
+ {isBusy ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/packages/extension/src/content/widget/panel.tsx b/packages/extension/src/content/widget/panel.tsx index f95037a..f6be505 100644 --- a/packages/extension/src/content/widget/panel.tsx +++ b/packages/extension/src/content/widget/panel.tsx @@ -2,16 +2,18 @@ import { useCallback, useEffect, useState } from "react"; import { X, Minus, ExternalLink, MessageSquarePlus } from "lucide-react"; import { ChatView } from "@/sidepanel/components/chat-view"; import { EmptyState } from "./empty-state"; -import { InputBox } from "@/sidepanel/input/input-box"; +import { InputRow } from "./input-row"; import { StatusBar } from "./status-bar"; import { ErrorBanner } from "./error-banner"; import { SaveEntry } from "./save-entry"; import { useSession, appendUserMessage, + appendUserMessageWithImages, ensureSession, setCurrentTab, } from "@/sidepanel/chat/session-store"; +import type { ImagePart } from "@atwebpilot/shared/types"; import { getApproverForTab, broadcastApprovalDecision, @@ -31,6 +33,7 @@ export function Panel({ onClose, onMinimize }: Props) { const [size, setSize] = useState({ w: 320, h: 480 }); const [tabId, setTabId] = useState(null); const [input, setInput] = useState(""); + const [stagedImages, setStagedImages] = useState([]); const session = useSession(); const maxRounds = useSettings((s) => s.maxRounds); @@ -54,16 +57,28 @@ export function Panel({ onClose, onMinimize }: Props) { session.status === "awaiting" || session.status === "running"; + function handleStop() { + if (!tabId) return; + session.abortController?.abort(); + } + async function handleSubmit() { - if (!tabId || !input.trim() || isBusy) return; + if (!tabId) return; const text = input.trim(); - appendUserMessage(tabId, text); + if (!text && stagedImages.length === 0) return; + if (isBusy) return; + if (stagedImages.length > 0) { + appendUserMessageWithImages(tabId, text, stagedImages); + } else { + appendUserMessage(tabId, text); + } + setStagedImages([]); setInput(""); try { const { runFromInput } = await import("./run-widget-session"); await runFromInput(tabId, text); - } catch { - // Task 12 will implement the full run loop + } catch (e) { + console.warn("[atwebpilot-widget] runFromInput failed:", e); } } @@ -176,16 +191,20 @@ export function Panel({ onClose, onMinimize }: Props) { - {/* Input */} -
- -
+ )}
); } diff --git a/packages/extension/tests/content/widget/input-row.test.tsx b/packages/extension/tests/content/widget/input-row.test.tsx new file mode 100644 index 0000000..c59e3e4 --- /dev/null +++ b/packages/extension/tests/content/widget/input-row.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { InputRow } from "@/content/widget/input-row"; +import type { SessionData } from "@/sidepanel/chat/session-store"; +import type { ImagePart } from "@atwebpilot/shared/types"; + +vi.mock("@/sidepanel/chat/session-store", async (orig) => { + const actual = await orig(); + return { ...actual, setPermissionMode: vi.fn() }; +}); + +vi.mock("@/sidepanel/chat/settings-store", () => ({ + useSettings: (selector: any) => + selector({ trustedDangerTools: [], save: vi.fn().mockResolvedValue(undefined) }), +})); + +function makeSession(patch: Partial): SessionData { + return { + tabId: 1, url: "", runRecordId: null, + messages: [], streamingAssistantText: "", cards: [], + status: "idle", errorMessage: null, roundCount: 0, + tokenUsage: { input: 0, output: 0 }, + executedSteps: [], lastOutput: null, showSaveDialog: false, + abortController: null, logs: [], logsOpen: false, + inputDraft: "", attachedTabs: [], llmExchanges: [], + permissionMode: "default", debugBadge: null, + chatMode: "compact", _rev: 0, + ...patch, + } as SessionData; +} + +describe("InputRow", () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("shows send button when not busy", async () => { + await act(async () => + root.render( + {}} + onSubmit={() => {}} + onStop={() => {}} + stagedImages={[]} + onSetStagedImages={() => {}} + disabled={false} + isBusy={false} + /> + ) + ); + expect(container.querySelector("[data-testid=widget-send-btn]")).toBeTruthy(); + expect(container.querySelector("[data-testid=widget-stop-btn]")).toBeNull(); + }); + + it("shows stop button when busy and calls onStop on click", async () => { + const onStop = vi.fn(); + await act(async () => + root.render( + {}} + onSubmit={() => {}} + onStop={onStop} + stagedImages={[]} + onSetStagedImages={() => {}} + disabled={false} + isBusy={true} + /> + ) + ); + const stopBtn = container.querySelector("[data-testid=widget-stop-btn]") as HTMLButtonElement; + expect(stopBtn).toBeTruthy(); + await act(async () => stopBtn.click()); + expect(onStop).toHaveBeenCalled(); + }); + + it("renders staged images strip", async () => { + const img: ImagePart = { + type: "image", + media_type: "image/png", + data: "AAAA", + }; + await act(async () => + root.render( + {}} + onSubmit={() => {}} + onStop={() => {}} + stagedImages={[img]} + onSetStagedImages={() => {}} + disabled={false} + isBusy={false} + /> + ) + ); + expect(container.querySelector("[data-testid=staged-images]")).toBeTruthy(); + }); + + it("send button disabled when input empty and no images", async () => { + await act(async () => + root.render( + {}} + onSubmit={() => {}} + onStop={() => {}} + stagedImages={[]} + onSetStagedImages={() => {}} + disabled={false} + isBusy={false} + /> + ) + ); + const sendBtn = container.querySelector("[data-testid=widget-send-btn]") as HTMLButtonElement; + expect(sendBtn.disabled).toBe(true); + }); +}); From d475719793bb26d5ec2cc0f1bb06fb4226e3d458 Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 16:13:09 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(widget):=20=E5=A4=B4=E9=83=A8=20?= =?UTF-8?q?=E2=8C=96=20=E5=9C=88=E9=80=89=E6=8C=89=E9=92=AE=20+=20captureR?= =?UTF-8?q?esult=20=E5=A1=9E=E8=BF=9B=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/widget/element-capture-hook.ts | 35 +++++++++++ .../extension/src/content/widget/panel.tsx | 14 ++++- .../widget/element-capture-hook.test.tsx | 59 +++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/extension/src/content/widget/element-capture-hook.ts create mode 100644 packages/extension/tests/content/widget/element-capture-hook.test.tsx diff --git a/packages/extension/src/content/widget/element-capture-hook.ts b/packages/extension/src/content/widget/element-capture-hook.ts new file mode 100644 index 0000000..a34ee2e --- /dev/null +++ b/packages/extension/src/content/widget/element-capture-hook.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; + +/** + * Widget-side hook for element-capture flow: + * - startCapture() 触发页面进入圈选模式(content/element-capture.ts 监听) + * - 用户点选后 element-capture 发 atwebpilot.captureResult 消息 + * - 本 hook 挂 chrome.runtime.onMessage listener,收到就调 onSelector + */ +export function useElementCapture(onSelector: (selector: string) => void): { + startCapture: () => void; +} { + useEffect(() => { + function listener(msg: unknown) { + const m = msg as { type?: string; selector?: string } | null; + if (!m || m.type !== "atwebpilot.captureResult") return; + if (typeof m.selector === "string") onSelector(m.selector); + } + try { + chrome.runtime.onMessage.addListener(listener); + } catch { /* no chrome in test */ } + return () => { + try { + chrome.runtime.onMessage.removeListener(listener); + } catch { /* noop */ } + }; + }, [onSelector]); + + function startCapture(): void { + try { + chrome.runtime.sendMessage({ type: "atwebpilot.startCapture" }); + } catch { /* noop */ } + } + + return { startCapture }; +} diff --git a/packages/extension/src/content/widget/panel.tsx b/packages/extension/src/content/widget/panel.tsx index f6be505..80af123 100644 --- a/packages/extension/src/content/widget/panel.tsx +++ b/packages/extension/src/content/widget/panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { X, Minus, ExternalLink, MessageSquarePlus } from "lucide-react"; +import { Crosshair, X, Minus, ExternalLink, MessageSquarePlus } from "lucide-react"; +import { useElementCapture } from "./element-capture-hook"; import { ChatView } from "@/sidepanel/components/chat-view"; import { EmptyState } from "./empty-state"; import { InputRow } from "./input-row"; @@ -120,6 +121,10 @@ export function Panel({ onClose, onMinimize }: Props) { [tabId] ); + const { startCapture } = useElementCapture((selector) => { + setInput((prev) => (prev ? `${prev}\n\n针对元素 ${selector}:` : `针对元素 ${selector}:`)); + }); + return (
⚡ AtWebPilot +
); } diff --git a/packages/extension/src/content/widget/resize-handle.tsx b/packages/extension/src/content/widget/resize-handle.tsx new file mode 100644 index 0000000..b63d708 --- /dev/null +++ b/packages/extension/src/content/widget/resize-handle.tsx @@ -0,0 +1,62 @@ +import { useRef } from "react"; + +type Props = { + size: { w: number; h: number }; + onResize: (w: number, h: number) => void; + onCommit: (w: number, h: number) => void; + minW?: number; minH?: number; maxW?: number; maxH?: number; +}; + +function clamp(n: number, lo: number, hi: number) { + return Math.max(lo, Math.min(hi, n)); +} + +export function ResizeHandle({ + size, onResize, onCommit, + minW = 320, minH = 360, maxW = 720, maxH = 900, +}: Props) { + const dragRef = useRef<{ startX: number; startY: number; w0: number; h0: number } | null>(null); + const latestRef = useRef({ w: size.w, h: size.h }); + + function onPointerDown(e: React.PointerEvent) { + if (e.button !== 0) return; + dragRef.current = { startX: e.clientX, startY: e.clientY, w0: size.w, h0: size.h }; + latestRef.current = { w: size.w, h: size.h }; + (e.target as Element).setPointerCapture?.(e.pointerId); + e.preventDefault(); + e.stopPropagation(); + } + + function onPointerMove(e: React.PointerEvent) { + if (!dragRef.current) return; + const dx = e.clientX - dragRef.current.startX; + const dy = e.clientY - dragRef.current.startY; + // Widget is anchored at right:X, bottom:Y — dragging bottom-right corner + // means dx>0 SHRINKS width (panel is to the left of cursor). To match + // intuition, invert dx. + const w = clamp(dragRef.current.w0 - dx, minW, maxW); + const h = clamp(dragRef.current.h0 + dy, minH, maxH); + latestRef.current = { w, h }; + onResize(w, h); + } + + function onPointerUp() { + if (!dragRef.current) return; + dragRef.current = null; + onCommit(latestRef.current.w, latestRef.current.h); + } + + return ( +
+ ); +} diff --git a/packages/extension/tests/content/widget/resize-handle.test.tsx b/packages/extension/tests/content/widget/resize-handle.test.tsx new file mode 100644 index 0000000..3ec2498 --- /dev/null +++ b/packages/extension/tests/content/widget/resize-handle.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ResizeHandle } from "@/content/widget/resize-handle"; + +describe("ResizeHandle", () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("renders with correct testid", async () => { + await act(async () => + root.render( + {}} + onCommit={() => {}} + /> + ) + ); + expect(container.querySelector("[data-testid=widget-resize-handle]")).toBeTruthy(); + }); + + it("clamps to min/max", async () => { + const events: Array<{ w: number; h: number }> = []; + await act(async () => + root.render( + events.push({ w, h })} + onCommit={() => {}} + minW={320} minH={360} maxW={720} maxH={900} + /> + ) + ); + const el = container.querySelector("[data-testid=widget-resize-handle]") as HTMLElement; + // Simulate pointer down + move well beyond max + el.dispatchEvent(new PointerEvent("pointerdown", { button: 0, clientX: 500, clientY: 500 })); + el.dispatchEvent(new PointerEvent("pointermove", { clientX: 5000, clientY: 5000 })); + // The onResize should have been called with clamped values + // (dx > 0 shrinks; huge negative-effective width clamps to 320; huge h clamps to 900) + const last = events.at(-1); + if (last) { + expect(last.w).toBeGreaterThanOrEqual(320); + expect(last.w).toBeLessThanOrEqual(720); + expect(last.h).toBeLessThanOrEqual(900); + } + }); +}); From 828013f386464cfaaa8c610a0c30c16ff7203032 Mon Sep 17 00:00:00 2001 From: attson Date: Fri, 10 Jul 2026 16:19:58 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(widget):=20=E5=8E=86=E5=8F=B2=20mini?= =?UTF-8?q?=20drawer(=E6=9B=BF=E6=8D=A2=20body)+=20footer=20=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/content/widget/history-mode.tsx | 104 ++++++++++++++++++ .../extension/src/content/widget/panel.tsx | 33 +++++- .../content/widget/history-mode.test.tsx | 85 ++++++++++++++ 3 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 packages/extension/src/content/widget/history-mode.tsx create mode 100644 packages/extension/tests/content/widget/history-mode.test.tsx diff --git a/packages/extension/src/content/widget/history-mode.tsx b/packages/extension/src/content/widget/history-mode.tsx new file mode 100644 index 0000000..a1eb64f --- /dev/null +++ b/packages/extension/src/content/widget/history-mode.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import { Clock } from "lucide-react"; +import { + listArchivedByUrl, restoreArchived, +} from "@/sidepanel/chat/persistence/sessions-storage"; + +type ArchivedRow = { + id: string; + url: string; + updatedAt: number; + messageCount: number; + stepCount: number; + status: string; + title: string; +}; + +type Props = { + url: string; + tabId: number; + onBack: () => void; +}; + +function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, n - 1) + "…"; +} + +function relativeTime(ts: number): string { + const diffMs = Date.now() - ts; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return `${diffSec}s 前`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m 前`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h 前`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d 前`; +} + +export function HistoryMode({ url, tabId, onBack }: Props) { + const [rows, setRows] = useState(null); + + useEffect(() => { + let cancelled = false; + listArchivedByUrl(url).then((list) => { + if (cancelled) return; + const mapped: ArchivedRow[] = list.map((s) => { + const data = (s.data ?? {}) as { messages?: any[]; executedSteps?: any[]; status?: string }; + const msgs = data.messages ?? []; + const firstUser = msgs.find((m: any) => m.role === "user"); + const firstText = typeof firstUser?.content === "string" + ? firstUser.content + : (firstUser?.content?.find?.((p: any) => p.type === "text")?.text ?? ""); + return { + id: s.id, + url: s.url, + updatedAt: s.updatedAt ?? s.createdAt ?? 0, + messageCount: msgs.length, + stepCount: (data.executedSteps ?? []).length, + status: data.status ?? "unknown", + title: firstText ? truncate(firstText, 30) : "(无标题)", + }; + }).sort((a, b) => b.updatedAt - a.updatedAt); + setRows(mapped); + }).catch(() => setRows([])); + return () => { cancelled = true; }; + }, [url]); + + async function onRestore(id: string) { + await restoreArchived(id, tabId); + onBack(); + } + + return ( +
+
+ + 本 URL 历史对话({rows?.length ?? "…"}) +
+
+ {rows == null &&
加载中…
} + {rows && rows.length === 0 && ( +
此 URL 无历史会话
+ )} + {rows?.map((r) => ( + + ))} +
+
+ ); +} diff --git a/packages/extension/src/content/widget/panel.tsx b/packages/extension/src/content/widget/panel.tsx index ff0e10a..80d6027 100644 --- a/packages/extension/src/content/widget/panel.tsx +++ b/packages/extension/src/content/widget/panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { Crosshair, X, Minus, ExternalLink, MessageSquarePlus } from "lucide-react"; +import { Crosshair, X, Minus, ExternalLink, MessageSquarePlus, ArrowLeft, Clock } from "lucide-react"; +import { HistoryMode } from "./history-mode"; import { useElementCapture } from "./element-capture-hook"; import { ChatView } from "@/sidepanel/components/chat-view"; import { EmptyState } from "./empty-state"; @@ -36,6 +37,7 @@ export function Panel({ onClose, onMinimize }: Props) { const [tabId, setTabId] = useState(null); const [input, setInput] = useState(""); const [stagedImages, setStagedImages] = useState([]); + const [mode, setMode] = useState<"chat" | "history">("chat"); const session = useSession(); const maxRounds = useSettings((s) => s.maxRounds); @@ -140,6 +142,15 @@ export function Panel({ onClose, onMinimize }: Props) { > {/* Header */}
+ {mode === "history" && ( + + )} ⚡ AtWebPilot
- {/* Footer: token usage */} -