- {bubbleMessages.map((message) => (
+ {bubbleItems.map((item) => (
-
+
))}
diff --git a/apps/desktop/src/features/shell-ball/shellBall.bubble.ts b/apps/desktop/src/features/shell-ball/shellBall.bubble.ts
index 62aee71b5..43f051a85 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.bubble.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.bubble.ts
@@ -1,20 +1,14 @@
export type {
ShellBallBubbleDesktopFreshnessHint,
- ShellBallBubbleDesktopMotionHint,
ShellBallBubbleDesktopLifecycleState,
+ ShellBallBubbleDesktopMotionHint,
ShellBallBubbleDesktopState,
ShellBallBubbleItem,
ShellBallBubbleRole,
- ShellBallLegacyBubbleMessage as ShellBallBubbleMessage,
} from "./shellBallBubbleDesktop";
export {
cloneShellBallBubbleDesktopState,
cloneShellBallBubbleItem,
cloneShellBallBubbleItems,
- cloneShellBallBubbleMessages,
- createLegacyShellBallBubbleMessage,
- createLegacyShellBallBubbleMessages,
- createShellBallBubbleItemFromLegacyMessage,
- createShellBallBubbleItemsFromLegacyMessages,
} from "./shellBallBubbleDesktop";
diff --git a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
index 9052f35a9..469d455d7 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
@@ -54,7 +54,7 @@ import {
getShellBallHelperWindowVisibility,
shellBallWindowSyncEvents,
} from "./shellBall.windowSync";
-import type { ShellBallBubbleItem, ShellBallBubbleMessage } from "./shellBall.bubble";
+import type { ShellBallBubbleItem } from "./shellBall.bubble";
import { cloneShellBallBubbleItems } from "./shellBall.bubble";
import {
SHELL_BALL_WINDOW_GAP_PX,
@@ -1178,6 +1178,7 @@ test("shell-ball bubble item contract wraps protocol payload and keeps desktop-o
assert.equal("role" in bubbleItem.bubble, false);
assert.equal("desktop" in bubbleItem.bubble, false);
assert.doesNotMatch(bubbleContractSource, /"pulse"/);
+ assert.doesNotMatch(bubbleContractSource, /ShellBallBubbleMessage/);
assert.deepEqual(createShellBallWindowSnapshot({
visualState: "idle",
@@ -2155,12 +2156,21 @@ test("shell-ball bubble zone keeps the latest message visible on feed updates",
RuntimeShellBallBubbleZone({
visualState: "processing",
- bubbleMessages: [
+ bubbleItems: [
{
- id: "msg-scroll-1",
+ bubble: {
+ bubble_id: "msg-scroll-1",
+ task_id: "task-scroll-1",
+ type: "status",
+ text: "Newest status.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:08:00.000Z",
+ },
role: "agent",
- text: "Newest status.",
- createdAt: "2026-04-11T10:08:00.000Z",
+ desktop: {
+ lifecycleState: "visible",
+ },
},
],
});
@@ -2170,17 +2180,26 @@ test("shell-ball bubble zone keeps the latest message visible on feed updates",
assert.equal(scrollElement.scrollTop, scrollElement.scrollHeight);
});
-test("shell-ball bubble window resolves bubble messages from the helper-window snapshot", () => {
+test("shell-ball bubble window resolves bubble items from the helper-window snapshot", () => {
const helperSnapshot = createShellBallWindowSnapshot({
visualState: "processing",
inputValue: "",
voicePreview: null,
- bubbleMessages: [
+ bubbleItems: [
{
- id: "msg-helper-1",
+ bubble: {
+ bubble_id: "msg-helper-1",
+ task_id: "task-helper-1",
+ type: "status",
+ text: "Drafting your update.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:04:00.000Z",
+ },
role: "agent",
- text: "Drafting your update.",
- createdAt: "2026-04-11T10:04:00.000Z",
+ desktop: {
+ lifecycleState: "visible",
+ },
},
],
});
@@ -2210,7 +2229,7 @@ test("shell-ball bubble window resolves bubble messages from the helper-window s
assert.deepEqual(capturedProps, {
visualState: "processing",
- bubbleMessages: helperSnapshot.bubbleMessages,
+ bubbleItems: helperSnapshot.bubbleItems,
});
});
@@ -2219,12 +2238,21 @@ test("shell-ball bubble window does not depend on only visualState to render its
visualState: "idle",
inputValue: "",
voicePreview: null,
- bubbleMessages: [
+ bubbleItems: [
{
- id: "msg-helper-2",
+ bubble: {
+ bubble_id: "msg-helper-2",
+ task_id: "task-helper-2",
+ type: "result",
+ text: "Open the dashboard.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:05:00.000Z",
+ },
role: "user",
- text: "Open the dashboard.",
- createdAt: "2026-04-11T10:05:00.000Z",
+ desktop: {
+ lifecycleState: "visible",
+ },
},
],
});
@@ -2254,7 +2282,7 @@ test("shell-ball bubble window does not depend on only visualState to render its
assert.deepEqual(capturedProps, {
visualState: "voice_locked",
- bubbleMessages: helperSnapshot.bubbleMessages,
+ bubbleItems: helperSnapshot.bubbleItems,
});
});
@@ -2262,20 +2290,38 @@ test("shell-ball bubble zone renders a real message list without placeholder chr
const markup = renderToStaticMarkup(
createElement(ShellBallBubbleZone, {
visualState: "processing",
- bubbleMessages: [
+ bubbleItems: [
{
- id: "msg-agent-1",
+ bubble: {
+ bubble_id: "msg-agent-1",
+ task_id: "task-agent-1",
+ type: "status",
+ text: "I found the latest dashboard status.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:06:00.000Z",
+ },
role: "agent",
- text: "I found the latest dashboard status.",
- createdAt: "2026-04-11T10:06:00.000Z",
+ desktop: {
+ lifecycleState: "visible",
+ },
},
{
- id: "msg-user-1",
+ bubble: {
+ bubble_id: "msg-user-1",
+ task_id: "task-user-1",
+ type: "result",
+ text: "Open it for me.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:06:05.000Z",
+ },
role: "user",
- text: "Open it for me.",
- createdAt: "2026-04-11T10:06:05.000Z",
+ desktop: {
+ lifecycleState: "visible",
+ },
},
- ] satisfies ShellBallBubbleMessage[],
+ ] satisfies ShellBallBubbleItem[],
}),
);
@@ -2302,16 +2348,25 @@ test("shell-ball bubble window styles stay transparent, faded, and motion-ready"
const markup = renderToStaticMarkup(
createElement(ShellBallBubbleZone, {
visualState: "processing",
- bubbleMessages: [
+ bubbleItems: [
{
- id: "msg-style-1",
+ bubble: {
+ bubble_id: "msg-style-1",
+ task_id: "task-style-1",
+ type: "status",
+ text: "Draft ready.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:07:00.000Z",
+ },
role: "agent",
- text: "Draft ready.",
- createdAt: "2026-04-11T10:07:00.000Z",
- freshnessHint: "fresh",
- motionHint: "settle",
+ desktop: {
+ lifecycleState: "visible",
+ freshnessHint: "fresh",
+ motionHint: "settle",
+ },
},
- ] satisfies ShellBallBubbleMessage[],
+ ] satisfies ShellBallBubbleItem[],
}),
);
diff --git a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
index c816091b0..2beaf42e8 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
@@ -1,9 +1,5 @@
-import {
- cloneShellBallBubbleItems,
- createLegacyShellBallBubbleMessages,
- createShellBallBubbleItemsFromLegacyMessages,
-} from "./shellBall.bubble";
-import type { ShellBallBubbleItem, ShellBallBubbleMessage } from "./shellBall.bubble";
+import { cloneShellBallBubbleItems } from "./shellBall.bubble";
+import type { ShellBallBubbleItem } from "./shellBall.bubble";
import type { ShellBallVoicePreview } from "./shellBall.interaction";
import { getShellBallInputBarMode } from "./shellBall.interaction";
import type { ShellBallInputBarMode, ShellBallVisualState } from "./shellBall.types";
@@ -33,7 +29,6 @@ export type ShellBallWindowSnapshot = {
inputValue: string;
voicePreview: ShellBallVoicePreview;
bubbleItems: ShellBallBubbleItem[];
- bubbleMessages?: ShellBallBubbleMessage[];
visibility: ShellBallHelperWindowVisibility;
};
@@ -88,29 +83,15 @@ export function createShellBallWindowSnapshot(input: {
inputValue: string;
voicePreview: ShellBallVoicePreview;
bubbleItems?: ShellBallBubbleItem[];
- bubbleMessages?: ShellBallBubbleMessage[];
}): ShellBallWindowSnapshot {
- const bubbleItems = cloneShellBallBubbleItems(
- input.bubbleItems ?? createShellBallBubbleItemsFromLegacyMessages(input.bubbleMessages ?? []),
- );
- const snapshot: ShellBallWindowSnapshot = {
+ return {
visualState: input.visualState,
inputBarMode: getShellBallInputBarMode(input.visualState),
inputValue: input.inputValue,
voicePreview: input.voicePreview,
- bubbleItems,
+ bubbleItems: cloneShellBallBubbleItems(input.bubbleItems ?? []),
visibility: getShellBallHelperWindowVisibility(input.visualState),
};
-
- Object.defineProperty(snapshot, "bubbleMessages", {
- enumerable: false,
- configurable: true,
- get() {
- return createLegacyShellBallBubbleMessages(bubbleItems);
- },
- });
-
- return snapshot;
}
export function createDefaultShellBallWindowSnapshot(): ShellBallWindowSnapshot {
diff --git a/apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts b/apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts
index 6ea5e594d..362817410 100644
--- a/apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts
+++ b/apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts
@@ -9,7 +9,6 @@ export type ShellBallBubbleDesktopMotionHint = "settle";
export type ShellBallBubbleDesktopLifecycleState = "visible" | "fading" | "hidden";
export type ShellBallBubbleDesktopState = {
- detachedWindowId?: string;
lifecycleState: ShellBallBubbleDesktopLifecycleState;
freshnessHint?: ShellBallBubbleDesktopFreshnessHint;
motionHint?: ShellBallBubbleDesktopMotionHint;
@@ -21,15 +20,6 @@ export type ShellBallBubbleItem = {
desktop: ShellBallBubbleDesktopState;
};
-export type ShellBallLegacyBubbleMessage = {
- id: string;
- role: ShellBallBubbleRole;
- text: string;
- createdAt: string;
- freshnessHint?: ShellBallBubbleDesktopFreshnessHint;
- motionHint?: ShellBallBubbleDesktopMotionHint;
-};
-
export function cloneShellBallBubbleDesktopState(state: ShellBallBubbleDesktopState): ShellBallBubbleDesktopState {
return { ...state };
}
@@ -45,50 +35,3 @@ export function cloneShellBallBubbleItem(item: ShellBallBubbleItem): ShellBallBu
export function cloneShellBallBubbleItems(items: ShellBallBubbleItem[]): ShellBallBubbleItem[] {
return items.map(cloneShellBallBubbleItem);
}
-
-export function createShellBallBubbleItemFromLegacyMessage(
- message: ShellBallLegacyBubbleMessage,
-): ShellBallBubbleItem {
- return {
- bubble: {
- bubble_id: message.id,
- task_id: "",
- type: "status",
- text: message.text,
- pinned: false,
- hidden: false,
- created_at: message.createdAt,
- },
- role: message.role,
- desktop: {
- lifecycleState: "visible",
- freshnessHint: message.freshnessHint,
- motionHint: message.motionHint,
- },
- };
-}
-
-export function createLegacyShellBallBubbleMessage(item: ShellBallBubbleItem): ShellBallLegacyBubbleMessage {
- return {
- id: item.bubble.bubble_id,
- role: item.role,
- text: item.bubble.text,
- createdAt: item.bubble.created_at,
- freshnessHint: item.desktop.freshnessHint,
- motionHint: item.desktop.motionHint,
- };
-}
-
-export function cloneShellBallBubbleMessages(messages: ShellBallLegacyBubbleMessage[]): ShellBallLegacyBubbleMessage[] {
- return messages.map((message) => ({ ...message }));
-}
-
-export function createShellBallBubbleItemsFromLegacyMessages(
- messages: ShellBallLegacyBubbleMessage[],
-): ShellBallBubbleItem[] {
- return messages.map(createShellBallBubbleItemFromLegacyMessage);
-}
-
-export function createLegacyShellBallBubbleMessages(items: ShellBallBubbleItem[]): ShellBallLegacyBubbleMessage[] {
- return items.map(createLegacyShellBallBubbleMessage);
-}
diff --git a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
index 944bfcf47..b9158db3f 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { shellBallWindowLabels } from "../../platform/shellBallWindowController";
-import type { ShellBallBubbleMessage } from "./shellBall.bubble";
+import type { ShellBallBubbleItem } from "./shellBall.bubble";
import type { ShellBallVoicePreview } from "./shellBall.interaction";
import type { ShellBallInputBarMode, ShellBallVisualState } from "./shellBall.types";
import {
@@ -34,20 +34,38 @@ type ShellBallHelperSnapshotInput = {
role: ShellBallHelperWindowRole;
};
-const SHELL_BALL_LOCAL_BUBBLE_MESSAGES: ShellBallBubbleMessage[] = [
+const SHELL_BALL_LOCAL_BUBBLE_ITEMS: ShellBallBubbleItem[] = [
{
- id: "shell-ball-local-agent-1",
+ bubble: {
+ bubble_id: "shell-ball-local-agent-1",
+ task_id: "",
+ type: "status",
+ text: "Drafting your update.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:04:00.000Z",
+ },
role: "agent",
- text: "Drafting your update.",
- createdAt: "2026-04-11T10:04:00.000Z",
+ desktop: {
+ lifecycleState: "visible",
+ },
},
{
- id: "shell-ball-local-user-1",
+ bubble: {
+ bubble_id: "shell-ball-local-user-1",
+ task_id: "",
+ type: "result",
+ text: "Open the dashboard.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:05:00.000Z",
+ },
role: "user",
- text: "Open the dashboard.",
- createdAt: "2026-04-11T10:05:00.000Z",
- freshnessHint: "fresh",
- motionHint: "settle",
+ desktop: {
+ lifecycleState: "visible",
+ freshnessHint: "fresh",
+ motionHint: "settle",
+ },
},
];
@@ -58,7 +76,7 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
visualState: input.visualState,
inputValue: input.inputValue,
voicePreview: input.voicePreview,
- bubbleMessages: SHELL_BALL_LOCAL_BUBBLE_MESSAGES,
+ bubbleItems: SHELL_BALL_LOCAL_BUBBLE_ITEMS,
}),
[input.inputValue, input.visualState, input.voicePreview],
);
From 6187a568f3223d34e2df269de31bd66a347096b0 Mon Sep 17 00:00:00 2001
From: gdemonc <1502689916@qq.com>
Date: Sat, 11 Apr 2026 19:35:13 +0800
Subject: [PATCH 13/19] feat(desktop-shell-ball): define bubble window
existence strategy
---
.../shell-ball/ShellBallBubbleWindow.tsx | 7 +-
.../shell-ball/shellBall.contract.test.ts | 94 +++++++++++++++++--
.../shell-ball/shellBall.windowSync.ts | 28 +++++-
.../shell-ball/useShellBallWindowMetrics.ts | 7 +-
4 files changed, 122 insertions(+), 14 deletions(-)
diff --git a/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx b/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx
index 5b9ced072..82c8908ff 100644
--- a/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx
+++ b/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx
@@ -1,4 +1,5 @@
import type { ShellBallVisualState } from "./shellBall.types";
+import { getShellBallVisibleBubbleItems } from "./shellBall.windowSync";
import { useShellBallHelperWindowSnapshot } from "./useShellBallCoordinator";
import { useShellBallWindowMetrics } from "./useShellBallWindowMetrics";
import { ShellBallBubbleZone } from "./components/ShellBallBubbleZone";
@@ -10,14 +11,16 @@ type ShellBallBubbleWindowProps = {
export function ShellBallBubbleWindow({ visualState }: ShellBallBubbleWindowProps) {
const snapshot = useShellBallHelperWindowSnapshot({ role: "bubble" });
const resolvedVisualState = visualState ?? snapshot.visualState;
+ const visibleBubbleItems = getShellBallVisibleBubbleItems(snapshot.bubbleItems);
const { rootRef } = useShellBallWindowMetrics({
role: "bubble",
- visible: snapshot.visibility.bubble,
+ visible: true,
+ clickThrough: snapshot.bubbleRegion.clickThrough,
});
return (
-
+
);
}
diff --git a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
index 469d455d7..16bc165cd 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
@@ -50,8 +50,10 @@ import {
resolveDashboardRoutePath,
} from "../dashboard/shared/dashboardRouteTargets";
import {
+ getShellBallBubbleRegionState,
createShellBallWindowSnapshot,
getShellBallHelperWindowVisibility,
+ getShellBallVisibleBubbleItems,
shellBallWindowSyncEvents,
} from "./shellBall.windowSync";
import type { ShellBallBubbleItem } from "./shellBall.bubble";
@@ -776,7 +778,7 @@ test("shell-ball helper windows avoid auto-focus behavior", () => {
assert.match(controllerSource, /setShellBallWindowFocusable\([^)]*focusable: boolean\)/);
assert.match(controllerSource, /setShellBallWindowIgnoreCursorEvents\([^)]*ignore: boolean\)/);
assert.match(metricsSource, /setShellBallWindowFocusable\(role, false\)/);
- assert.match(metricsSource, /setShellBallWindowIgnoreCursorEvents\(role, true\)/);
+ assert.match(metricsSource, /setShellBallWindowIgnoreCursorEvents\(role, clickThrough\)/);
assert.doesNotMatch(metricsSource, /setFocus\(\)/);
assert.doesNotMatch(inputBarSource, /focus\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(planSource, /focusable: false/);
@@ -1072,7 +1074,7 @@ test("shell-ball helper window sync maps visual states into visibility and snaps
});
assert.deepEqual(getShellBallHelperWindowVisibility("idle"), {
- bubble: false,
+ bubble: true,
input: false,
});
@@ -1130,6 +1132,11 @@ test("shell-ball helper window sync maps visual states into visibility and snaps
},
},
],
+ bubbleRegion: {
+ strategy: "persistent",
+ hasVisibleItems: true,
+ clickThrough: false,
+ },
visibility: {
bubble: true,
input: true,
@@ -1138,6 +1145,68 @@ test("shell-ball helper window sync maps visual states into visibility and snaps
);
});
+test("shell-ball bubble region existence strategy is explicit and item-driven", () => {
+ const bubbleItems: ShellBallBubbleItem[] = [
+ {
+ bubble: {
+ bubble_id: "bubble-visible",
+ task_id: "task-visible",
+ type: "status",
+ text: "Visible bubble",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:00:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "bubble-pinned",
+ task_id: "task-pinned",
+ type: "result",
+ text: "Pinned bubble",
+ pinned: true,
+ hidden: false,
+ created_at: "2026-04-11T10:01:00.000Z",
+ },
+ role: "user",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "bubble-hidden",
+ task_id: "task-hidden",
+ type: "status",
+ text: "Hidden bubble",
+ pinned: false,
+ hidden: true,
+ created_at: "2026-04-11T10:02:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "hidden",
+ },
+ },
+ ];
+
+ assert.deepEqual(getShellBallVisibleBubbleItems(bubbleItems).map((item) => item.bubble.bubble_id), ["bubble-visible"]);
+ assert.deepEqual(getShellBallBubbleRegionState(bubbleItems), {
+ strategy: "persistent",
+ hasVisibleItems: true,
+ clickThrough: false,
+ });
+ assert.deepEqual(getShellBallBubbleRegionState([]), {
+ strategy: "persistent",
+ hasVisibleItems: false,
+ clickThrough: true,
+ });
+});
+
test("shell-ball bubble item contract wraps protocol payload and keeps desktop-only state local", () => {
const bubbleContractSource = readFileSync(resolve(desktopRoot, "src/features/shell-ball/shellBall.bubble.ts"), "utf8");
const bubbleItem: ShellBallBubbleItem = {
@@ -2122,6 +2191,9 @@ test("shell-ball coordinator snapshots carry shell-ball-local bubble messages",
assert.ok(Array.isArray(snapshot.bubbleItems));
assert.ok(snapshot.bubbleItems.length > 0);
+ assert.equal(snapshot.bubbleRegion.strategy, "persistent");
+ assert.equal(snapshot.bubbleRegion.hasVisibleItems, true);
+ assert.equal(snapshot.bubbleRegion.clickThrough, false);
assert.equal(snapshot.bubbleItems.at(-1)?.bubble.created_at, "2026-04-11T10:05:00.000Z");
assert.equal(snapshot.bubbleItems.at(-1)?.desktop.freshnessHint, "fresh");
assert.equal(snapshot.bubbleItems.at(-1)?.desktop.motionHint, "settle");
@@ -2203,6 +2275,7 @@ test("shell-ball bubble window resolves bubble items from the helper-window snap
},
],
});
+ helperSnapshot.bubbleRegion = getShellBallBubbleRegionState(helperSnapshot.bubbleItems);
let capturedProps: Record
| null = null;
const { ShellBallBubbleWindow: RuntimeShellBallBubbleWindow } = withShellBallModuleRuntime("ShellBallBubbleWindow.tsx", {
@@ -2219,7 +2292,7 @@ test("shell-ball bubble window resolves bubble items from the helper-window snap
},
"./components/ShellBallBubbleZone": {
ShellBallBubbleZone(props: Record) {
- capturedProps = props;
+ capturedProps = { ...(capturedProps ?? {}), ...props };
return createElement("section", { className: "shell-ball-bubble-zone-stub" });
},
},
@@ -2229,7 +2302,7 @@ test("shell-ball bubble window resolves bubble items from the helper-window snap
assert.deepEqual(capturedProps, {
visualState: "processing",
- bubbleItems: helperSnapshot.bubbleItems,
+ bubbleItems: getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems),
});
});
@@ -2256,6 +2329,7 @@ test("shell-ball bubble window does not depend on only visualState to render its
},
],
});
+ helperSnapshot.bubbleRegion = getShellBallBubbleRegionState(helperSnapshot.bubbleItems);
let capturedProps: Record | null = null;
const { ShellBallBubbleWindow: RuntimeShellBallBubbleWindow } = withShellBallModuleRuntime("ShellBallBubbleWindow.tsx", {
@@ -2266,13 +2340,14 @@ test("shell-ball bubble window does not depend on only visualState to render its
},
},
"./useShellBallWindowMetrics": {
- useShellBallWindowMetrics() {
+ useShellBallWindowMetrics(input: Record) {
+ capturedProps = { ...(capturedProps ?? {}), metricsInput: input };
return { rootRef: null };
},
},
"./components/ShellBallBubbleZone": {
ShellBallBubbleZone(props: Record) {
- capturedProps = props;
+ capturedProps = { ...(capturedProps ?? {}), ...props };
return createElement("section", { className: "shell-ball-bubble-zone-stub" });
},
},
@@ -2282,7 +2357,12 @@ test("shell-ball bubble window does not depend on only visualState to render its
assert.deepEqual(capturedProps, {
visualState: "voice_locked",
- bubbleItems: helperSnapshot.bubbleItems,
+ bubbleItems: getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems),
+ metricsInput: {
+ role: "bubble",
+ visible: true,
+ clickThrough: helperSnapshot.bubbleRegion.clickThrough,
+ },
});
});
diff --git a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
index 2beaf42e8..5a7868466 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
@@ -23,12 +23,19 @@ export type ShellBallHelperWindowVisibility = {
input: boolean;
};
+export type ShellBallBubbleRegionState = {
+ strategy: "persistent";
+ hasVisibleItems: boolean;
+ clickThrough: boolean;
+};
+
export type ShellBallWindowSnapshot = {
visualState: ShellBallVisualState;
inputBarMode: ShellBallInputBarMode;
inputValue: string;
voicePreview: ShellBallVoicePreview;
bubbleItems: ShellBallBubbleItem[];
+ bubbleRegion: ShellBallBubbleRegionState;
visibility: ShellBallHelperWindowVisibility;
};
@@ -73,23 +80,40 @@ export function getShellBallHelperWindowVisibility(
visualState: ShellBallVisualState,
): ShellBallHelperWindowVisibility {
return {
- bubble: visualState !== "idle",
+ bubble: true,
input: getShellBallInputBarMode(visualState) !== "hidden",
};
}
+export function getShellBallVisibleBubbleItems(items: ShellBallBubbleItem[]): ShellBallBubbleItem[] {
+ return items.filter((item) => item.bubble.hidden === false && item.bubble.pinned === false);
+}
+
+export function getShellBallBubbleRegionState(items: ShellBallBubbleItem[]): ShellBallBubbleRegionState {
+ const visibleItems = getShellBallVisibleBubbleItems(items);
+
+ return {
+ strategy: "persistent",
+ hasVisibleItems: visibleItems.length > 0,
+ clickThrough: visibleItems.length === 0,
+ };
+}
+
export function createShellBallWindowSnapshot(input: {
visualState: ShellBallVisualState;
inputValue: string;
voicePreview: ShellBallVoicePreview;
bubbleItems?: ShellBallBubbleItem[];
}): ShellBallWindowSnapshot {
+ const bubbleItems = cloneShellBallBubbleItems(input.bubbleItems ?? []);
+
return {
visualState: input.visualState,
inputBarMode: getShellBallInputBarMode(input.visualState),
inputValue: input.inputValue,
voicePreview: input.voicePreview,
- bubbleItems: cloneShellBallBubbleItems(input.bubbleItems ?? []),
+ bubbleItems,
+ bubbleRegion: getShellBallBubbleRegionState(bubbleItems),
visibility: getShellBallHelperWindowVisibility(input.visualState),
};
}
diff --git a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
index 9dcc665b6..90228e871 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
@@ -50,6 +50,7 @@ type ShellBallWindowBounds = {
type UseShellBallWindowMetricsInput = {
role: "ball" | ShellBallHelperWindowRole;
visible?: boolean;
+ clickThrough?: boolean;
};
export function createShellBallWindowFrame(
@@ -132,7 +133,7 @@ function getShellBallBoundsFromMonitor(monitor: Monitor | null, geometry: ShellB
};
}
-export function useShellBallWindowMetrics({ role, visible = true }: UseShellBallWindowMetricsInput) {
+export function useShellBallWindowMetrics({ role, visible = true, clickThrough = false }: UseShellBallWindowMetricsInput) {
const rootRef = useRef(null);
const [windowFrame, setWindowFrame] = useState(null);
const geometryRef = useRef(null);
@@ -262,7 +263,7 @@ export function useShellBallWindowMetrics({ role, visible = true }: UseShellBall
if (role === "bubble") {
void setShellBallWindowFocusable(role, false);
- void setShellBallWindowIgnoreCursorEvents(role, true);
+ void setShellBallWindowIgnoreCursorEvents(role, clickThrough);
}
let cleanup: (() => void) | null = null;
@@ -325,7 +326,7 @@ export function useShellBallWindowMetrics({ role, visible = true }: UseShellBall
disposed = true;
cleanup?.();
};
- }, [role, visible, windowFrame]);
+ }, [clickThrough, role, visible, windowFrame]);
useEffect(() => {
if (role === "ball") {
From ccdb9263bd2adf0df535b434fd9959bab331aa18 Mon Sep 17 00:00:00 2001
From: gdemonc <1502689916@qq.com>
Date: Sat, 11 Apr 2026 19:42:14 +0800
Subject: [PATCH 14/19] feat(desktop-shell-ball): add bubble pin and delete
controls
---
.../shell-ball/ShellBallBubbleWindow.tsx | 13 +-
.../components/ShellBallBubbleMessage.tsx | 32 +++-
.../components/ShellBallBubbleZone.tsx | 15 +-
.../shell-ball/shellBall.contract.test.ts | 154 ++++++++++++++++--
.../src/features/shell-ball/shellBall.css | 49 +++++-
.../shell-ball/shellBall.windowSync.ts | 9 +
.../shell-ball/useShellBallCoordinator.ts | 47 +++++-
.../shell-ball/useShellBallWindowMetrics.ts | 33 +++-
8 files changed, 326 insertions(+), 26 deletions(-)
diff --git a/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx b/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx
index 82c8908ff..c13e33115 100644
--- a/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx
+++ b/apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx
@@ -1,6 +1,6 @@
import type { ShellBallVisualState } from "./shellBall.types";
import { getShellBallVisibleBubbleItems } from "./shellBall.windowSync";
-import { useShellBallHelperWindowSnapshot } from "./useShellBallCoordinator";
+import { emitShellBallBubbleAction, useShellBallHelperWindowSnapshot } from "./useShellBallCoordinator";
import { useShellBallWindowMetrics } from "./useShellBallWindowMetrics";
import { ShellBallBubbleZone } from "./components/ShellBallBubbleZone";
@@ -20,7 +20,16 @@ export function ShellBallBubbleWindow({ visualState }: ShellBallBubbleWindowProp
return (
-
+ {
+ void emitShellBallBubbleAction("delete", bubbleId);
+ }}
+ onPinBubble={(bubbleId) => {
+ void emitShellBallBubbleAction("pin", bubbleId);
+ }}
+ />
);
}
diff --git a/apps/desktop/src/features/shell-ball/components/ShellBallBubbleMessage.tsx b/apps/desktop/src/features/shell-ball/components/ShellBallBubbleMessage.tsx
index 1cead17c0..8e907804b 100644
--- a/apps/desktop/src/features/shell-ball/components/ShellBallBubbleMessage.tsx
+++ b/apps/desktop/src/features/shell-ball/components/ShellBallBubbleMessage.tsx
@@ -2,15 +2,43 @@ import type { ShellBallBubbleItem } from "../shellBall.bubble";
type ShellBallBubbleMessageProps = {
item: ShellBallBubbleItem;
+ onDelete?: (bubbleId: string) => void;
+ onPin?: (bubbleId: string) => void;
};
-export function ShellBallBubbleMessage({ item }: ShellBallBubbleMessageProps) {
+export function ShellBallBubbleMessage({ item, onDelete, onPin }: ShellBallBubbleMessageProps) {
+ const bubbleId = item.bubble.bubble_id;
+
return (
-
+
+
+
{item.bubble.text}
diff --git a/apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx b/apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx
index 0bc9fb96b..f533fe0d8 100644
--- a/apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx
+++ b/apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx
@@ -6,9 +6,16 @@ import { ShellBallBubbleMessage as ShellBallBubbleMessageView } from "./ShellBal
type ShellBallBubbleZoneProps = {
visualState: ShellBallVisualState;
bubbleItems?: ShellBallBubbleItem[];
+ onDeleteBubble?: (bubbleId: string) => void;
+ onPinBubble?: (bubbleId: string) => void;
};
-export function ShellBallBubbleZone({ visualState, bubbleItems = [] }: ShellBallBubbleZoneProps) {
+export function ShellBallBubbleZone({
+ visualState,
+ bubbleItems = [],
+ onDeleteBubble,
+ onPinBubble,
+}: ShellBallBubbleZoneProps) {
const scrollRef = useRef
(null);
useEffect(() => {
@@ -30,7 +37,11 @@ export function ShellBallBubbleZone({ visualState, bubbleItems = [] }: ShellBall
data-freshness={item.desktop.freshnessHint ?? "stale"}
data-motion={item.desktop.motionHint ?? "settle"}
>
-
+
))}
diff --git a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
index 16bc165cd..e72d033b4 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
@@ -63,10 +63,12 @@ import {
SHELL_BALL_WINDOW_SAFE_MARGIN_PX,
clampShellBallFrameToBounds,
createShellBallWindowFrame,
+ getShellBallHelperWindowInteractionMode,
getShellBallBubbleAnchor,
getShellBallInputAnchor,
measureShellBallContentSize,
} from "./useShellBallWindowMetrics";
+import { applyShellBallBubbleAction } from "./useShellBallCoordinator";
import {
getShellBallPostSubmitInputReset,
getShellBallDashboardOpenGesturePolicy,
@@ -777,8 +779,9 @@ test("shell-ball helper windows avoid auto-focus behavior", () => {
assert.doesNotMatch(tauriConfig, /"focusable": false/);
assert.match(controllerSource, /setShellBallWindowFocusable\([^)]*focusable: boolean\)/);
assert.match(controllerSource, /setShellBallWindowIgnoreCursorEvents\([^)]*ignore: boolean\)/);
- assert.match(metricsSource, /setShellBallWindowFocusable\(role, false\)/);
- assert.match(metricsSource, /setShellBallWindowIgnoreCursorEvents\(role, clickThrough\)/);
+ assert.match(metricsSource, /getShellBallHelperWindowInteractionMode/);
+ assert.match(metricsSource, /setShellBallWindowFocusable\(role, interactionMode\.focusable\)/);
+ assert.match(metricsSource, /setShellBallWindowIgnoreCursorEvents\(role, interactionMode\.ignoreCursorEvents\)/);
assert.doesNotMatch(metricsSource, /setFocus\(\)/);
assert.doesNotMatch(inputBarSource, /focus\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(planSource, /focusable: false/);
@@ -1071,6 +1074,7 @@ test("shell-ball helper window sync maps visual states into visibility and snaps
inputFocus: "desktop-shell-ball:input-focus",
inputDraft: "desktop-shell-ball:input-draft",
primaryAction: "desktop-shell-ball:primary-action",
+ bubbleAction: "desktop-shell-ball:bubble-action",
});
assert.deepEqual(getShellBallHelperWindowVisibility("idle"), {
@@ -2300,10 +2304,10 @@ test("shell-ball bubble window resolves bubble items from the helper-window snap
renderToStaticMarkup(createElement(RuntimeShellBallBubbleWindow, null));
- assert.deepEqual(capturedProps, {
- visualState: "processing",
- bubbleItems: getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems),
- });
+ assert.deepEqual(capturedProps?.visualState, "processing");
+ assert.deepEqual(capturedProps?.bubbleItems, getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems));
+ assert.equal(typeof capturedProps?.onDeleteBubble, "function");
+ assert.equal(typeof capturedProps?.onPinBubble, "function");
});
test("shell-ball bubble window does not depend on only visualState to render its body", () => {
@@ -2355,15 +2359,15 @@ test("shell-ball bubble window does not depend on only visualState to render its
renderToStaticMarkup(createElement(RuntimeShellBallBubbleWindow, { visualState: "voice_locked" }));
- assert.deepEqual(capturedProps, {
- visualState: "voice_locked",
- bubbleItems: getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems),
- metricsInput: {
- role: "bubble",
- visible: true,
- clickThrough: helperSnapshot.bubbleRegion.clickThrough,
- },
+ assert.deepEqual(capturedProps?.visualState, "voice_locked");
+ assert.deepEqual(capturedProps?.bubbleItems, getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems));
+ assert.deepEqual(capturedProps?.metricsInput, {
+ role: "bubble",
+ visible: true,
+ clickThrough: helperSnapshot.bubbleRegion.clickThrough,
});
+ assert.equal(typeof capturedProps?.onDeleteBubble, "function");
+ assert.equal(typeof capturedProps?.onPinBubble, "function");
});
test("shell-ball bubble zone renders a real message list without placeholder chrome", () => {
@@ -2420,6 +2424,128 @@ test("shell-ball bubble zone renders a real message list without placeholder chr
assert.doesNotMatch(markup, /toolbar/i);
});
+test("shell-ball bubble zone renders per-bubble pin and delete controls", () => {
+ const markup = renderToStaticMarkup(
+ createElement(ShellBallBubbleZone, {
+ visualState: "processing",
+ bubbleItems: [
+ {
+ bubble: {
+ bubble_id: "msg-agent-pin-1",
+ task_id: "task-agent-pin-1",
+ type: "status",
+ text: "Keep this handy.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:09:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "msg-user-pin-1",
+ task_id: "task-user-pin-1",
+ type: "result",
+ text: "Delete this after review.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:09:05.000Z",
+ },
+ role: "user",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ ] satisfies ShellBallBubbleItem[],
+ }),
+ );
+
+ assert.match(markup, /shell-ball-bubble-message__pin-control/g);
+ assert.match(markup, /shell-ball-bubble-message__delete-control/g);
+ assert.equal(markup.match(/data-bubble-action="pin"/g)?.length, 2);
+ assert.equal(markup.match(/data-bubble-action="delete"/g)?.length, 2);
+});
+
+test("shell-ball coordinator bubble actions pin and delete local items", () => {
+ const sourceItems: ShellBallBubbleItem[] = [
+ {
+ bubble: {
+ bubble_id: "msg-action-1",
+ task_id: "task-action-1",
+ type: "status",
+ text: "Pin this.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:10:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "msg-action-2",
+ task_id: "task-action-2",
+ type: "result",
+ text: "Delete this.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:10:05.000Z",
+ },
+ role: "user",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ ];
+
+ const pinnedItems = applyShellBallBubbleAction(sourceItems, {
+ action: "pin",
+ bubbleId: "msg-action-1",
+ });
+
+ assert.equal(pinnedItems[0]?.bubble.pinned, true);
+ assert.equal(pinnedItems[1]?.bubble.pinned, false);
+ assert.equal(sourceItems[0]?.bubble.pinned, false);
+
+ const remainingItems = applyShellBallBubbleAction(pinnedItems, {
+ action: "delete",
+ bubbleId: "msg-action-2",
+ });
+
+ assert.deepEqual(remainingItems.map((item) => item.bubble.bubble_id), ["msg-action-1"]);
+});
+
+test("shell-ball bubble interaction mode stays clickable while visible unpinned bubbles remain", () => {
+ assert.deepEqual(
+ getShellBallHelperWindowInteractionMode({
+ role: "bubble",
+ visible: true,
+ clickThrough: false,
+ }),
+ {
+ focusable: false,
+ ignoreCursorEvents: false,
+ },
+ );
+
+ assert.deepEqual(
+ getShellBallHelperWindowInteractionMode({
+ role: "bubble",
+ visible: true,
+ clickThrough: true,
+ }),
+ {
+ focusable: false,
+ ignoreCursorEvents: true,
+ },
+ );
+});
+
test("shell-ball bubble window styles stay transparent, faded, and motion-ready", () => {
const shellBallStyles = readFileSync(resolve(desktopRoot, "src/features/shell-ball/shellBall.css"), "utf8");
const mobileBubbleZoneBlock = shellBallStyles.match(
diff --git a/apps/desktop/src/features/shell-ball/shellBall.css b/apps/desktop/src/features/shell-ball/shellBall.css
index bc0eac5e2..7fb0e0256 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.css
+++ b/apps/desktop/src/features/shell-ball/shellBall.css
@@ -155,7 +155,8 @@
border-radius: 1.15rem;
box-shadow: 0 16px 28px -24px rgba(62, 56, 48, 0.34);
max-width: min(18rem, calc(100% - 2rem));
- padding: 0.65rem 0.85rem;
+ padding: 1.65rem 0.85rem 0.65rem;
+ position: relative;
}
.shell-ball-bubble-message--agent {
@@ -174,6 +175,52 @@
word-break: break-word;
}
+.shell-ball-bubble-message__control {
+ align-items: center;
+ appearance: none;
+ background: rgba(255, 255, 255, 0.72);
+ border: 1px solid rgba(132, 118, 99, 0.14);
+ border-radius: 999px;
+ color: var(--shell-ball-copy);
+ cursor: pointer;
+ display: inline-flex;
+ font-family: var(--font-mono);
+ font-size: 0.58rem;
+ inset-block-start: 0.45rem;
+ line-height: 1;
+ min-height: 1.25rem;
+ padding: 0.2rem 0.45rem;
+ position: absolute;
+ text-transform: uppercase;
+ transition:
+ background 160ms ease,
+ border-color 160ms ease,
+ color 160ms ease,
+ transform 160ms ease;
+}
+
+.shell-ball-bubble-message__control:hover {
+ background: rgba(255, 255, 255, 0.9);
+ border-color: rgba(106, 145, 200, 0.24);
+ color: var(--shell-ball-ink);
+ transform: translateY(-1px);
+}
+
+.shell-ball-bubble-message__control:focus-visible {
+ box-shadow:
+ 0 0 0 2px rgba(255, 251, 244, 0.92),
+ 0 0 0 4px rgba(106, 145, 200, 0.22);
+ outline: 0;
+}
+
+.shell-ball-bubble-message__pin-control {
+ inset-inline-start: 0.55rem;
+}
+
+.shell-ball-bubble-message__delete-control {
+ inset-inline-end: 0.55rem;
+}
+
.shell-ball-bubble-zone[data-state="waiting_auth"] {
--shell-ball-bubble-accent: rgba(193, 141, 70, 0.22);
}
diff --git a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
index 5a7868466..873a9f768 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
@@ -12,12 +12,15 @@ export const shellBallWindowSyncEvents = Object.freeze({
inputFocus: "desktop-shell-ball:input-focus",
inputDraft: "desktop-shell-ball:input-draft",
primaryAction: "desktop-shell-ball:primary-action",
+ bubbleAction: "desktop-shell-ball:bubble-action",
});
export type ShellBallHelperWindowRole = "bubble" | "input";
export type ShellBallPrimaryAction = "attach_file" | "submit" | "primary_click";
+export type ShellBallBubbleAction = "pin" | "delete";
+
export type ShellBallHelperWindowVisibility = {
bubble: boolean;
input: boolean;
@@ -76,6 +79,12 @@ export type ShellBallPrimaryActionPayload = {
action: ShellBallPrimaryAction;
};
+export type ShellBallBubbleActionPayload = {
+ source: "bubble";
+ action: ShellBallBubbleAction;
+ bubbleId: string;
+};
+
export function getShellBallHelperWindowVisibility(
visualState: ShellBallVisualState,
): ShellBallHelperWindowVisibility {
diff --git a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
index b9158db3f..15f943d0c 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
@@ -1,12 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { shellBallWindowLabels } from "../../platform/shellBallWindowController";
-import type { ShellBallBubbleItem } from "./shellBall.bubble";
+import { cloneShellBallBubbleItems, type ShellBallBubbleItem } from "./shellBall.bubble";
import type { ShellBallVoicePreview } from "./shellBall.interaction";
import type { ShellBallInputBarMode, ShellBallVisualState } from "./shellBall.types";
import {
createDefaultShellBallWindowSnapshot,
createShellBallWindowSnapshot,
+ type ShellBallBubbleAction,
+ type ShellBallBubbleActionPayload,
shellBallWindowSyncEvents,
type ShellBallHelperReadyPayload,
type ShellBallHelperWindowRole,
@@ -69,16 +71,40 @@ const SHELL_BALL_LOCAL_BUBBLE_ITEMS: ShellBallBubbleItem[] = [
},
];
+export function applyShellBallBubbleAction(
+ items: ShellBallBubbleItem[],
+ payload: Pick,
+): ShellBallBubbleItem[] {
+ if (payload.action === "delete") {
+ return items.filter((item) => item.bubble.bubble_id !== payload.bubbleId);
+ }
+
+ return items.map((item) => {
+ if (item.bubble.bubble_id !== payload.bubbleId) {
+ return item;
+ }
+
+ return {
+ ...item,
+ bubble: {
+ ...item.bubble,
+ pinned: true,
+ },
+ };
+ });
+}
+
export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
+ const [bubbleItems, setBubbleItems] = useState(() => cloneShellBallBubbleItems(SHELL_BALL_LOCAL_BUBBLE_ITEMS));
const snapshot = useMemo(
() =>
createShellBallWindowSnapshot({
visualState: input.visualState,
inputValue: input.inputValue,
voicePreview: input.voicePreview,
- bubbleItems: SHELL_BALL_LOCAL_BUBBLE_ITEMS,
+ bubbleItems,
}),
- [input.inputValue, input.visualState, input.voicePreview],
+ [bubbleItems, input.inputValue, input.visualState, input.voicePreview],
);
const snapshotRef = useRef(snapshot);
const handlersRef = useRef({
@@ -144,6 +170,10 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
}
}
+ function handleBubbleAction(payload: ShellBallBubbleActionPayload) {
+ setBubbleItems((currentItems) => applyShellBallBubbleAction(currentItems, payload));
+ }
+
void Promise.all([
currentWindow.listen(
shellBallWindowSyncEvents.helperReady,
@@ -171,6 +201,9 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
handlePrimaryAction(payload.action);
},
),
+ currentWindow.listen(shellBallWindowSyncEvents.bubbleAction, ({ payload }) => {
+ handleBubbleAction(payload);
+ }),
]).then((unlisteners) => {
if (disposed) {
for (const unlisten of unlisteners) {
@@ -249,3 +282,11 @@ export async function emitShellBallPrimaryAction(action: ShellBallPrimaryAction,
source,
});
}
+
+export async function emitShellBallBubbleAction(action: ShellBallBubbleAction, bubbleId: string) {
+ await getCurrentWindow().emitTo(shellBallWindowLabels.ball, shellBallWindowSyncEvents.bubbleAction, {
+ action,
+ bubbleId,
+ source: "bubble",
+ });
+}
diff --git a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
index 90228e871..ab60321ff 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
@@ -53,6 +53,11 @@ type UseShellBallWindowMetricsInput = {
clickThrough?: boolean;
};
+type ShellBallHelperWindowInteractionMode = {
+ focusable: boolean;
+ ignoreCursorEvents: boolean;
+};
+
export function createShellBallWindowFrame(
contentSize: ShellBallContentSize,
safeMargin = SHELL_BALL_WINDOW_SAFE_MARGIN_PX,
@@ -112,6 +117,24 @@ export function clampShellBallFrameToBounds(
};
}
+export function getShellBallHelperWindowInteractionMode(input: {
+ role: ShellBallHelperWindowRole;
+ visible: boolean;
+ clickThrough: boolean;
+}): ShellBallHelperWindowInteractionMode {
+ if (input.role === "bubble") {
+ return {
+ focusable: false,
+ ignoreCursorEvents: input.clickThrough || input.visible === false,
+ };
+ }
+
+ return {
+ focusable: true,
+ ignoreCursorEvents: false,
+ };
+}
+
function getShellBallBoundsFromMonitor(monitor: Monitor | null, geometry: ShellBallWindowGeometry | null): ShellBallWindowBounds {
if (monitor === null) {
return geometry?.bounds ?? {
@@ -262,8 +285,14 @@ export function useShellBallWindowMetrics({ role, visible = true, clickThrough =
const helperFrame = windowFrame;
if (role === "bubble") {
- void setShellBallWindowFocusable(role, false);
- void setShellBallWindowIgnoreCursorEvents(role, clickThrough);
+ const interactionMode = getShellBallHelperWindowInteractionMode({
+ role,
+ visible,
+ clickThrough,
+ });
+
+ void setShellBallWindowFocusable(role, interactionMode.focusable);
+ void setShellBallWindowIgnoreCursorEvents(role, interactionMode.ignoreCursorEvents);
}
let cleanup: (() => void) | null = null;
From 71ff46459a92bb819fb01666253118c52b55df1f Mon Sep 17 00:00:00 2001
From: gdemonc <1502689916@qq.com>
Date: Sat, 11 Apr 2026 19:46:20 +0800
Subject: [PATCH 15/19] feat(desktop-shell-ball): define pinned bubble state
ownership
---
.../shell-ball/shellBall.contract.test.ts | 30 +++++++++++++++++++
.../shell-ball/shellBall.windowSync.ts | 6 ++--
.../shell-ball/useShellBallCoordinator.ts | 10 +++++--
3 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
index e72d033b4..d911695c4 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
@@ -2518,6 +2518,36 @@ test("shell-ball coordinator bubble actions pin and delete local items", () => {
});
assert.deepEqual(remainingItems.map((item) => item.bubble.bubble_id), ["msg-action-1"]);
+
+ const unpinnedItems = applyShellBallBubbleAction(pinnedItems, {
+ action: "unpin",
+ bubbleId: "msg-action-1",
+ });
+
+ assert.equal(unpinnedItems[0]?.bubble.pinned, false);
+});
+
+test("shell-ball bubble actions stay coordinator-owned and detached-position free", () => {
+ const bubbleActionPayload = {
+ source: "pinned_window",
+ action: "unpin",
+ bubbleId: "msg-action-1",
+ } as const;
+ const coordinatorSource = readFileSync(resolve(desktopRoot, "src/features/shell-ball/useShellBallCoordinator.ts"), "utf8");
+ const syncSource = readFileSync(resolve(desktopRoot, "src/features/shell-ball/shellBall.windowSync.ts"), "utf8");
+
+ assert.deepEqual(bubbleActionPayload, {
+ source: "pinned_window",
+ action: "unpin",
+ bubbleId: "msg-action-1",
+ });
+ assert.equal("x" in bubbleActionPayload, false);
+ assert.equal("y" in bubbleActionPayload, false);
+ assert.equal("position" in bubbleActionPayload, false);
+ assert.match(syncSource, /export type ShellBallBubbleAction = "pin" \| "unpin" \| "delete";/);
+ assert.match(syncSource, /export type ShellBallBubbleActionSource = "bubble" \| "pinned_window";/);
+ assert.match(coordinatorSource, /currentWindow\.listen\(shellBallWindowSyncEvents\.bubbleAction/);
+ assert.match(coordinatorSource, /setBubbleItems\(\(currentItems\) => applyShellBallBubbleAction\(currentItems, payload\)\)/);
});
test("shell-ball bubble interaction mode stays clickable while visible unpinned bubbles remain", () => {
diff --git a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
index 873a9f768..fc22f9702 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
@@ -19,7 +19,9 @@ export type ShellBallHelperWindowRole = "bubble" | "input";
export type ShellBallPrimaryAction = "attach_file" | "submit" | "primary_click";
-export type ShellBallBubbleAction = "pin" | "delete";
+export type ShellBallBubbleAction = "pin" | "unpin" | "delete";
+
+export type ShellBallBubbleActionSource = "bubble" | "pinned_window";
export type ShellBallHelperWindowVisibility = {
bubble: boolean;
@@ -80,7 +82,7 @@ export type ShellBallPrimaryActionPayload = {
};
export type ShellBallBubbleActionPayload = {
- source: "bubble";
+ source: ShellBallBubbleActionSource;
action: ShellBallBubbleAction;
bubbleId: string;
};
diff --git a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
index 15f943d0c..f9d6f3a91 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
@@ -88,7 +88,7 @@ export function applyShellBallBubbleAction(
...item,
bubble: {
...item.bubble,
- pinned: true,
+ pinned: payload.action === "pin",
},
};
});
@@ -283,10 +283,14 @@ export async function emitShellBallPrimaryAction(action: ShellBallPrimaryAction,
});
}
-export async function emitShellBallBubbleAction(action: ShellBallBubbleAction, bubbleId: string) {
+export async function emitShellBallBubbleAction(
+ action: ShellBallBubbleAction,
+ bubbleId: string,
+ source: ShellBallBubbleActionPayload["source"] = "bubble",
+) {
await getCurrentWindow().emitTo(shellBallWindowLabels.ball, shellBallWindowSyncEvents.bubbleAction, {
action,
bubbleId,
- source: "bubble",
+ source,
});
}
From ccbf69de67da3e133514c325e4fc192a5bbaae1c Mon Sep 17 00:00:00 2001
From: gdemonc <1502689916@qq.com>
Date: Sat, 11 Apr 2026 20:00:53 +0800
Subject: [PATCH 16/19] feat(desktop-shell-ball): detach pinned bubbles into
windows
---
apps/desktop/shell-ball-bubble-pinned.html | 12 ++
.../src-tauri/capabilities/default.json | 2 +-
.../src-tauri/gen/schemas/capabilities.json | 2 +-
.../src/app/shell-ball-bubble-pinned/main.tsx | 19 ++
.../ShellBallPinnedBubbleWindow.tsx | 73 ++++++++
.../shell-ball/shellBall.contract.test.ts | 164 +++++++++++++++++-
.../shell-ball/shellBall.windowSync.ts | 15 +-
.../shell-ball/useShellBallCoordinator.ts | 133 +++++++++++++-
.../shell-ball/useShellBallWindowMetrics.ts | 6 +-
.../src/platform/shellBallWindowController.ts | 112 +++++++++++-
apps/desktop/vite.config.ts | 1 +
11 files changed, 514 insertions(+), 25 deletions(-)
create mode 100644 apps/desktop/shell-ball-bubble-pinned.html
create mode 100644 apps/desktop/src/app/shell-ball-bubble-pinned/main.tsx
create mode 100644 apps/desktop/src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx
diff --git a/apps/desktop/shell-ball-bubble-pinned.html b/apps/desktop/shell-ball-bubble-pinned.html
new file mode 100644
index 000000000..8891fbf1d
--- /dev/null
+++ b/apps/desktop/shell-ball-bubble-pinned.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ CialloClaw Shell Ball Bubble Pinned
+
+
+
+
+
+
diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json
index a41a00dbe..04c08d4b5 100644
--- a/apps/desktop/src-tauri/capabilities/default.json
+++ b/apps/desktop/src-tauri/capabilities/default.json
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop app permissions plus shell-ball floating window controls.",
- "windows": ["shell-ball", "shell-ball-bubble", "shell-ball-input", "dashboard", "control-panel"],
+ "windows": ["shell-ball", "shell-ball-bubble", "shell-ball-input", "shell-ball-bubble-pinned-*", "dashboard", "control-panel"],
"permissions": [
"core:default",
"core:window:allow-create",
diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json
index bf98e7ebb..e62e06134 100644
--- a/apps/desktop/src-tauri/gen/schemas/capabilities.json
+++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json
@@ -1 +1 @@
-{"default":{"identifier":"default","description":"Default desktop app permissions plus shell-ball floating window controls.","local":true,"windows":["shell-ball","shell-ball-bubble","shell-ball-input","dashboard","control-panel"],"permissions":["core:default","core:window:allow-create","core:window:allow-show","core:window:allow-hide","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-set-size-constraints","core:window:allow-set-focus","core:window:allow-set-focusable","core:window:allow-set-ignore-cursor-events","core:window:allow-set-always-on-top","core:window:allow-unminimize","core:window:allow-start-dragging"]}}
\ No newline at end of file
+{"default":{"identifier":"default","description":"Default desktop app permissions plus shell-ball floating window controls.","local":true,"windows":["shell-ball","shell-ball-bubble","shell-ball-input","shell-ball-bubble-pinned-*","dashboard","control-panel"],"permissions":["core:default","core:window:allow-create","core:window:allow-show","core:window:allow-hide","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-set-size-constraints","core:window:allow-set-focus","core:window:allow-set-focusable","core:window:allow-set-ignore-cursor-events","core:window:allow-set-always-on-top","core:window:allow-unminimize","core:window:allow-start-dragging"]}}
diff --git a/apps/desktop/src/app/shell-ball-bubble-pinned/main.tsx b/apps/desktop/src/app/shell-ball-bubble-pinned/main.tsx
new file mode 100644
index 000000000..5570ba537
--- /dev/null
+++ b/apps/desktop/src/app/shell-ball-bubble-pinned/main.tsx
@@ -0,0 +1,19 @@
+import ReactDOM from "react-dom/client";
+import { AppProviders } from "@/features/shared/AppProviders";
+import { ShellBallPinnedBubbleWindow } from "@/features/shell-ball/ShellBallPinnedBubbleWindow";
+import "@/features/shell-ball/shellBall.css";
+
+const rootElement = document.getElementById("root")!;
+
+document.documentElement.dataset.appWindow = "shell-ball-bubble-pinned";
+document.body.dataset.appWindow = "shell-ball-bubble-pinned";
+rootElement.dataset.appWindow = "shell-ball-bubble-pinned";
+document.documentElement.setAttribute("data-app-window", "shell-ball-bubble-pinned");
+document.body.setAttribute("data-app-window", "shell-ball-bubble-pinned");
+rootElement.setAttribute("data-app-window", "shell-ball-bubble-pinned");
+
+ReactDOM.createRoot(rootElement).render(
+
+
+ ,
+);
diff --git a/apps/desktop/src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx b/apps/desktop/src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx
new file mode 100644
index 000000000..309d99c30
--- /dev/null
+++ b/apps/desktop/src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx
@@ -0,0 +1,73 @@
+import { useMemo, useState } from "react";
+import {
+ getShellBallCurrentWindow,
+ getShellBallPinnedBubbleIdFromLabel,
+ startShellBallWindowDragging,
+} from "../../platform/shellBallWindowController";
+import { emitShellBallBubbleAction, emitShellBallPinnedWindowDetached, useShellBallHelperWindowSnapshot } from "./useShellBallCoordinator";
+
+export function ShellBallPinnedBubbleWindow() {
+ const windowLabel = getShellBallCurrentWindow().label;
+ const bubbleId = getShellBallPinnedBubbleIdFromLabel(windowLabel);
+ const snapshot = useShellBallHelperWindowSnapshot({ role: "pinned" });
+ const [followsShellBallGeometry, setFollowsShellBallGeometry] = useState(true);
+ const pinnedItem = useMemo(
+ () => snapshot.bubbleItems.find((item) => item.bubble.bubble_id === bubbleId && item.bubble.pinned),
+ [bubbleId, snapshot.bubbleItems],
+ );
+
+ if (bubbleId === null || pinnedItem === undefined) {
+ return ;
+ }
+
+ const pinnedBubbleId = bubbleId;
+
+ function handleDetachDrag() {
+ if (followsShellBallGeometry) {
+ setFollowsShellBallGeometry(false);
+ void emitShellBallPinnedWindowDetached(pinnedBubbleId);
+ }
+
+ void startShellBallWindowDragging();
+ }
+
+ return (
+
+
+
+
+
+
{pinnedItem.bubble.text}
+
+
+ );
+}
diff --git a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
index d911695c4..3d58975ee 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
@@ -662,6 +662,20 @@ test("shell-ball desktop host declares bubble and input helper windows", () => {
assert.match(tauriConfig, /"url": "shell-ball-input\.html"/);
});
+test("shell-ball desktop host declares detached pinned bubble windows", () => {
+ assert.equal(existsSync(resolve(desktopRoot, "shell-ball-bubble-pinned.html")), true);
+ assert.equal(existsSync(resolve(desktopRoot, "src/app/shell-ball-bubble-pinned/main.tsx")), true);
+
+ const pinnedHtml = readFileSync(resolve(desktopRoot, "shell-ball-bubble-pinned.html"), "utf8");
+ const pinnedEntry = readFileSync(resolve(desktopRoot, "src/app/shell-ball-bubble-pinned/main.tsx"), "utf8");
+ const viteConfig = readFileSync(resolve(desktopRoot, "vite.config.ts"), "utf8");
+
+ assert.match(pinnedHtml, /src="\/src\/app\/shell-ball-bubble-pinned\/main\.tsx"/);
+ assert.match(pinnedEntry, /ShellBallPinnedBubbleWindow/);
+ assert.match(pinnedEntry, /data-app-window/);
+ assert.match(viteConfig, /"shell-ball-bubble-pinned"/);
+});
+
test("shell-ball desktop window controller and capabilities stay aligned", () => {
assert.deepEqual(shellBallWindowLabels, {
ball: "shell-ball",
@@ -687,6 +701,7 @@ test("shell-ball desktop window controller and capabilities stay aligned", () =>
"shell-ball",
"shell-ball-bubble",
"shell-ball-input",
+ "shell-ball-bubble-pinned-*",
"dashboard",
"control-panel",
]);
@@ -711,6 +726,34 @@ test("shell-ball desktop window controller and capabilities stay aligned", () =>
assert.equal(generatedCapabilitySchema.default.permissions.includes("core:window:allow-unminimize"), true);
});
+test("shell-ball pinned window labels and capabilities stay deterministic", () => {
+ const controllerSource = readFileSync(
+ resolve(desktopRoot, "src/platform/shellBallWindowController.ts"),
+ "utf8",
+ );
+ const capabilityConfig = JSON.parse(
+ readFileSync(resolve(desktopRoot, "src-tauri/capabilities/default.json"), "utf8"),
+ ) as {
+ windows: string[];
+ permissions: string[];
+ };
+ const generatedCapabilitySchema = JSON.parse(
+ readFileSync(resolve(desktopRoot, "src-tauri/gen/schemas/capabilities.json"), "utf8"),
+ ) as {
+ default: {
+ windows: string[];
+ permissions: string[];
+ };
+ };
+
+ assert.match(controllerSource, /shellBallPinnedBubbleWindowLabelPrefix = "shell-ball-bubble-pinned-"/);
+ assert.match(controllerSource, /return `\$\{shellBallPinnedBubbleWindowLabelPrefix\}\$\{bubbleId\}`/);
+ assert.match(controllerSource, /shell-ball-bubble-pinned\.html/);
+ assert.equal(capabilityConfig.windows.includes("shell-ball-bubble-pinned-*"), true);
+ assert.deepEqual(generatedCapabilitySchema.default.windows, capabilityConfig.windows);
+ assert.equal(generatedCapabilitySchema.default.windows.includes("shell-ball-bubble-pinned-*"), true);
+});
+
test("dashboard and control-panel stay hidden on cold launch until explicitly opened", () => {
const tauriConfig = JSON.parse(
readFileSync(resolve(desktopRoot, "src-tauri/tauri.conf.json"), "utf8"),
@@ -1070,6 +1113,8 @@ test("shell-ball helper window sync maps visual states into visibility and snaps
snapshot: "desktop-shell-ball:snapshot",
geometry: "desktop-shell-ball:geometry",
helperReady: "desktop-shell-ball:helper-ready",
+ pinnedWindowReady: "desktop-shell-ball:pinned-window-ready",
+ pinnedWindowDetached: "desktop-shell-ball:pinned-window-detached",
inputHover: "desktop-shell-ball:input-hover",
inputFocus: "desktop-shell-ball:input-focus",
inputDraft: "desktop-shell-ball:input-draft",
@@ -2304,10 +2349,13 @@ test("shell-ball bubble window resolves bubble items from the helper-window snap
renderToStaticMarkup(createElement(RuntimeShellBallBubbleWindow, null));
- assert.deepEqual(capturedProps?.visualState, "processing");
- assert.deepEqual(capturedProps?.bubbleItems, getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems));
- assert.equal(typeof capturedProps?.onDeleteBubble, "function");
- assert.equal(typeof capturedProps?.onPinBubble, "function");
+ assert.notEqual(capturedProps, null);
+ const resolvedProps = capturedProps as unknown as Record;
+
+ assert.deepEqual(resolvedProps.visualState, "processing");
+ assert.deepEqual(resolvedProps.bubbleItems, getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems));
+ assert.equal(typeof resolvedProps.onDeleteBubble, "function");
+ assert.equal(typeof resolvedProps.onPinBubble, "function");
});
test("shell-ball bubble window does not depend on only visualState to render its body", () => {
@@ -2359,15 +2407,18 @@ test("shell-ball bubble window does not depend on only visualState to render its
renderToStaticMarkup(createElement(RuntimeShellBallBubbleWindow, { visualState: "voice_locked" }));
- assert.deepEqual(capturedProps?.visualState, "voice_locked");
- assert.deepEqual(capturedProps?.bubbleItems, getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems));
- assert.deepEqual(capturedProps?.metricsInput, {
+ assert.notEqual(capturedProps, null);
+ const resolvedProps = capturedProps as unknown as Record;
+
+ assert.deepEqual(resolvedProps.visualState, "voice_locked");
+ assert.deepEqual(resolvedProps.bubbleItems, getShellBallVisibleBubbleItems(helperSnapshot.bubbleItems));
+ assert.deepEqual(resolvedProps.metricsInput, {
role: "bubble",
visible: true,
clickThrough: helperSnapshot.bubbleRegion.clickThrough,
});
- assert.equal(typeof capturedProps?.onDeleteBubble, "function");
- assert.equal(typeof capturedProps?.onPinBubble, "function");
+ assert.equal(typeof resolvedProps.onDeleteBubble, "function");
+ assert.equal(typeof resolvedProps.onPinBubble, "function");
});
test("shell-ball bubble zone renders a real message list without placeholder chrome", () => {
@@ -2550,6 +2601,101 @@ test("shell-ball bubble actions stay coordinator-owned and detached-position fre
assert.match(coordinatorSource, /setBubbleItems\(\(currentItems\) => applyShellBallBubbleAction\(currentItems, payload\)\)/);
});
+test("shell-ball pinned bubble windows render one coordinator-owned pinned item and emit detached actions", () => {
+ const helperSnapshot = createShellBallWindowSnapshot({
+ visualState: "processing",
+ inputValue: "",
+ voicePreview: null,
+ bubbleItems: [
+ {
+ bubble: {
+ bubble_id: "msg-pinned-1",
+ task_id: "task-pinned-1",
+ type: "status",
+ text: "Keep this pinned.",
+ pinned: true,
+ hidden: false,
+ created_at: "2026-04-11T10:12:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "msg-unpinned-1",
+ task_id: "task-unpinned-1",
+ type: "result",
+ text: "Leave this in the region.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:12:01.000Z",
+ },
+ role: "user",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ ],
+ });
+ const actions: Array<{ action: string; bubbleId: string; source: string | undefined }> = [];
+
+ const { ShellBallPinnedBubbleWindow: RuntimeShellBallPinnedBubbleWindow } = withShellBallModuleRuntime(
+ "ShellBallPinnedBubbleWindow.tsx",
+ {
+ react: require("react"),
+ "./useShellBallCoordinator": {
+ useShellBallHelperWindowSnapshot() {
+ return helperSnapshot;
+ },
+ emitShellBallBubbleAction(action: string, bubbleId: string, source?: string) {
+ actions.push({ action, bubbleId, source });
+ return Promise.resolve();
+ },
+ },
+ "../../platform/shellBallWindowController": {
+ getShellBallPinnedBubbleIdFromLabel() {
+ return "msg-pinned-1";
+ },
+ getShellBallCurrentWindow() {
+ return { label: "shell-ball-bubble-pinned-msg-pinned-1" };
+ },
+ startShellBallWindowDragging() {
+ actions.push({ action: "drag", bubbleId: "msg-pinned-1", source: "window" });
+ return Promise.resolve();
+ },
+ },
+ },
+ (moduleExports) => moduleExports as {
+ ShellBallPinnedBubbleWindow: (props: Record) => ReturnType;
+ },
+ );
+
+ const markup = renderToStaticMarkup(createElement(RuntimeShellBallPinnedBubbleWindow, null));
+
+ assert.match(markup, /Keep this pinned\./);
+ assert.doesNotMatch(markup, /Leave this in the region\./);
+ assert.match(markup, /Unpin/);
+ assert.match(markup, /Delete/);
+});
+
+test("shell-ball detached pinned window contract stays anchored before drag and detached after drag", () => {
+ const pinnedWindowSource = readFileSync(
+ resolve(desktopRoot, "src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx"),
+ "utf8",
+ );
+ const coordinatorSource = readFileSync(resolve(desktopRoot, "src/features/shell-ball/useShellBallCoordinator.ts"), "utf8");
+ const syncSource = readFileSync(resolve(desktopRoot, "src/features/shell-ball/shellBall.windowSync.ts"), "utf8");
+
+ assert.match(pinnedWindowSource, /startShellBallWindowDragging/);
+ assert.match(pinnedWindowSource, /setFollowsShellBallGeometry\(false\)/);
+ assert.match(syncSource, /pinnedWindowReady/);
+ assert.match(coordinatorSource, /openShellBallPinnedBubbleWindow/);
+ assert.match(coordinatorSource, /closeShellBallPinnedBubbleWindow/);
+ assert.match(coordinatorSource, /shellBallWindowSyncEvents\.pinnedWindowReady/);
+});
+
test("shell-ball bubble interaction mode stays clickable while visible unpinned bubbles remain", () => {
assert.deepEqual(
getShellBallHelperWindowInteractionMode({
diff --git a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
index fc22f9702..e6c72c22f 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.windowSync.ts
@@ -8,6 +8,8 @@ export const shellBallWindowSyncEvents = Object.freeze({
snapshot: "desktop-shell-ball:snapshot",
geometry: "desktop-shell-ball:geometry",
helperReady: "desktop-shell-ball:helper-ready",
+ pinnedWindowReady: "desktop-shell-ball:pinned-window-ready",
+ pinnedWindowDetached: "desktop-shell-ball:pinned-window-detached",
inputHover: "desktop-shell-ball:input-hover",
inputFocus: "desktop-shell-ball:input-focus",
inputDraft: "desktop-shell-ball:input-draft",
@@ -15,7 +17,7 @@ export const shellBallWindowSyncEvents = Object.freeze({
bubbleAction: "desktop-shell-ball:bubble-action",
});
-export type ShellBallHelperWindowRole = "bubble" | "input";
+export type ShellBallHelperWindowRole = "bubble" | "input" | "pinned";
export type ShellBallPrimaryAction = "attach_file" | "submit" | "primary_click";
@@ -61,7 +63,16 @@ export type ShellBallWindowGeometry = {
};
export type ShellBallHelperReadyPayload = {
- role: ShellBallHelperWindowRole;
+ role: Exclude;
+};
+
+export type ShellBallPinnedWindowReadyPayload = {
+ windowLabel: string;
+ bubbleId: string;
+};
+
+export type ShellBallPinnedWindowDetachedPayload = {
+ bubbleId: string;
};
export type ShellBallInputHoverPayload = {
diff --git a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
index f9d6f3a91..4b799fcdc 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
@@ -1,6 +1,15 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
-import { shellBallWindowLabels } from "../../platform/shellBallWindowController";
+import {
+ SHELL_BALL_PINNED_BUBBLE_WINDOW_FRAME,
+ closeShellBallPinnedBubbleWindow,
+ emitToShellBallWindowLabel,
+ getShellBallPinnedBubbleIdFromLabel,
+ getShellBallPinnedBubbleWindowAnchor,
+ getShellBallPinnedBubbleWindowLabel,
+ openShellBallPinnedBubbleWindow,
+ shellBallWindowLabels,
+} from "../../platform/shellBallWindowController";
import { cloneShellBallBubbleItems, type ShellBallBubbleItem } from "./shellBall.bubble";
import type { ShellBallVoicePreview } from "./shellBall.interaction";
import type { ShellBallInputBarMode, ShellBallVisualState } from "./shellBall.types";
@@ -15,9 +24,12 @@ import {
type ShellBallInputDraftPayload,
type ShellBallInputFocusPayload,
type ShellBallInputHoverPayload,
+ type ShellBallPinnedWindowDetachedPayload,
+ type ShellBallPinnedWindowReadyPayload,
type ShellBallPrimaryAction,
type ShellBallPrimaryActionPayload,
} from "./shellBall.windowSync";
+import { getShellBallBubbleAnchor } from "./useShellBallWindowMetrics";
type ShellBallCoordinatorInput = {
visualState: ShellBallVisualState;
@@ -34,6 +46,7 @@ type ShellBallCoordinatorInput = {
type ShellBallHelperSnapshotInput = {
role: ShellBallHelperWindowRole;
+ windowLabel?: string;
};
const SHELL_BALL_LOCAL_BUBBLE_ITEMS: ShellBallBubbleItem[] = [
@@ -107,6 +120,8 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
[bubbleItems, input.inputValue, input.visualState, input.voicePreview],
);
const snapshotRef = useRef(snapshot);
+ const bubbleItemsRef = useRef(bubbleItems);
+ const detachedPinnedBubbleIdsRef = useRef(new Set());
const handlersRef = useRef({
setInputValue: input.setInputValue,
onRegionEnter: input.onRegionEnter,
@@ -118,6 +133,7 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
});
snapshotRef.current = snapshot;
+ bubbleItemsRef.current = bubbleItems;
handlersRef.current = {
setInputValue: input.setInputValue,
onRegionEnter: input.onRegionEnter,
@@ -135,11 +151,19 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
return;
}
- async function emitSnapshotTo(role: ShellBallHelperWindowRole) {
- await currentWindow.emitTo(shellBallWindowLabels[role], shellBallWindowSyncEvents.snapshot, snapshotRef.current);
+ async function emitSnapshotToLabel(label: string) {
+ await emitToShellBallWindowLabel(label, shellBallWindowSyncEvents.snapshot, snapshotRef.current);
}
- void Promise.all([emitSnapshotTo("bubble"), emitSnapshotTo("input")]);
+ const pinnedBubbleLabels = snapshotRef.current.bubbleItems
+ .filter((item) => item.bubble.pinned)
+ .map((item) => getShellBallPinnedBubbleWindowLabel(item.bubble.bubble_id));
+
+ void Promise.all([
+ emitSnapshotToLabel(shellBallWindowLabels.bubble),
+ emitSnapshotToLabel(shellBallWindowLabels.input),
+ ...pinnedBubbleLabels.map((label) => emitSnapshotToLabel(label)),
+ ]);
}, [snapshot]);
useEffect(() => {
@@ -152,8 +176,49 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
let disposed = false;
let cleanupFns: Array<() => void> = [];
- async function emitSnapshotTo(role: ShellBallHelperWindowRole) {
- await currentWindow.emitTo(shellBallWindowLabels[role], shellBallWindowSyncEvents.snapshot, snapshotRef.current);
+ async function emitSnapshotTo(role: Exclude) {
+ await emitToShellBallWindowLabel(shellBallWindowLabels[role], shellBallWindowSyncEvents.snapshot, snapshotRef.current);
+ }
+
+ async function syncPinnedBubbleWindowAnchor(bubbleId: string) {
+ if (detachedPinnedBubbleIdsRef.current.has(bubbleId)) {
+ return;
+ }
+
+ const bubbleItem = bubbleItemsRef.current.find((item) => item.bubble.bubble_id === bubbleId && item.bubble.pinned);
+
+ if (bubbleItem === undefined) {
+ return;
+ }
+
+ const outerPosition = await currentWindow.outerPosition();
+ const outerSize = await currentWindow.outerSize();
+ const scaleFactor = await currentWindow.scaleFactor();
+ const logicalPosition = outerPosition.toLogical(scaleFactor);
+ const logicalSize = outerSize.toLogical(scaleFactor);
+ const bubbleAnchor = getShellBallBubbleAnchor({
+ ballFrame: {
+ x: logicalPosition.x,
+ y: logicalPosition.y,
+ width: logicalSize.width,
+ height: logicalSize.height,
+ },
+ helperFrame: SHELL_BALL_PINNED_BUBBLE_WINDOW_FRAME,
+ });
+
+ await openShellBallPinnedBubbleWindow({
+ bubbleId,
+ position: getShellBallPinnedBubbleWindowAnchor({ bubbleAnchor }),
+ size: SHELL_BALL_PINNED_BUBBLE_WINDOW_FRAME,
+ });
+ }
+
+ async function syncAnchoredPinnedBubbleWindows() {
+ await Promise.all(
+ bubbleItemsRef.current
+ .filter((item) => item.bubble.pinned)
+ .map((item) => syncPinnedBubbleWindowAnchor(item.bubble.bubble_id)),
+ );
}
function handlePrimaryAction(action: ShellBallPrimaryAction) {
@@ -172,6 +237,15 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
function handleBubbleAction(payload: ShellBallBubbleActionPayload) {
setBubbleItems((currentItems) => applyShellBallBubbleAction(currentItems, payload));
+
+ if (payload.action === "pin") {
+ detachedPinnedBubbleIdsRef.current.delete(payload.bubbleId);
+ void syncPinnedBubbleWindowAnchor(payload.bubbleId);
+ return;
+ }
+
+ detachedPinnedBubbleIdsRef.current.delete(payload.bubbleId);
+ void closeShellBallPinnedBubbleWindow(payload.bubbleId);
}
void Promise.all([
@@ -181,6 +255,19 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
void emitSnapshotTo(payload.role);
},
),
+ currentWindow.listen(
+ shellBallWindowSyncEvents.pinnedWindowReady,
+ ({ payload }) => {
+ void emitToShellBallWindowLabel(payload.windowLabel, shellBallWindowSyncEvents.snapshot, snapshotRef.current);
+ void syncPinnedBubbleWindowAnchor(payload.bubbleId);
+ },
+ ),
+ currentWindow.listen(
+ shellBallWindowSyncEvents.pinnedWindowDetached,
+ ({ payload }) => {
+ detachedPinnedBubbleIdsRef.current.add(payload.bubbleId);
+ },
+ ),
currentWindow.listen(shellBallWindowSyncEvents.inputHover, ({ payload }) => {
if (payload.active) {
handlersRef.current.onRegionEnter();
@@ -204,6 +291,12 @@ export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
currentWindow.listen(shellBallWindowSyncEvents.bubbleAction, ({ payload }) => {
handleBubbleAction(payload);
}),
+ currentWindow.onMoved(() => {
+ void syncAnchoredPinnedBubbleWindows();
+ }),
+ currentWindow.onResized(() => {
+ void syncAnchoredPinnedBubbleWindows();
+ }),
]).then((unlisteners) => {
if (disposed) {
for (const unlisten of unlisteners) {
@@ -232,7 +325,13 @@ export function useShellBallHelperWindowSnapshot({ role }: ShellBallHelperSnapsh
useEffect(() => {
const currentWindow = getCurrentWindow();
- if (currentWindow.label !== shellBallWindowLabels[role]) {
+ const targetLabel = role === "pinned" ? currentWindow.label : shellBallWindowLabels[role];
+
+ if (role === "pinned" && getShellBallPinnedBubbleIdFromLabel(targetLabel) === null) {
+ return;
+ }
+
+ if (role !== "pinned" && currentWindow.label !== targetLabel) {
return;
}
@@ -250,6 +349,20 @@ export function useShellBallHelperWindowSnapshot({ role }: ShellBallHelperSnapsh
}
cleanup = unlisten;
+
+ if (role === "pinned") {
+ const bubbleId = getShellBallPinnedBubbleIdFromLabel(targetLabel);
+
+ if (bubbleId !== null) {
+ void currentWindow.emitTo(shellBallWindowLabels.ball, shellBallWindowSyncEvents.pinnedWindowReady, {
+ windowLabel: targetLabel,
+ bubbleId,
+ });
+ }
+
+ return;
+ }
+
void currentWindow.emitTo(shellBallWindowLabels.ball, shellBallWindowSyncEvents.helperReady, { role });
});
@@ -294,3 +407,9 @@ export async function emitShellBallBubbleAction(
source,
});
}
+
+export async function emitShellBallPinnedWindowDetached(bubbleId: string) {
+ await getCurrentWindow().emitTo(shellBallWindowLabels.ball, shellBallWindowSyncEvents.pinnedWindowDetached, {
+ bubbleId,
+ });
+}
diff --git a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
index ab60321ff..5843c7b4e 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
@@ -13,6 +13,8 @@ import {
} from "../../platform/shellBallWindowController";
import { shellBallWindowSyncEvents, type ShellBallHelperWindowRole, type ShellBallWindowGeometry } from "./shellBall.windowSync";
+type AnchoredShellBallHelperWindowRole = Exclude;
+
export const SHELL_BALL_WINDOW_SAFE_MARGIN_PX = 12;
export const SHELL_BALL_WINDOW_GAP_PX = 12;
@@ -48,7 +50,7 @@ type ShellBallWindowBounds = {
};
type UseShellBallWindowMetricsInput = {
- role: "ball" | ShellBallHelperWindowRole;
+ role: "ball" | AnchoredShellBallHelperWindowRole;
visible?: boolean;
clickThrough?: boolean;
};
@@ -118,7 +120,7 @@ export function clampShellBallFrameToBounds(
}
export function getShellBallHelperWindowInteractionMode(input: {
- role: ShellBallHelperWindowRole;
+ role: AnchoredShellBallHelperWindowRole;
visible: boolean;
clickThrough: boolean;
}): ShellBallHelperWindowInteractionMode {
diff --git a/apps/desktop/src/platform/shellBallWindowController.ts b/apps/desktop/src/platform/shellBallWindowController.ts
index 961f011e2..0056a35cc 100644
--- a/apps/desktop/src/platform/shellBallWindowController.ts
+++ b/apps/desktop/src/platform/shellBallWindowController.ts
@@ -7,6 +7,15 @@ export const shellBallWindowLabels = Object.freeze({
input: "shell-ball-input",
});
+export const shellBallPinnedBubbleWindowLabelPrefix = "shell-ball-bubble-pinned-";
+
+export const SHELL_BALL_PINNED_BUBBLE_WINDOW_FRAME = Object.freeze({
+ width: 360,
+ height: 140,
+});
+
+export const SHELL_BALL_PINNED_BUBBLE_WINDOW_ANCHOR_OFFSET_PX = 24;
+
export const shellBallWindowPermissions = Object.freeze([
"core:window:allow-show",
"core:window:allow-hide",
@@ -22,19 +31,52 @@ export const shellBallWindowPermissions = Object.freeze([
export type ShellBallWindowRole = keyof typeof shellBallWindowLabels;
+export function getShellBallPinnedBubbleWindowLabel(bubbleId: string) {
+ return `${shellBallPinnedBubbleWindowLabelPrefix}${bubbleId}`;
+}
+
+export function isShellBallPinnedBubbleWindowLabel(label: string) {
+ return label.startsWith(shellBallPinnedBubbleWindowLabelPrefix);
+}
+
+export function getShellBallPinnedBubbleIdFromLabel(label: string) {
+ if (!isShellBallPinnedBubbleWindowLabel(label)) {
+ return null;
+ }
+
+ return label.slice(shellBallPinnedBubbleWindowLabelPrefix.length);
+}
+
+export function getShellBallPinnedBubbleWindowAnchor(input: {
+ bubbleAnchor: {
+ x: number;
+ y: number;
+ };
+}) {
+ return {
+ x: input.bubbleAnchor.x + SHELL_BALL_PINNED_BUBBLE_WINDOW_ANCHOR_OFFSET_PX,
+ y: input.bubbleAnchor.y + SHELL_BALL_PINNED_BUBBLE_WINDOW_ANCHOR_OFFSET_PX,
+ };
+}
+
export function getShellBallCurrentWindow() {
return getCurrentWindow();
}
-export async function getShellBallWindow(role: ShellBallWindowRole) {
+async function getShellBallWindowByLabel(label: string) {
const currentWindow = getCurrentWindow();
- const label = shellBallWindowLabels[role];
if (currentWindow.label === label) {
return currentWindow;
}
- const windowHandle = await Window.getByLabel(label);
+ return Window.getByLabel(label);
+}
+
+export async function getShellBallWindow(role: ShellBallWindowRole) {
+ const label = shellBallWindowLabels[role];
+
+ const windowHandle = await getShellBallWindowByLabel(label);
if (windowHandle === null) {
throw new Error(`Shell-ball window not found: ${label}`);
@@ -77,6 +119,70 @@ export async function startShellBallWindowDragging() {
await getCurrentWindow().startDragging();
}
+export async function openShellBallPinnedBubbleWindow(input: {
+ bubbleId: string;
+ position: {
+ x: number;
+ y: number;
+ };
+ size?: {
+ width: number;
+ height: number;
+ };
+}) {
+ const label = getShellBallPinnedBubbleWindowLabel(input.bubbleId);
+ const size = input.size ?? SHELL_BALL_PINNED_BUBBLE_WINDOW_FRAME;
+ const existingWindow = await getShellBallWindowByLabel(label);
+
+ if (existingWindow !== null) {
+ await existingWindow.setPosition(createShellBallLogicalPosition(input.position.x, input.position.y));
+ await existingWindow.setSize(createShellBallLogicalSize(size.width, size.height));
+ await existingWindow.show();
+ return label;
+ }
+
+ const pinnedWindowOptions = {
+ title: "CialloClaw Shell Ball Bubble",
+ url: "shell-ball-bubble-pinned.html",
+ x: input.position.x,
+ y: input.position.y,
+ width: size.width,
+ height: size.height,
+ focus: false,
+ visible: true,
+ transparent: true,
+ decorations: false,
+ alwaysOnTop: true,
+ resizable: false,
+ skipTaskbar: true,
+ shadow: false,
+ };
+
+ new Window(label, pinnedWindowOptions);
+
+ return label;
+}
+
+export async function closeShellBallPinnedBubbleWindow(bubbleId: string) {
+ const windowHandle = await getShellBallWindowByLabel(getShellBallPinnedBubbleWindowLabel(bubbleId));
+
+ if (windowHandle === null) {
+ return;
+ }
+
+ await windowHandle.destroy();
+}
+
+export async function emitToShellBallWindowLabel(label: string, event: string, payload?: T) {
+ const windowHandle = await getShellBallWindowByLabel(label);
+
+ if (windowHandle === null) {
+ return;
+ }
+
+ await getCurrentWindow().emitTo(label, event, payload);
+}
+
export function createShellBallLogicalSize(width: number, height: number) {
return new LogicalSize(width, height);
}
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index e5b994097..e584ab31b 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -18,6 +18,7 @@ export default defineConfig({
input: {
"shell-ball": resolve(currentDirectory, "shell-ball.html"),
"shell-ball-bubble": resolve(currentDirectory, "shell-ball-bubble.html"),
+ "shell-ball-bubble-pinned": resolve(currentDirectory, "shell-ball-bubble-pinned.html"),
"shell-ball-input": resolve(currentDirectory, "shell-ball-input.html"),
dashboard: resolve(currentDirectory, "dashboard.html"),
"control-panel": resolve(currentDirectory, "control-panel.html"),
From ec626c9d7a581a244909413f2f5ff85462265bc5 Mon Sep 17 00:00:00 2001
From: gdemonc <1502689916@qq.com>
Date: Sat, 11 Apr 2026 20:35:46 +0800
Subject: [PATCH 17/19] feat(desktop-shell-ball): restore pinned bubbles to
region order
---
.../shell-ball/shellBall.contract.test.ts | 261 ++++++++++++++++++
.../shell-ball/useShellBallCoordinator.ts | 44 ++-
2 files changed, 291 insertions(+), 14 deletions(-)
diff --git a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
index 3d58975ee..319445a56 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
@@ -2578,6 +2578,267 @@ test("shell-ball coordinator bubble actions pin and delete local items", () => {
assert.equal(unpinnedItems[0]?.bubble.pinned, false);
});
+test("shell-ball coordinator bubble actions restore unpinned bubbles by timestamp then id", () => {
+ const sourceItems: ShellBallBubbleItem[] = [
+ {
+ bubble: {
+ bubble_id: "msg-order-2",
+ task_id: "task-order-2",
+ type: "status",
+ text: "Pinned later twin.",
+ pinned: true,
+ hidden: false,
+ created_at: "2026-04-11T10:10:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "msg-order-3",
+ task_id: "task-order-3",
+ type: "result",
+ text: "Newest visible bubble.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:11:00.000Z",
+ },
+ role: "user",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "msg-order-1",
+ task_id: "task-order-1",
+ type: "status",
+ text: "Oldest visible bubble.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:09:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "msg-order-0",
+ task_id: "task-order-0",
+ type: "status",
+ text: "Pinned earlier twin.",
+ pinned: true,
+ hidden: false,
+ created_at: "2026-04-11T10:10:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ ];
+
+ const unpinnedItems = applyShellBallBubbleAction(sourceItems, {
+ action: "unpin",
+ bubbleId: "msg-order-2",
+ });
+
+ assert.deepEqual(
+ unpinnedItems.map((item) => ({
+ bubbleId: item.bubble.bubble_id,
+ pinned: item.bubble.pinned,
+ createdAt: item.bubble.created_at,
+ })),
+ [
+ {
+ bubbleId: "msg-order-1",
+ pinned: false,
+ createdAt: "2026-04-11T10:09:00.000Z",
+ },
+ {
+ bubbleId: "msg-order-0",
+ pinned: true,
+ createdAt: "2026-04-11T10:10:00.000Z",
+ },
+ {
+ bubbleId: "msg-order-2",
+ pinned: false,
+ createdAt: "2026-04-11T10:10:00.000Z",
+ },
+ {
+ bubbleId: "msg-order-3",
+ pinned: false,
+ createdAt: "2026-04-11T10:11:00.000Z",
+ },
+ ],
+ );
+});
+
+test("shell-ball detached bubble actions close pinned windows and delete detached bubbles entirely", () => {
+ const listeners = new Map void>();
+ const closeCalls: string[] = [];
+ let bubbleItemsState: ShellBallBubbleItem[] = [
+ {
+ bubble: {
+ bubble_id: "msg-detached-1",
+ task_id: "task-detached-1",
+ type: "status",
+ text: "Pinned bubble.",
+ pinned: true,
+ hidden: false,
+ created_at: "2026-04-11T10:10:00.000Z",
+ },
+ role: "agent",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ {
+ bubble: {
+ bubble_id: "msg-detached-2",
+ task_id: "task-detached-2",
+ type: "result",
+ text: "Keep me visible.",
+ pinned: false,
+ hidden: false,
+ created_at: "2026-04-11T10:11:00.000Z",
+ },
+ role: "user",
+ desktop: {
+ lifecycleState: "visible",
+ },
+ },
+ ];
+
+ const { useShellBallCoordinator } = withShellBallModuleRuntime("useShellBallCoordinator.ts", {
+ react: {
+ ...require("react"),
+ useEffect(callback: () => void) {
+ callback();
+ },
+ useMemo(factory: () => T) {
+ return factory();
+ },
+ useRef(value: T) {
+ return { current: value };
+ },
+ useState(value: T) {
+ const resolvedValue = typeof value === "function" ? (value as () => T)() : value;
+
+ if (
+ Array.isArray(resolvedValue) &&
+ resolvedValue.every((item) => item && typeof item === "object" && "bubble" in item) &&
+ bubbleItemsState.length === 0
+ ) {
+ bubbleItemsState = resolvedValue as ShellBallBubbleItem[];
+ }
+
+ return [bubbleItemsState as unknown as T || resolvedValue, (nextValue: T | ((currentValue: T) => T)) => {
+ bubbleItemsState = typeof nextValue === "function"
+ ? (nextValue as (currentValue: T) => T)(bubbleItemsState as unknown as T) as unknown as ShellBallBubbleItem[]
+ : nextValue as unknown as ShellBallBubbleItem[];
+ }] as const;
+ },
+ },
+ "@tauri-apps/api/window": {
+ getCurrentWindow() {
+ return {
+ label: shellBallWindowLabels.ball,
+ listen(eventName: string, callback: (event: { payload: unknown }) => void) {
+ listeners.set(eventName, callback);
+ return Promise.resolve(() => {});
+ },
+ onMoved() {
+ return Promise.resolve(() => {});
+ },
+ onResized() {
+ return Promise.resolve(() => {});
+ },
+ outerPosition() {
+ return Promise.resolve({ toLogical: () => ({ x: 0, y: 0 }) });
+ },
+ outerSize() {
+ return Promise.resolve({ toLogical: () => ({ width: 124, height: 104 }) });
+ },
+ scaleFactor() {
+ return Promise.resolve(1);
+ },
+ };
+ },
+ },
+ "../../platform/shellBallWindowController": {
+ SHELL_BALL_PINNED_BUBBLE_WINDOW_FRAME: { width: 240, height: 140 },
+ closeShellBallPinnedBubbleWindow(bubbleId: string) {
+ closeCalls.push(bubbleId);
+ return Promise.resolve();
+ },
+ emitToShellBallWindowLabel() {
+ return Promise.resolve();
+ },
+ getShellBallPinnedBubbleIdFromLabel() {
+ return null;
+ },
+ getShellBallPinnedBubbleWindowAnchor() {
+ return { x: 0, y: 0 };
+ },
+ getShellBallPinnedBubbleWindowLabel(bubbleId: string) {
+ return `shell-ball-bubble-pinned-${bubbleId}`;
+ },
+ openShellBallPinnedBubbleWindow() {
+ return Promise.resolve();
+ },
+ shellBallWindowLabels,
+ },
+ "./shellBall.bubble": require(resolve(desktopRoot, ".cache/shell-ball-tests/features/shell-ball/shellBall.bubble.js")),
+ "./shellBall.windowSync": require(resolve(desktopRoot, ".cache/shell-ball-tests/features/shell-ball/shellBall.windowSync.js")),
+ "./useShellBallWindowMetrics": {
+ getShellBallBubbleAnchor() {
+ return { x: 0, y: 0 };
+ },
+ },
+ }, (moduleExports) => moduleExports as { useShellBallCoordinator: typeof import("./useShellBallCoordinator").useShellBallCoordinator });
+
+ useShellBallCoordinator({
+ visualState: "hover_input",
+ inputValue: "",
+ voicePreview: null,
+ setInputValue: () => {},
+ onRegionEnter: () => {},
+ onRegionLeave: () => {},
+ onInputFocusChange: () => {},
+ onSubmitText: () => {},
+ onAttachFile: () => {},
+ onPrimaryClick: () => {},
+ });
+
+ listeners.get(shellBallWindowSyncEvents.pinnedWindowDetached)?.({
+ payload: { bubbleId: "msg-detached-1" },
+ });
+ listeners.get(shellBallWindowSyncEvents.bubbleAction)?.({
+ payload: { source: "pinned_window", action: "unpin", bubbleId: "msg-detached-1" },
+ });
+
+ assert.deepEqual(closeCalls, ["msg-detached-1"]);
+ assert.deepEqual(bubbleItemsState.map((item) => ({ bubbleId: item.bubble.bubble_id, pinned: item.bubble.pinned })), [
+ { bubbleId: "msg-detached-1", pinned: false },
+ { bubbleId: "msg-detached-2", pinned: false },
+ ]);
+
+ listeners.get(shellBallWindowSyncEvents.pinnedWindowDetached)?.({
+ payload: { bubbleId: "msg-detached-1" },
+ });
+ listeners.get(shellBallWindowSyncEvents.bubbleAction)?.({
+ payload: { source: "pinned_window", action: "delete", bubbleId: "msg-detached-1" },
+ });
+
+ assert.deepEqual(closeCalls, ["msg-detached-1", "msg-detached-1"]);
+ assert.deepEqual(bubbleItemsState.map((item) => item.bubble.bubble_id), ["msg-detached-2"]);
+});
+
test("shell-ball bubble actions stay coordinator-owned and detached-position free", () => {
const bubbleActionPayload = {
source: "pinned_window",
diff --git a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
index 4b799fcdc..871bbc702 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts
@@ -84,31 +84,47 @@ const SHELL_BALL_LOCAL_BUBBLE_ITEMS: ShellBallBubbleItem[] = [
},
];
+export function compareShellBallBubbleItemsByTimestamp(left: ShellBallBubbleItem, right: ShellBallBubbleItem) {
+ const createdAtOrder = left.bubble.created_at.localeCompare(right.bubble.created_at);
+
+ if (createdAtOrder !== 0) {
+ return createdAtOrder;
+ }
+
+ return left.bubble.bubble_id.localeCompare(right.bubble.bubble_id);
+}
+
+export function sortShellBallBubbleItemsByTimestamp(items: ShellBallBubbleItem[]) {
+ return [...items].sort(compareShellBallBubbleItemsByTimestamp);
+}
+
export function applyShellBallBubbleAction(
items: ShellBallBubbleItem[],
payload: Pick,
): ShellBallBubbleItem[] {
if (payload.action === "delete") {
- return items.filter((item) => item.bubble.bubble_id !== payload.bubbleId);
+ return sortShellBallBubbleItemsByTimestamp(items.filter((item) => item.bubble.bubble_id !== payload.bubbleId));
}
- return items.map((item) => {
- if (item.bubble.bubble_id !== payload.bubbleId) {
- return item;
- }
+ return sortShellBallBubbleItemsByTimestamp(
+ items.map((item) => {
+ if (item.bubble.bubble_id !== payload.bubbleId) {
+ return item;
+ }
- return {
- ...item,
- bubble: {
- ...item.bubble,
- pinned: payload.action === "pin",
- },
- };
- });
+ return {
+ ...item,
+ bubble: {
+ ...item.bubble,
+ pinned: payload.action === "pin",
+ },
+ };
+ }),
+ );
}
export function useShellBallCoordinator(input: ShellBallCoordinatorInput) {
- const [bubbleItems, setBubbleItems] = useState(() => cloneShellBallBubbleItems(SHELL_BALL_LOCAL_BUBBLE_ITEMS));
+ const [bubbleItems, setBubbleItems] = useState(() => sortShellBallBubbleItemsByTimestamp(cloneShellBallBubbleItems(SHELL_BALL_LOCAL_BUBBLE_ITEMS)));
const snapshot = useMemo(
() =>
createShellBallWindowSnapshot({
From d7ce857dea1f184c1d6626e294d5eafa3bc844e0 Mon Sep 17 00:00:00 2001
From: gdemonc <1502689916@qq.com>
Date: Sat, 11 Apr 2026 20:38:18 +0800
Subject: [PATCH 18/19] feat(desktop-shell-ball): tune bubble region anchor
---
.../src/features/shell-ball/shellBall.contract.test.ts | 10 +++++++---
.../features/shell-ball/useShellBallWindowMetrics.ts | 7 ++++---
2 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
index 319445a56..5bb776922 100644
--- a/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+++ b/apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
@@ -59,7 +59,8 @@ import {
import type { ShellBallBubbleItem } from "./shellBall.bubble";
import { cloneShellBallBubbleItems } from "./shellBall.bubble";
import {
- SHELL_BALL_WINDOW_GAP_PX,
+ SHELL_BALL_BUBBLE_GAP_PX,
+ SHELL_BALL_INPUT_GAP_PX,
SHELL_BALL_WINDOW_SAFE_MARGIN_PX,
clampShellBallFrameToBounds,
createShellBallWindowFrame,
@@ -1429,7 +1430,8 @@ test("shell-ball window snapshot copies bubble item arrays defensively", () => {
});
test("shell-ball window metrics compute safe frames and helper anchors", () => {
- assert.equal(SHELL_BALL_WINDOW_GAP_PX, 12);
+ assert.equal(SHELL_BALL_BUBBLE_GAP_PX, 6);
+ assert.equal(SHELL_BALL_INPUT_GAP_PX, 12);
assert.equal(SHELL_BALL_WINDOW_SAFE_MARGIN_PX, 12);
const ballFrame = createShellBallWindowFrame({ width: 100, height: 80 });
@@ -1453,10 +1455,12 @@ test("shell-ball window metrics compute safe frames and helper anchors", () => {
}),
{
x: 172,
- y: 198,
+ y: 204,
},
);
+ assert.equal(204 + 90 <= 300, true);
+
assert.deepEqual(
getShellBallInputAnchor({
ballFrame: {
diff --git a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
index 5843c7b4e..56d4020cc 100644
--- a/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
+++ b/apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts
@@ -16,7 +16,8 @@ import { shellBallWindowSyncEvents, type ShellBallHelperWindowRole, type ShellBa
type AnchoredShellBallHelperWindowRole = Exclude;
export const SHELL_BALL_WINDOW_SAFE_MARGIN_PX = 12;
-export const SHELL_BALL_WINDOW_GAP_PX = 12;
+export const SHELL_BALL_BUBBLE_GAP_PX = 6;
+export const SHELL_BALL_INPUT_GAP_PX = 12;
type ShellBallContentSize = {
width: number;
@@ -84,7 +85,7 @@ export function getShellBallBubbleAnchor(input: {
helperFrame: ShellBallWindowSize;
gap?: number;
}) {
- const gap = input.gap ?? SHELL_BALL_WINDOW_GAP_PX;
+ const gap = input.gap ?? SHELL_BALL_BUBBLE_GAP_PX;
return {
x: Math.round(input.ballFrame.x + input.ballFrame.width / 2 - input.helperFrame.width / 2),
@@ -97,7 +98,7 @@ export function getShellBallInputAnchor(input: {
helperFrame: ShellBallWindowSize;
gap?: number;
}) {
- const gap = input.gap ?? SHELL_BALL_WINDOW_GAP_PX;
+ const gap = input.gap ?? SHELL_BALL_INPUT_GAP_PX;
return {
x: Math.round(input.ballFrame.x + input.ballFrame.width / 2 - input.helperFrame.width / 2),
From eb40cb653a8eaace28bc558cbcd4dcdc21ab84b8 Mon Sep 17 00:00:00 2001
From: gdemonc <1502689916@qq.com>
Date: Sat, 11 Apr 2026 20:41:38 +0800
Subject: [PATCH 19/19] feat(desktop-shell-ball): add pinned bubble plan
---
...ubble-pinned-window-implementation-plan.md | 439 ++++++++++++++++++
1 file changed, 439 insertions(+)
create mode 100644 apps/desktop/docs/2026-04-11-shell-ball-bubble-pinned-window-implementation-plan.md
diff --git a/apps/desktop/docs/2026-04-11-shell-ball-bubble-pinned-window-implementation-plan.md b/apps/desktop/docs/2026-04-11-shell-ball-bubble-pinned-window-implementation-plan.md
new file mode 100644
index 000000000..fe806239a
--- /dev/null
+++ b/apps/desktop/docs/2026-04-11-shell-ball-bubble-pinned-window-implementation-plan.md
@@ -0,0 +1,439 @@
+# Shell-Ball Bubble Pinned Window 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:** Upgrade the shell-ball bubble system so bubble items align to protocol semantics, support per-bubble pin/delete controls, detach pinned bubbles into standalone draggable windows, and return unpinned bubbles to their timestamp order in the transparent bubble region.
+
+**Architecture:** Keep `packages/protocol` as the semantic source of truth for the bubble payload core by wrapping protocol `BubbleMessage` objects in a shell-ball-local desktop state envelope instead of inventing a second independent message model. The always-present `shell-ball-bubble` window renders only unpinned items in the bubble region, while each pinned item gets its own runtime-created detached window that survives bubble-region lifecycle rules until unpinned or deleted.
+
+**Tech Stack:** Tauri 2 window API, React 18, TypeScript, existing shell-ball coordinator/window sync layer, shell-ball contract test suite.
+
+---
+
+## File Map
+
+- Create: `apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts`
+- Create: `apps/desktop/src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx`
+- Create: `apps/desktop/src/app/shell-ball-bubble-pinned/main.tsx`
+- Create: `apps/desktop/shell-ball-bubble-pinned.html`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.bubble.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.windowSync.ts`
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts`
+- Modify: `apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx`
+- Modify: `apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx`
+- Modify: `apps/desktop/src/features/shell-ball/components/ShellBallBubbleMessage.tsx`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.css`
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts`
+- Modify: `apps/desktop/src/platform/shellBallWindowController.ts`
+- Modify: `apps/desktop/vite.config.ts`
+- Modify: `apps/desktop/src-tauri/capabilities/default.json`
+- Modify: `apps/desktop/src-tauri/gen/schemas/capabilities.json`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+## Target Model
+
+The follow-up implementation should replace the current ad hoc bubble item shape with a protocol-wrapped desktop bubble item:
+
+```ts
+type ShellBallBubbleRole = "user" | "agent"
+
+type ShellBallBubbleDesktopState = {
+ detachedWindowId?: string
+ lifecycleState: "visible" | "fading" | "hidden"
+ freshnessHint?: "fresh" | "stale"
+ motionHint?: "settle"
+}
+
+type ShellBallBubbleItem = {
+ bubble: BubbleMessage
+ role: ShellBallBubbleRole
+ desktop: ShellBallBubbleDesktopState
+}
+```
+
+Rules:
+
+- `bubble` carries the protocol-aligned payload core (`bubble_id`, `task_id`, `type`, `text`, `pinned`, `hidden`, `created_at`).
+- `role` remains a shell-ball UI concern until protocol adds role semantics.
+- `desktop` is shell-ball-local and must not be pushed into `packages/protocol`.
+- Bubble region renders items with `bubble.hidden === false && bubble.pinned === false`.
+- Pinned windows render items with `bubble.pinned === true`.
+
+## Window Interaction and Runtime Rules
+
+- `shell-ball-bubble` stays structurally present as the bubble-region window, but its interaction mode depends on content state:
+ - when there are no visible unpinned bubbles, it remains transparent and click-through
+ - when visible unpinned bubbles exist, it must disable click-through so pin/delete controls are usable
+- pinned windows use deterministic labels: `shell-ball-bubble-pinned-`
+- pinned windows receive one `ShellBallBubbleItem` snapshot payload and return actions through shell-ball-local event names such as:
+ - `desktop-shell-ball:bubble-pin-toggle`
+ - `desktop-shell-ball:bubble-delete`
+- pinned windows spawn from a named pinned-window anchor near the bubble region before first drag
+- once the user drags a pinned window, it becomes free-positioned and no longer follows shell-ball or bubble-region geometry updates
+- only the main bubble region continues using the shared helper-window metrics/anchor flow
+- unpin ordering is stable by `bubble.created_at`, then `bubble.bubble_id`
+- bubble-region offset tuning must use named constants in the metrics layer and be verified against the ball frame so the region moves closer without overlapping the mascot window
+
+## Coordinator Ownership Contract
+
+- The coordinator is the single source of truth for bubble-item existence, ordering, `bubble.pinned`, and delete/unpin state transitions.
+- Pinned windows only render snapshot data from the coordinator; they do not own independent bubble-item state.
+- All `pin`, `unpin`, and `delete` actions round-trip through the coordinator before UI state changes are considered final.
+- Detached position may live in local desktop/window state after the first user drag, but bubble-item existence and region ordering remain coordinator-owned.
+
+## Behavioral Constraints
+
+- The bubble region window always exists, but its background remains fully transparent.
+- If there are no visible unpinned bubbles, the user sees nothing in the bubble region.
+- Every bubble item has two controls:
+ - top-left pin toggle
+ - top-right delete
+- Delete always destroys the bubble item immediately.
+- Pin removes the bubble item from the bubble region and creates a standalone detached pinned window.
+- Detached pinned windows:
+ - are draggable
+ - stay always-on-top
+ - do not participate in bubble fade/hide/disperse behavior
+- Unpin destroys the detached window and returns the bubble item to the bubble region sorted by `bubble.created_at`, then `bubble.bubble_id`.
+- Bubble region offset must be defined by named constants and verified against the ball frame so it moves closer without overlap.
+- Bubble region has no fixed item count limit; visibility is governed by space + scrolling only.
+
+### Task 1: Refactor Bubble Data to Protocol-Wrapped Items
+
+**Files:**
+- Create: `apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.bubble.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.windowSync.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+- [ ] **Step 1: Write the failing contract test**
+
+Add tests asserting that shell-ball bubble data now wraps protocol `BubbleMessage` instead of exposing only front-end-local fields, while still preserving shell-ball-local `role` and desktop state.
+
+Cover:
+- `ShellBallBubbleItem.bubble` matches protocol field names
+- `role` stays outside protocol payload
+- `desktop` carries shell-ball-local lifecycle/window state
+- snapshot payload now uses `bubbleItems` instead of `bubbleMessages`
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: FAIL because current bubble model still uses `bubbleMessages` and front-end-only fields.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts` for shell-ball-local desktop extensions and mapping helpers.
+
+Refactor `apps/desktop/src/features/shell-ball/shellBall.bubble.ts` so the exported type becomes protocol-wrapped `ShellBallBubbleItem`.
+
+Update `apps/desktop/src/features/shell-ball/shellBall.windowSync.ts` to carry `bubbleItems` in the snapshot.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS for the protocol-wrapped bubble item contract.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/features/shell-ball/shellBallBubbleDesktop.ts apps/desktop/src/features/shell-ball/shellBall.bubble.ts apps/desktop/src/features/shell-ball/shellBall.windowSync.ts apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+git commit -m "feat(desktop-shell-ball): align bubble items to protocol payloads"
+```
+
+### Task 2: Verify Bubble Region Window Existence Strategy First
+
+**Files:**
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.windowSync.ts`
+- Modify: `apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx`
+- Modify: `apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx`
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+- [ ] **Step 1: Write the failing contract test**
+
+Add tests asserting that:
+- the bubble helper window existence strategy is explicit and verified independently of rendering details
+- the bubble helper window is no longer gated purely by `visualState`
+- the snapshot/visibility contract distinguishes bubble-window existence from empty bubble-region content
+- it receives a flat `bubbleItems` list
+- the visible list excludes `bubble.pinned === true`
+- the empty region stays structurally present but visually transparent and click-through when there are no visible unpinned bubbles
+- non-empty bubble regions disable click-through so pin/delete controls are usable
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: FAIL because current visibility/rendering still assumes the earlier message-only model and the existence strategy is not contract-defined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Update the coordinator to produce the new `bubbleItems` snapshot.
+
+Update `shellBall.windowSync.ts` so bubble-region persistence is not driven only by `visualState`.
+
+Update `ShellBallBubbleWindow.tsx`, `ShellBallBubbleZone.tsx`, and `useShellBallWindowMetrics.ts` so the bubble region only renders unpinned items while the bubble window existence strategy is explicit and the interaction mode follows content state.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS for the chosen existence strategy contract and unpinned-only rendering.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts apps/desktop/src/features/shell-ball/shellBall.windowSync.ts apps/desktop/src/features/shell-ball/ShellBallBubbleWindow.tsx apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+git commit -m "feat(desktop-shell-ball): define bubble window existence strategy"
+```
+
+### Task 3: Add Per-Bubble Pin and Delete Controls
+
+**Files:**
+- Modify: `apps/desktop/src/features/shell-ball/components/ShellBallBubbleMessage.tsx`
+- Modify: `apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx`
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts`
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+- [ ] **Step 1: Write the failing render/behavior test**
+
+Add tests asserting that each bubble renders:
+- a pin control at top-left
+- a delete control at top-right
+
+And that the coordinator exposes handlers that:
+- set `bubble.pinned = true` on pin
+- remove the bubble item on delete
+- switch the bubble window out of click-through mode when visible unpinned bubbles remain
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: FAIL because bubble items do not yet have controls or state transitions.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add pin/delete controls to `ShellBallBubbleMessage.tsx`.
+
+Thread handlers through `ShellBallBubbleZone.tsx`, implement the state transitions in the coordinator, and update the bubble window metrics path so the bubble window interaction mode follows visible content.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS for pin/delete controls and local state transitions.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/features/shell-ball/components/ShellBallBubbleMessage.tsx apps/desktop/src/features/shell-ball/components/ShellBallBubbleZone.tsx apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+git commit -m "feat(desktop-shell-ball): add bubble pin and delete controls"
+```
+
+### Task 4: Add Coordinator Ownership Contract for Pinned Bubble State
+
+**Files:**
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.windowSync.ts`
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+- [ ] **Step 1: Write the failing ownership contract test**
+
+Add tests asserting that:
+- pinned windows only render snapshot payloads from the coordinator
+- all `pin`, `unpin`, and `delete` actions round-trip through coordinator-owned events/state transitions
+- detached position may be local desktop state, but bubble-item existence/order is coordinator-owned
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: FAIL because pinned-window ownership and action round-trip rules are not explicit yet.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Extend `shellBall.windowSync.ts` with the coordinator-owned pinned-window event contract.
+
+Update `useShellBallCoordinator.ts` to become the explicit source of truth for pin/unpin/delete state transitions before detached window implementation begins.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS for pinned-window ownership/state contract.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/features/shell-ball/shellBall.windowSync.ts apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+git commit -m "feat(desktop-shell-ball): define pinned bubble state ownership"
+```
+
+### Task 5: Create Detached Pinned Bubble Windows
+
+**Files:**
+- Create: `apps/desktop/src/app/shell-ball-bubble-pinned/main.tsx`
+- Create: `apps/desktop/shell-ball-bubble-pinned.html`
+- Create: `apps/desktop/src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx`
+- Modify: `apps/desktop/vite.config.ts`
+- Modify: `apps/desktop/src/platform/shellBallWindowController.ts`
+- Modify: `apps/desktop/src-tauri/capabilities/default.json`
+- Modify: `apps/desktop/src-tauri/gen/schemas/capabilities.json`
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.windowSync.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+- [ ] **Step 1: Write the failing window contract test**
+
+Add tests asserting that pinning a bubble:
+- assigns a deterministic pinned-window label using `bubble.bubble_id`
+- creates a detached window label for the bubble item
+- removes it from the bubble region view
+- renders the bubble item in a dedicated pinned-bubble window
+- keeps the pinned item outside normal bubble lifecycle rules
+- routes unpin/delete actions back through shell-ball-local pinned-window events
+- spawns the pinned window from a defined initial anchor near the bubble region
+- stops following shell-ball geometry after the user drags it
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: FAIL because pinned windows do not exist yet.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add the dedicated pinned bubble entrypoint and component.
+
+Extend `shellBallWindowController.ts` with runtime create/open/close helpers for pinned bubble windows.
+
+Extend `shellBall.windowSync.ts` with pinned-window event names and payloads.
+
+Update capabilities to allow the required create/show/hide/focus/drag behavior.
+
+Implement the pinned-window geometry contract explicitly:
+- initial spawn anchor comes from a named pinned-window anchor near the bubble region
+- free-dragged pinned windows stop following the shell-ball metrics flow after user drag
+- only the main bubble region remains on the shared helper-window metrics flow
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS for detached pinned-window behavior.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/app/shell-ball-bubble-pinned/main.tsx apps/desktop/shell-ball-bubble-pinned.html apps/desktop/src/features/shell-ball/ShellBallPinnedBubbleWindow.tsx apps/desktop/vite.config.ts apps/desktop/src/platform/shellBallWindowController.ts apps/desktop/src-tauri/capabilities/default.json apps/desktop/src-tauri/gen/schemas/capabilities.json apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts apps/desktop/src/features/shell-ball/shellBall.windowSync.ts apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+git commit -m "feat(desktop-shell-ball): detach pinned bubbles into windows"
+```
+
+### Task 6: Support Unpin Return-by-Timestamp and Detached Delete Semantics
+
+**Files:**
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+- [ ] **Step 1: Write the failing behavior/style test**
+
+Add tests asserting that:
+- unpinning destroys the detached window and restores the bubble item into bubble-region order by `bubble.created_at`, then `bubble.bubble_id`
+- deleting from a detached window destroys the bubble item entirely
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: FAIL because unpin restore ordering and detached delete semantics are not implemented yet.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Implement unpin restore ordering in the coordinator using `bubble.created_at`, then `bubble.bubble_id`.
+
+Finish the detached delete path.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS for unpin return and detached delete behavior.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/features/shell-ball/useShellBallCoordinator.ts apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+git commit -m "feat(desktop-shell-ball): restore pinned bubbles to region order"
+```
+
+### Task 7: Tune Bubble-Region Offset Closer to the Shell-Ball
+
+**Files:**
+- Modify: `apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.css`
+- Modify: `apps/desktop/src/features/shell-ball/shellBall.contract.test.ts`
+
+- [ ] **Step 1: Write the failing offset contract test**
+
+Add tests asserting that:
+- bubble region anchor uses named constants
+- the region is closer to the shell-ball than the current baseline
+- the chosen offset still does not overlap the ball frame
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: FAIL because the old anchor gap constants still reflect the previous offset.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Tune the bubble-region anchor gap in `useShellBallWindowMetrics.ts` and any supporting CSS constants so the region sits lower and closer to the shell-ball using named constants and verified no-overlap behavior.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS for bubble-region offset tuning.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/features/shell-ball/useShellBallWindowMetrics.ts apps/desktop/src/features/shell-ball/shellBall.css apps/desktop/src/features/shell-ball/shellBall.contract.test.ts
+git commit -m "feat(desktop-shell-ball): tune bubble region anchor"
+```
+
+### Task 8: Verify Bubble Region and Detached Window Behavior End-to-End
+
+**Files:**
+- Verify only
+
+- [ ] **Step 1: Run shell-ball tests**
+
+Run: `pnpm --dir apps/desktop test:shell-ball`
+Expected: PASS.
+
+- [ ] **Step 2: Run typecheck**
+
+Run: `pnpm --dir apps/desktop typecheck`
+Expected: PASS.
+
+- [ ] **Step 3: Run build**
+
+Run: `pnpm --dir apps/desktop build`
+Expected: PASS.
+
+- [ ] **Step 4: Run the desktop app manually**
+
+Run: `pnpm --dir apps/desktop exec tauri dev`
+
+Expected:
+- bubble region stays visually transparent when empty
+- each new bubble appears in timestamp order within the bubble region
+- pin creates a detached window and removes the bubble from the bubble region
+- detached windows are draggable and stay alive regardless of bubble fade/disperse behavior
+- unpin returns the bubble to the region at its timestamp position
+- delete removes the bubble immediately from either location
+- bubble region sits closer to the shell-ball without overlapping it
+
+- [ ] **Step 5: Commit only if manual verification required a follow-up fix**
+
+Run: `git status --short`
+Expected: clean working tree; if not, commit with:
+
+```bash
+git add
+git commit -m "feat(desktop-shell-ball): polish pinned bubble behavior"
+```