3 Commits
Author SHA1 Message Date
JianMiauandClaude Fable 5 4e4563e861 完成時氣泡改顯示最後一句回覆,不再延到下一輪才出現
根本原因:
Stop 事件時 main.js 已從 transcript 附上最後一句助理訊息(narration),
但 renderer 的 Stop 分支寫死顯示「完成!」沒使用它,也沒把 narrationId
標記為已顯示;下一輪第一個工具事件呼叫 sayActivity 時,transcript 裡
最新的助理訊息仍是上一輪結尾、id 又沒被消費過,就被當成新話顯示出來。

影響:
對話收尾只看得到「完成!」,看不到 AI 的最後一句回覆;等使用者送出
下一句指令後,寵物反而冒出上一輪的舊訊息,時序錯亂。

修法:
Stop 時若帶有 narration,氣泡直接顯示「 最後一句」並消費 narrationId,
沒有才退回「完成!」;UserPromptSubmit 也先把當下 narrationId 標記為已
顯示,雙重保險。另修 release.js 只挑檔名含當前版本的產物,避免 dist/
殘留的舊版檔案混進新 release。版號 2.0.9。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:40:32 +08:00
JianMiauandClaude Fable 5 7060571df1 修正被 VS Code 蓋住:定期重新宣告置頂
摘要:
每 2 秒重新宣告一次置頂,並在螢幕配置變動與啟動後各補一次。

根本原因:
Windows 會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元。
以 Win32 檢查實際情況:寵物視窗的 exStyle 是 0x08280028(含 WS_EX_TOPMOST),
但 z-order 排在 Z=4,而兩個 TOPMOST=false 的 VS Code 視窗排在 Z=1 與 Z=2,
也就是它們蓋在寵物上面。Electron 這邊仍以為自己是置頂的,
setAlwaysOnTop 已經在建立視窗時與勾選選單時都設過,所以不會自己修好。

影響:
寵物被 VS Code 之類的一般視窗蓋住,只能重啟或重新勾一次選單的「永遠置頂」。

修法:
- 新增 reassertTopmost():setAlwaysOnTop(true, "screen-saver") 之後再呼叫 moveTop()。
  單靠 setAlwaysOnTop 不夠——狀態沒變時它可能不會真的呼叫 SetWindowPos;
  moveTop() 會強制 SetWindowPos,對 WS_EX_TOPMOST 的視窗即為插回 topmost band 最上面。
  視窗是 WS_EX_NOACTIVATE + focusable:false(已用 Win32 確認),不會搶焦點。
- 每 TOPMOST_REASSERT_MS(2000)呼叫一次;display-metrics-changed / added / removed
  也各接一次,因為螢幕配置變動最容易觸發降級。
- ready-to-show 的 showInactive 後補一次;選單勾選置頂時也補一次。
- 拖曳中(dragTimer 存在)跳過,避免和每 16 ms 的位置更新打架。
- 結束時清掉 timer。

驗證:
- 先從外部對執行中的視窗呼叫 SetWindowPos(HWND_TOPMOST) 確認這正是有效解:
  寵物由 Z=4 移到 Z=0,兩個 VS Code 視窗退到 Z=5、Z=6。
- 再以改好的版本實測守護行為,用 SetWindowPos(HWND_BOTTOM) 模擬 Windows 的降級:
  基準 petZ=0 TOPMOST=true 上方 0 個;強制降級後 petZ=17 TOPMOST=false 上方 4 個
  (VS Code 兩個、CM 等);1 秒後回到 petZ=0 TOPMOST=true 上方 0 個;3 秒後維持。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.8 與八項改動都在出貨檔案裡。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:36:36 +08:00
JianMiauandClaude Fable 5 cd4b618be0 補上遺漏的氣泡標題列
摘要:
整理記憶中、需要你授權、工具失敗、錯誤訊息、一般通知、掰掰這六個氣泡補上第一行標題。

根本原因:
上一版把氣泡改成兩行時,是以「原本帶(專案名)尾綴」為線索去改呼叫點,
但這六個訊息本來就沒有尾綴,所以整批漏掉,顯示出來只有單行、沒有專案或對話名稱。

影響:
壓縮記憶、等授權、出錯這些最需要知道是哪個 session 的時候,反而看不到來源。

修法:
- handleEvent 內六個 say() 補上 title 參數。
- 選單「動作測試」的氣泡給標題「動作測試」,才分得出是測試不是真的事件。
- first-awake 的「嗨,我是〈寵物名〉」維持沒有標題:它不屬於任何 session,
  訊息本身已含寵物名,CSS 的 :empty 會把空標題整行收起來,不會留空白。

驗證:
以 DOM stub 載入完整 renderer.js 驅動 handleEvent,11 項全過:
PreCompact 的標題與訊息、其餘五個補上的事件都帶標題、原本就有的「完成」仍正常、
對話名稱優先於專案名、無 cwd 也無對話名時標題為空字串、滑鼠停留提示為兩行。
另以靜態掃描確認 handleEvent 內全部 13 個 say() 都帶 title,
並在打包後的 app.asar 上重跑同一項掃描。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:06:50 +08:00
6 changed files with 53 additions and 13 deletions
+1
View File
@@ -243,6 +243,7 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`renderer 讀 canvas 該點的 alpha(或是否壓在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。判定的座標有兩個來源:renderer 自己的 mousemove,以及**主程序每 50 ms 的游標輪詢**。後者是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到),只靠 mousemove 會在「游標還停在寵物身上時進入點穿」之後再也收不到事件,變成永遠卡在點穿、按不下去也拖不動。另外每一格畫完也會用最後已知座標重驗一次,因為換格會改變游標底下的像素。
- **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。
- **置頂要定期重新宣告**:Windows 有時候會把視窗踢出 topmost band,卻留著 `WS_EX_TOPMOST` 樣式位元——從外部檢查看到 `TOPMOST=true`,但 z-order 排在一般視窗(例如 VS Code)之下,寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。所以每 2 秒重新宣告一次,並在螢幕配置變動時立刻補一次。`setAlwaysOnTop` 在狀態沒變時可能不會真的呼叫 `SetWindowPos`,因此再補一個 `moveTop()` 強制插回 topmost band 最上面;視窗是 `WS_EX_NOACTIVATE` + `focusable: false`,不會搶焦點。
- **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。
- **顯示尺寸與 sprite 來源尺寸分開**:`CELL_W/CELL_H`192 × 208)只用來從 spritesheet 取格子,畫到畫面上的是 `PET_W/PET_H`126 × 137Codex 的 mascot 尺寸)。視窗寬度另有 `MIN_WIN_W = 240` 的下限,免得寵物變小後氣泡被截掉。
- **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。
+30 -1
View File
@@ -37,6 +37,7 @@ const PAD_BOTTOM = 8;
const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身)
// Codex 的 mascot 寬度可調範圍是 80224 pxike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內
const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75];
const TOPMOST_REASSERT_MS = 2000; // 多久重新宣告一次置頂(見 reassertTopmost
const DEFAULT_CONFIG = {
activePetId: "xiao-nian",
@@ -55,6 +56,7 @@ let tray = null;
let pets = [];
let dragTimer = null;
let cursorTimer = null;
let topmostTimer = null;
let lastCursor = { x: NaN, y: NaN };
let currentState = { anim: "idle", base: "idle", sessions: 0 };
@@ -279,7 +281,10 @@ function createWindow() {
if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
win.setIgnoreMouseEvents(true, { forward: true });
win.loadFile(path.join(APP_DIR, "renderer", "index.html"));
win.once("ready-to-show", () => win.showInactive());
win.once("ready-to-show", () => {
win.showInactive();
reassertTopmost();
});
win.on("closed", () => { win = null; });
}
@@ -326,6 +331,27 @@ function endDrag(save) {
// ---------- cursor look ----------
// Windows 有時候會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元:
// 從外部檢查會看到 TOPMOST=true,但 z-order 排在一般視窗(例如 VS Code)之下,
// 於是寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。
// setAlwaysOnTop 在狀態沒變時可能不會真的呼叫 SetWindowPos,所以再補一個 moveTop()
// 強制重新插回 topmost band 的最上面(視窗是 WS_EX_NOACTIVATE + focusable:false,不會搶焦點)。
function reassertTopmost() {
if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer) return;
try {
win.setAlwaysOnTop(true, "screen-saver");
win.moveTop();
} catch { /* 視窗正在關閉就算了 */ }
}
function startTopmostGuard() {
topmostTimer = setInterval(reassertTopmost, TOPMOST_REASSERT_MS);
// 螢幕配置變動最容易觸發降級,變動後立刻補一次
screen.on("display-metrics-changed", reassertTopmost);
screen.on("display-added", reassertTopmost);
screen.on("display-removed", reassertTopmost);
}
function startCursorPolling() {
cursorTimer = setInterval(() => {
if (!win || dragTimer) return;
@@ -488,6 +514,7 @@ function buildMenu() {
config.alwaysOnTop = item.checked;
saveConfig();
win?.setAlwaysOnTop(item.checked, "screen-saver");
if (item.checked) reassertTopmost();
},
},
{
@@ -605,6 +632,7 @@ if (!app.requestSingleInstanceLock()) {
createTray();
startServer();
startCursorPolling();
startTopmostGuard();
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
});
@@ -614,6 +642,7 @@ if (!app.requestSingleInstanceLock()) {
app.on("before-quit", () => {
if (cursorTimer) clearInterval(cursorTimer);
if (topmostTimer) clearInterval(topmostTimer);
endDrag(false);
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-pet",
"version": "2.0.6",
"version": "2.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-pet",
"version": "2.0.6",
"version": "2.0.9",
"devDependencies": {
"electron": "^43.4.1",
"electron-builder": "^26.0.12"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-pet",
"version": "2.0.6",
"version": "2.0.9",
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 29 個動作列 + 16 方向追視)",
"main": "main.js",
"private": true,
+16 -7
View File
@@ -409,6 +409,9 @@ function handleEvent(p) {
break;
case "UserPromptSubmit":
setState(s, "running");
// 這時 transcript 裡最新的助理訊息還是上一輪的結尾,
// 先標記成已顯示,免得接下來的工具事件把它當成新話講出來
if (p.narrationId) lastNarrationId = p.narrationId;
hideBubble();
break;
case "PreToolUse":
@@ -429,11 +432,11 @@ function handleEvent(p) {
case "PostToolUseFailure":
// Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡
setState(s, "running");
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000, false, title);
break;
case "PermissionRequest":
setState(s, "waiting");
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true);
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true, title);
break;
case "Notification": {
const t = p.notificationType;
@@ -447,7 +450,7 @@ function handleEvent(p) {
setState(s, "waiting");
say("❓ 有問題想問你", 0, true, title);
} else if (p.message) {
say(`💬 ${p.message}`, 4000);
say(`💬 ${p.message}`, 4000, false, title);
}
break;
}
@@ -456,12 +459,18 @@ function handleEvent(p) {
// Codexturn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
setState(s, "review");
// 收尾時把最後一句回覆講出來,而不是只說「完成」
if (p.narration) {
lastNarrationId = p.narrationId || lastNarrationId;
say(`${firstSentence(p.narration)}`, 8000, false, title);
} else {
say("✅ 完成!", 8000, false, title);
}
break;
case "StopFailure":
// Codexturn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
setState(s, "failed");
say(`${p.error || "出錯了"}`, 8000);
say(`${p.error || "出錯了"}`, 8000, false, title);
break;
case "SubagentStart":
case "SubagentStop":
@@ -469,13 +478,13 @@ function handleEvent(p) {
break;
case "PreCompact":
setState(s, "running");
say("🗜️ 整理記憶中…", 3000);
say("🗜️ 整理記憶中…", 3000, false, title);
break;
case "SessionEnd":
sessions.delete(p.sessionId || "default");
if (sessions.size === 0) {
hideBubble();
say("👋 掰掰", 2000);
say("👋 掰掰", 2000, false, title);
}
break;
default:
@@ -495,7 +504,7 @@ function handleTest(p) {
} else if (anim in PRIORITY) {
const s = session("test");
setState(s, anim);
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true);
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true, "動作測試");
else if (anim === "idle") hideBubble();
}
report();
+2 -1
View File
@@ -144,8 +144,9 @@ async function main() {
console.log(`Tag : ${TAG}`);
if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`);
// dist/ 可能留著前幾版的產物,只挑檔名含當前版本的,免得舊檔混進新 release
const files = fs.readdirSync(DIST_DIR)
.filter((f) => ASSET_PATTERNS.some((re) => re.test(f)))
.filter((f) => ASSET_PATTERNS.some((re) => re.test(f)) && f.includes(pkg.version))
.map((f) => path.join(DIST_DIR, f))
.filter((f) => fs.statSync(f).isFile());
if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`);