From e9597c08983d0344971c8b57ed4c8eec56d7f6a4 Mon Sep 17 00:00:00 2001 From: GuanLdong Date: Thu, 22 Oct 2026 22:40:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(prism):=20add=20Prism=20v0.6.0=20=E2=80=94?= =?UTF-8?q?=20per-project=20colors/icons/aliases,=20automation=20colors,?= =?UTF-8?q?=20recency=20ordering;=20bundle=20patching=20strictly=20opt-in?= =?UTF-8?q?=20(experimental,=20shim=20v14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon-sources.json | 8 + assets/prism/icon.png | Bin 0 -> 17200 bytes .../prism/.zcode-plugin/plugin.json | 48 + external_plugins/prism/LICENSE | 25 + external_plugins/prism/README.md | 79 + external_plugins/prism/README_CN.md | 79 + external_plugins/prism/commands/status.md | 11 + external_plugins/prism/hooks/asar-lite.js | 502 ++++++ external_plugins/prism/hooks/ensure-shim.js | 264 +++ external_plugins/prism/hooks/hooks.json | 16 + external_plugins/prism/renderer/prism.js | 1584 +++++++++++++++++ marketplace.json | 29 + 12 files changed, 2645 insertions(+) create mode 100644 assets/prism/icon.png create mode 100644 external_plugins/prism/.zcode-plugin/plugin.json create mode 100644 external_plugins/prism/LICENSE create mode 100644 external_plugins/prism/README.md create mode 100644 external_plugins/prism/README_CN.md create mode 100644 external_plugins/prism/commands/status.md create mode 100644 external_plugins/prism/hooks/asar-lite.js create mode 100644 external_plugins/prism/hooks/ensure-shim.js create mode 100644 external_plugins/prism/hooks/hooks.json create mode 100644 external_plugins/prism/renderer/prism.js diff --git a/assets/icon-sources.json b/assets/icon-sources.json index 9a1fd00e..d3db07c2 100644 --- a/assets/icon-sources.json +++ b/assets/icon-sources.json @@ -2552,5 +2552,13 @@ "homepage": "https://www.liblib.tv", "mimeType": "image/png", "sha256": "ada9717d540928baec64105056b93e8e8bc6cfbff145f9f3a9eadb6dd9b90689" + }, + { + "name": "prism", + "icon": "prism/icon.png", + "source": "https://raw.githubusercontent.com/saralaaga/prism/main/prism/assets/icon.svg", + "matchedBy": "official-repository-asset", + "mimeType": "image/png", + "sha256": "a8d57df3856a7e0088f9f7a42d7daa588c3c729b94841ec9c96000bb75233cfa" } ] diff --git a/assets/prism/icon.png b/assets/prism/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b268c20cf7acc2e4ac4fa1b4dae48f196b977893 GIT binary patch literal 17200 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 I5m4~|0Ota6LjV8( literal 0 HcmV?d00001 diff --git a/external_plugins/prism/.zcode-plugin/plugin.json b/external_plugins/prism/.zcode-plugin/plugin.json new file mode 100644 index 00000000..09ab913f --- /dev/null +++ b/external_plugins/prism/.zcode-plugin/plugin.json @@ -0,0 +1,48 @@ +{ + "name": "prism", + "description": "EXPERIMENTAL, OPT-IN: give every project in the ZCode desktop sidebar its own color, icon and display name, recolor individual conversations and automation cards, and keep the sidebar ordered by the most recently active conversation. Nothing is modified until you create the opt-in marker (~/.zcode/prism-opt-in); a SessionStart hook then installs a small renderer shim into the ZCode.app bundle (macOS), re-applies it after app updates, and automatically restores the original bundle once the marker is removed.", + "description_i18n": { + "en": "EXPERIMENTAL, OPT-IN: give every project in the ZCode desktop sidebar its own color, icon and display name, recolor individual conversations and automation cards, and keep the sidebar ordered by the most recently active conversation. Nothing is modified until you create the opt-in marker (~/.zcode/prism-opt-in); a SessionStart hook then installs a small renderer shim into the ZCode.app bundle (macOS), re-applies it after app updates, and automatically restores the original bundle once the marker is removed.", + "zh-CN": "实验性、需显式开启:为 ZCode 桌面端侧边栏的每个项目提供专属颜色、图标与显示名,支持单条对话与自动化卡片换色,并可让侧边栏按最近活跃自动排序。创建开启标记(~/.zcode/prism-opt-in)前不做任何修改;开启后 SessionStart 钩子向 ZCode.app 包内安装轻量渲染层 shim(仅限 macOS,应用更新后自动恢复),移除标记即自动还原原始应用包。" + }, + "version": "0.6.0", + "author": { + "name": "Carlos", + "url": "https://github.com/saralaaga" + }, + "license": "MIT", + "keywords": [ + "sidebar", + "colors", + "icons", + "rename", + "appearance", + "customization" + ], + "userConfig": { + "dimTitles": { + "title": "压暗对话标题", + "description": "会话标题以 60% 不透明度显示;取色盘里的开关可临时覆盖此设置", + "type": "boolean", + "default": true + }, + "brightenThinking": { + "title": "调亮思考扫光", + "description": "提升思考/流式动画在深色主题下的可见度;取色盘开关可临时覆盖", + "type": "boolean", + "default": true + }, + "autoColor": { + "title": "自动配色(未指定的项目)", + "description": "未手动指定颜色的项目按路径哈希自动分配色相;默认关闭,只有手动指定的项目染色", + "type": "boolean", + "default": false + }, + "groupRecency": { + "title": "侧边栏按最近活跃排序", + "description": "对话按最后活跃时间排序、项目/分组按最新对话排序;纯显示层重排,不影响手动拖拽顺序", + "type": "boolean", + "default": true + } + } +} diff --git a/external_plugins/prism/LICENSE b/external_plugins/prism/LICENSE new file mode 100644 index 00000000..d7da0c2f --- /dev/null +++ b/external_plugins/prism/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2026 Carlos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Embedded icon path data is from Lucide (https://lucide.dev), licensed ISC. diff --git a/external_plugins/prism/README.md b/external_plugins/prism/README.md new file mode 100644 index 00000000..d477fa6d --- /dev/null +++ b/external_plugins/prism/README.md @@ -0,0 +1,79 @@ +# Prism + +> A beam of white light enters the prism and comes out as every color of the rainbow. + +**Prism** is a desktop-side customization plugin for [ZCode](https://zcode.z.ai): it gives every project in the left sidebar its own color, icon and display name, lets you recolor individual conversations and automation cards, and can keep the sidebar ordered by the most recently active conversation. + +> ⚠️ **Experimental, opt-in (0.6.0+)**: Prism restyles the desktop UI by injecting a renderer script into the ZCode.app bundle, which goes beyond the official plugin API. It therefore modifies **nothing by default** — create `~/.zcode/prism-opt-in` to enable it; remove the marker and the original bundle is restored automatically on the next session start. See [Opt-in](#opt-in) and the safety notes below. + +## Features + +- **Project colors** — all conversation rows of a project are tinted (background overlay + left accent bar), consistently across the sidebar's project / grouped / timeline views. Colors are only applied where you picked them. +- **Conversation colors** — right-click any conversation row and choose **Adjust color (this conversation)** to recolor a single conversation on top of its project color, **Cancel color (this conversation)** for an explicit transparent state, and transparent swatches in the picker for "no color" that take precedence over project colors. +- **Automation colors** — every card on the Automations page follows its project's color by default (the page is rendered per project); right-click a card, or use its `…` menu → **Adjust color (this automation)**, to override a single automation, with the same explicit transparent / follow-project states. +- **Project icons** — 30 curated Lucide icons (extracted from ZCode's own renderer assets) replace the generic folder icon on project headers, and the picked icon also appears at the far left of each conversation row in the grouped view (before the title, tinted with the project color); home/remote projects keep their house/cloud semantic icons. +- **Project aliases** — rename a project inside ZCode without touching the folder on disk or the workspace association. +- **Recency ordering** — optionally keep every sidebar view ordered by last activity: conversations by their most recent message, projects/groups by the newest conversation inside them. Implemented as a display-only reorder (CSS `order`) that never writes the app's persisted manual order — toggle it off and the native order is restored verbatim. +- **Global toggles** — dim conversation titles, brighten the thinking shimmer, automatic hash-based colors for unpicked projects (off by default), and recency ordering. + +## Requirements + +- macOS with the ZCode desktop app installed at `/Applications/ZCode.app` (this is the only layout Prism patches; other locations are left untouched). + +## Opt-in + +Installing the plugin changes nothing on disk. To enable the renderer shim, create the marker file and start a new session (or restart ZCode): + +```sh +mkdir -p ~/.zcode && touch ~/.zcode/prism-opt-in +``` + +From then on a SessionStart hook installs and maintains the shim (and re-applies it after app updates). Without the marker the hook stays inert, and any previously applied patch is automatically stripped, restoring the original `app.asar` and refreshing the pristine backup. `$ZCODE_PLUGIN_DATA/opt-in` is honored as an alternative marker location. + +## Usage + +| Action | Entry point | +| --- | --- | +| Project color / icon | Right-click a project header, or the header `…` menu → **Adjust color** | +| Conversation color | Right-click a conversation row → **Adjust color (this conversation)** | +| Automation color | Right-click an automation card on the Automations page → **Adjust color (this automation)** (defaults to its project's color) | +| Cancel color | Right-click a conversation row / project `…` menu → **Cancel color** | +| Alias | Header `…` menu → **Rename** (display name only) | +| Global toggles | Bottom of the color picker → **Prism** | + +All settings apply immediately and are stored in the renderer's `localStorage` (`zcProjectTint.*` / `zcPrism.settings.v1`). Run `/status` inside a session to check the shim's installation state. + +## Settings + +Prism declares four global toggles through the manifest's `userConfig`, so they appear in ZCode's plugin settings page (Plugin management → Prism → Configure): + +| Setting | Default | Meaning | +| --- | --- | --- | +| Dim conversation titles | on | Titles render at 60% opacity | +| Brighten thinking shimmer | on | Improve shimmer visibility on dark themes | +| Auto colors for unpicked projects | off | Hash-based hue for projects without a manual pick | +| Order sidebar by recent activity | on | Display-only recency ordering, never touches manual drag order | + +The plugin settings page provides the defaults and is applied when a session starts; the toggles inside the color picker are immediate overrides stored per client and win over the settings page. + + +## Side effects, permissions and safety + +Read this before installing — Prism modifies the application bundle, which is the only way a plugin can restyle the desktop UI today. + +- **What gets written**: the SessionStart hook (`hooks/ensure-shim.js`) extracts `/Applications/ZCode.app/Contents/Resources/app.asar`, injects a single `\n" + ); +} + +function isCurrent(html) { + const m = html.match(/\/\* zc-prism v\d+ hash=([0-9a-f]{16}) cfg=([0-9a-f]{8})/); + return Boolean(m && m[1] === shimHash() && m[2] === configSig(readPluginConfig())); +} +function readIndex(asarPath) { + return asar.extractFile(asarPath, "out/renderer/index.html").toString("utf8"); +} + +async function rebuild(install) { + const state = readState(); + if (state.applyingAt && Date.now() - state.applyingAt < APPLY_STALE_MS) return; + writeState({ applyingAt: Date.now(), lastError: null }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "zc-prism-")); + try { + asar.extractAll(ASAR, tmp); + const indexPath = path.join(tmp, "out", "renderer", "index.html"); + let html = fs.readFileSync(indexPath, "utf8"); + if (install ? isCurrent(html) : !LEGACY_ENVELOPE_RE.test(html)) { + if (!install && process.argv.includes("--apply")) + console.error( + "[zc-prism] opt-in marker missing (~/.zcode/prism-opt-in): nothing installed, nothing to strip", + ); + writeState({ applyingAt: null }); + return; + } + // keep one pristine backup, but only of a truly unpatched app + const pristine = path.join(RESOURCES, "app.asar.pristine-backup"); + if (!LEGACY_ENVELOPE_RE.test(html) && !fs.existsSync(pristine)) { + try { + fs.copyFileSync(ASAR, pristine); + } catch (_) {} + } + html = html.replace(INJECT_RE, ""); // strip any older shim envelope + if (install) { + const shim = fs.readFileSync(shimPath(), "utf8"); + if (!html.includes("")) throw new Error("index.html has no "); + html = html.replace("", envelopeOf(shim) + ""); + } + fs.writeFileSync(indexPath, html); + + const incomingAsar = path.join(RESOURCES, ".zc-prism-incoming.asar"); + fs.rmSync(incomingAsar, { force: true }); + fs.rmSync(incomingAsar + ".unpacked", { recursive: true, force: true }); + await asar.createPackageWithOptions(tmp, incomingAsar, { + unpack: "**/{*.node,spawn-helper}", + }); + // sanity: the packaged index must match the goal before any swap + const packaged = readIndex(incomingAsar); + if (install ? !isCurrent(packaged) : LEGACY_ENVELOPE_RE.test(packaged)) + throw new Error("packaged asar failed " + (install ? "hash" : "cleanliness") + " check"); + + const prevAsar = path.join(RESOURCES, "app.asar.pre-prism"); + try { + fs.renameSync(ASAR, prevAsar); + } catch (_) {} + fs.renameSync(incomingAsar, ASAR); + // unpacked payload is byte-identical between builds of the same app + // version, so an in-place overwrite is safe and leaves no gap where the + // canonical app.asar.unpacked path is missing. + fs.cpSync(incomingAsar + ".unpacked", path.join(RESOURCES, "app.asar.unpacked"), { + recursive: true, + force: true, + }); + fs.rmSync(incomingAsar + ".unpacked", { recursive: true, force: true }); + try { + fs.rmSync(prevAsar, { force: true }); + } catch (_) {} + if (install) { + writeState({ applyingAt: null, lastApply: new Date().toISOString(), lastResult: "installed" }); + } else { + try { + fs.copyFileSync(ASAR, pristine); // the stripped build becomes the fresh backup + } catch (_) {} + writeState({ + applyingAt: null, + lastRollback: new Date().toISOString(), + lastResult: "rolled back (not opted in)", + }); + } + } catch (err) { + writeState({ applyingAt: null, lastResult: "error", lastError: String(err && err.message) }); + } finally { + try { + fs.rmSync(tmp, { recursive: true, force: true }); + } catch (_) {} + } +} + +if (process.argv.includes("--apply") || process.argv.includes("--rollback")) { + // --apply converges the bundle to the opt-in state (the marker decides); + // --rollback strips unconditionally. No spawned path can bypass the gate. + rebuild(process.argv.includes("--rollback") ? false : optIn()) + .catch(() => {}) + .finally(() => process.exit(0)); +} else { + // quick check + try { + if (!fs.existsSync(ASAR)) process.exit(0); + // one-shot diagnostics: which plugin variables does the host actually + // give this hook, and does a readable config file exist anywhere — + // recorded so the marketplace settings-page bridge can be tightened + // (or dropped) based on facts instead of documentation + const envKeys = Object.keys(process.env).filter((k) => + /^(ZCODE|USER_CONFIG|CLAUDE)/i.test(k), + ); + writeState({ + configProbe: { + at: new Date().toISOString(), + env: envKeys.reduce((o, k) => ((o[k] = process.env[k] || ""), o), {}), + argv: process.argv.slice(1), + dataDir: process.env.ZCODE_PLUGIN_DATA || null, + configFound: Object.keys(readPluginConfig()), + }, + }); + const html = readIndex(ASAR); + const optedIn = optIn(); + writeState({ optIn: optedIn }); + if (optedIn ? isCurrent(html) : !LEGACY_ENVELOPE_RE.test(html)) { + writeState({ lastResult: optedIn ? "ok" : "ok (inactive: no opt-in)" }); + process.exit(0); + } + const state = readState(); + if (state.applyingAt && Date.now() - state.applyingAt < APPLY_STALE_MS) process.exit(0); + const child = spawn(process.execPath, [__filename, "--apply"], { + detached: true, + stdio: "ignore", + }); + child.unref(); + writeState({ lastTrigger: new Date().toISOString() }); + } catch (_) {} + process.exit(0); +} diff --git a/external_plugins/prism/hooks/hooks.json b/external_plugins/prism/hooks/hooks.json new file mode 100644 index 00000000..358c8420 --- /dev/null +++ b/external_plugins/prism/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/ensure-shim.js"], + "timeoutMs": 10000 + } + ] + } + ] + } +} diff --git a/external_plugins/prism/renderer/prism.js b/external_plugins/prism/renderer/prism.js new file mode 100644 index 00000000..9796dfb2 --- /dev/null +++ b/external_plugins/prism/renderer/prism.js @@ -0,0 +1,1584 @@ +// zc-prism v14 (installed by the Prism plugin for ZCode) +// Per-project AND per-conversation color + icon for the ZCode desktop +// sidebar, per-automation color on the Automations page (defaulting to the +// automation's project color), a right-click picker on project headers, an +// opt-out recency ordering for every sidebar view, and the project icon +// carried onto grouped-view conversation rows. Everything here runs +// inside the production renderer and must never break the app: every entry +// point is defensive. +// Icons are Lucide path data extracted from the app's own renderer chunks. +(() => { + try { + if (window.__zcPrism) return; + const STORE_KEY = "zcProjectTint.v1"; + const ICON_STORE_KEY = "zcProjectTint.icons.v1"; + const ALIAS_STORE_KEY = "zcProjectTint.aliases.v1"; + const HEADER_PREFIX = "workspace-item-"; + const ROW_SELECTOR = "li[data-task-item-key], div[data-grouped-task-key]"; + const HEADER_SELECTOR = 'div[data-testid^="' + HEADER_PREFIX + '"]'; + // Automation cards on the Automations page (a workspace-scoped main view, + // NOT the sidebar). The card element is keyed by its automationId, which + // the React fiber preserves — that key is our stable per-card identity. + const AUTOMATION_SELECTOR = 'div[data-testid="automation-card"]'; + const HUES = [0, 25, 50, 95, 140, 165, 190, 215, 240, 270, 300, 330]; + const ICONS = { + "code-xml": "", + "terminal": "", + "database": "", + "globe": "", + "git-branch": "", + "palette": "", + "music-2": "", + "video": "", + "image": "", + "book-open": "", + "pen-tool": "", + "flask-conical": "", + "message-square": "", + "rocket": "", + "sparkles": "", + "wrench": "", + "bug": "", + "target": "", + "tag": "", + "house": "", + "hard-drive": "", + "package": "", + "files": "", + "calendar-days": "", + "chart-line": "", + "users": "", + "cpu": "", + "lightbulb": "", + "workflow": "", + "briefcase-business": "" +}; + + // Entity-escaped attributes (e.g. d="...") parse as UNQUOTED + // attributes in innerHTML, truncating path data at the first space, so + // decode before building markup. + function iconInner(id) { + return String(ICONS[id] || "").replace(/"/g, '"'); + } + + function readMap() { + try { + const raw = JSON.parse(localStorage.getItem(STORE_KEY) || "{}"); + return raw && typeof raw === "object" ? raw : {}; + } catch (_) { + return {}; + } + } + function writeMap(map) { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(map)); + } catch (_) {} + } + function readIconMap() { + try { + const raw = JSON.parse(localStorage.getItem(ICON_STORE_KEY) || "{}"); + return raw && typeof raw === "object" ? raw : {}; + } catch (_) { + return {}; + } + } + function writeIconMap(map) { + try { + localStorage.setItem(ICON_STORE_KEY, JSON.stringify(map)); + } catch (_) {} + } + function readAliasMap() { + try { + const raw = JSON.parse(localStorage.getItem(ALIAS_STORE_KEY) || "{}"); + return raw && typeof raw === "object" ? raw : {}; + } catch (_) { + return {}; + } + } + function writeAliasMap(map) { + try { + localStorage.setItem(ALIAS_STORE_KEY, JSON.stringify(map)); + } catch (_) {} + } + function leafOf(p) { + return p.replace(/\/+$/, "").split("/").pop() || p; + } + function aliasFor(p) { + return readAliasMap()[p] || leafOf(p); + } + // Per-conversation hue overrides, keyed by "\u0000" + // — the canonical form both row shapes normalize to (project/timeline rows + // carry "path:taskId", grouped rows carry encodeURIComponent(path\0taskId)). + const TASK_STORE_KEY = "zcProjectTint.tasks.v1"; + function readTaskMap() { + try { + const raw = JSON.parse(localStorage.getItem(TASK_STORE_KEY) || "{}"); + return raw && typeof raw === "object" ? raw : {}; + } catch (_) { + return {}; + } + } + function writeTaskMap(map) { + try { + localStorage.setItem(TASK_STORE_KEY, JSON.stringify(map)); + } catch (_) {} + } + function taskKeyOf(el) { + const k = el.getAttribute("data-task-item-key"); + if (k) { + const cut = k.lastIndexOf(":"); + return cut > 0 ? k.slice(0, cut) + "\u0000" + k.slice(cut + 1) : null; + } + const g = el.getAttribute("data-grouped-task-key"); + if (g) { + try { + const d = decodeURIComponent(g); + return d.indexOf("\u0000") > 0 ? d : null; + } catch (_) { + return null; + } + } + return null; + } + function taskTitleOf(el) { + const lb = el.querySelector("span.truncate"); + return lb ? (lb.textContent || "").trim() : ""; + } + function reactFiberOf(el) { + try { + for (const k of Object.keys(el)) { + if (k.indexOf("__reactFiber$") === 0) return el[k]; + } + } catch (_) {} + return null; + } + // Per-automation hue overrides on the Automations page, keyed by + // automationId (the card element's React key). Cards only render inside a + // workspace-scoped view, so with no override a card follows its project's + // color — resolved by walking the fiber chain up to the view component, + // whose props carry the workspace path. + const AUTO_STORE_KEY = "zcProjectTint.automations.v1"; + function readAutoMap() { + try { + const raw = JSON.parse(localStorage.getItem(AUTO_STORE_KEY) || "{}"); + return raw && typeof raw === "object" ? raw : {}; + } catch (_) { + return {}; + } + } + function writeAutoMap(map) { + try { + localStorage.setItem(AUTO_STORE_KEY, JSON.stringify(map)); + } catch (_) {} + } + function automationIdOf(cardEl) { + const f = reactFiberOf(cardEl); + const k = f && typeof f.key === "string" ? f.key : ""; + return k || null; + } + function automationTitleOf(cardEl) { + return taskTitleOf(cardEl); + } + function automationProjectOf(cardEl) { + let fiber = reactFiberOf(cardEl); + for (let hops = 0; fiber && hops < 40; hops++, fiber = fiber.return) { + const p = fiber.memoizedProps; + if (p && typeof p === "object" && typeof p.workspacePath === "string" && p.workspacePath) { + return p.workspacePath; + } + } + return null; + } + function automationHue(cardEl) { + const id = automationIdOf(cardEl); + const v = id ? readAutoMap()[id] : undefined; + if (typeof v === "number") return v === -1 ? null : v; // -1 = explicit transparent + const path = automationProjectOf(cardEl); + if (!path) return null; + const pv = readMap()[path]; + if (typeof pv === "number") return pv === -1 ? null : pv; + return readSettings().autoColor ? autoHue(path) : null; + } + const SETTINGS_KEY = "zcPrism.settings.v1"; + // autoColor: hash-based hue for projects without a manual pick — off by + // default, a hue is only ever applied when explicitly picked or opted in. + // groupRecency: display-only recency ordering for the grouped view. + const DEFAULT_SETTINGS = { dimTitles: true, brightenThinking: true, autoColor: false, groupRecency: true }; + // precedence: picker toggles (localStorage) > plugin settings page + // (window.__zcPrismConfig, baked in by the SessionStart hook) > defaults + function readSettings() { + try { + return Object.assign( + {}, + DEFAULT_SETTINGS, + window.__zcPrismConfig || {}, + JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"), + ); + } catch (_) { + return Object.assign({}, DEFAULT_SETTINGS, window.__zcPrismConfig || {}); + } + } + function writeSettings(s) { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); + } catch (_) {} + } + // global look & feel switches, kept in a dedicated style node so toggling + // rewrites it without touching the per-project tint rules + function applySettingsStyle() { + const st = readSettings(); + let el = document.getElementById("zc-prism-settings"); + if (!el) { + el = document.createElement("style"); + el.id = "zc-prism-settings"; + document.head.appendChild(el); + } + const rules = []; + if (st.dimTitles) { + rules.push( + ":is(li[data-task-item-key], div[data-grouped-task-key]) :is(p, span).text-foreground { opacity: 0.6; }", + ); + } + if (st.brightenThinking) { + // the shimmer sweep animates the --animated-gradient-text-soft stop, + // which is only ~20% white in dark themes; lift it to 65% of the strong + // color so the pulse stays theme-correct but clearly visible. Also + // covers .cua-group-gradient-text (computer-use streaming label), + // which sweeps the same two variables. + rules.push( + ":is(.animated-gradient-text, .cua-group-gradient-text) { --animated-gradient-text-soft: color-mix(in oklab, var(--animated-gradient-text-strong) 65%, transparent) !important; }", + ); + } + el.textContent = rules.join("\n"); + } + + const autoHueCache = new Map(); + // leaf aliases currently applied to timeline rows, so clearing an alias + // can restore the original text immediately instead of waiting for the + // app's next re-render of that row + const rowAliasApplied = new Map(); + function autoHue(key) { + let h = autoHueCache.get(key); + if (h === undefined) { + let acc = 0; + for (let i = 0; i < key.length; i++) acc = (acc * 31 + key.charCodeAt(i)) >>> 0; + h = Math.round((acc * 137.508) % 360); + autoHueCache.set(key, h); + } + return h; + } + function hueFor(path) { + const v = readMap()[path]; + if (typeof v === "number") return v === -1 ? null : v; // -1 = explicit transparent + return readSettings().autoColor ? autoHue(path) : null; + } + function hueColor(path) { + const h = hueFor(path); + return h === null ? null : "hsl(" + h + " 70% 55%)"; + } + + function projectOfRow(el) { + const k = el.getAttribute("data-task-item-key"); + if (k) { + const cut = k.lastIndexOf(":"); + return cut > 0 ? k.slice(0, cut) : k; + } + const g = el.getAttribute("data-grouped-task-key"); + if (g) { + try { + return decodeURIComponent(g).split("\u0000")[0]; + } catch (_) { + return null; + } + } + return null; + } + + function projectOfHeader(el) { + return (el.getAttribute("data-testid") || "").slice(HEADER_PREFIX.length) || null; + } + + // Replace the generic folder icon on project headers with the picked icon + // (native house/cloud keep their remote/home semantics and stay). + function syncHeaderIcon(header, path) { + const iconId = readIconMap()[path]; + const folderSvg = header.querySelector("svg.lucide-folder, svg.lucide-folder-open"); + const holder = folderSvg ? folderSvg.parentElement : null; + const mine = header.querySelector('span[data-zc-pt-icon="1"]'); + if (!iconId || !ICONS[iconId]) { + if (mine) mine.remove(); + if (holder) holder.style.display = ""; + return; + } + const color = hueColor(path); // null → keep the app's foreground color + if (holder) holder.style.display = "none"; + const wantKey = iconId + "|" + (color || ""); + let el = mine; + if (el && el.dataset.zc === wantKey) return; + if (!el) { + el = document.createElement("span"); + el.dataset.zcPtIcon = "1"; + if (holder && holder.parentNode) holder.parentNode.insertBefore(el, holder.nextSibling); + else header.insertBefore(el, header.firstChild); + } + el.dataset.zc = wantKey; + el.style.cssText = + "display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex:none" + + (color ? ";color:" + color : ""); + el.innerHTML = + '' + + iconInner(iconId) + + ""; + } + + // Show the display alias on the project header label. The text node is + // edited IN PLACE (nodeValue): assigning textContent would detach the + // text node React tracks, and its next commit would throw + // "removeChild ... not a child of this node". React restores the original + // text on re-render; the next pass rewrites it, guarded by comparison so + // no mutation is emitted once equal. + function syncHeaderAlias(header, path) { + const label = header.querySelector("div.truncate"); + if (!label) return; + const alias = readAliasMap()[path]; + const orig = label.dataset.zcPtOrig; + if (!alias) { + if (orig !== undefined) { + delete label.dataset.zcPtOrig; + for (const n of label.childNodes) { + if (n.nodeType === 3 && n.nodeValue !== orig) n.nodeValue = orig; + } + } + return; + } + for (const n of label.childNodes) { + if (n.nodeType !== 3 || n.nodeValue === alias) continue; + if (orig === undefined) label.dataset.zcPtOrig = n.nodeValue; + n.nodeValue = alias; + } + } + + // Grouped-view conversation rows: carry the project's picked icon at the + // far left of the title line (the native row has no leading icon slot). + // The title line is the row's first span child, recognized by the marquee + // track React renders inside it; anything else is left untouched. The + // icon span is PREPENDED to that line — React reconciles only the nodes + // it tracks, so a foreign leading element coexists with re-renders (same + // insertion pattern as the header icon). Color follows the project (not + // per-conversation overrides) so the icon keeps meaning "this project", + // matching the header icon. + function syncRowIcon(row, path, iconMap, color) { + const line = row.firstElementChild; + const mine = line + ? line.querySelector(':scope > span[data-zc-pt-ricon="1"]') + : null; + const ok = + line && + line.parentElement === row && + line.tagName === "SPAN" && + line.querySelector("span[data-task-title-marquee-track]"); + const iconId = ok ? iconMap[path] : undefined; + if (!iconId || !ICONS[iconId]) { + if (mine) mine.remove(); + return; + } + const wantKey = iconId + "|" + (color || ""); + if (mine && mine.dataset.zc === wantKey) return; + if (!mine) { + mine = document.createElement("span"); + mine.dataset.zcPtRicon = "1"; + line.insertBefore(mine, line.firstChild); + } + mine.dataset.zc = wantKey; + mine.style.cssText = + "display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex:none" + + (color ? ";color:" + color : ""); + mine.innerHTML = + '' + + iconInner(iconId) + + ""; + } + + // ---- sidebar recency ordering (display-only, all views) ---- + // The sidebar orders things manually (dragged groups/projects) or by join + // time; this floats recently-active conversations to the top WITHOUT + // touching any persisted order — purely CSS `order` on flexed containers, + // so toggling off restores the native order byte-for-byte. Times come + // from each task object (lastActivityAt/updatedAt), read ONLY through the + // React fiber pointer React leaves on row DOM nodes. + // + // Three shapes of list are recognized, each validation-gated so an + // unrecognized DOM simply stays native: + // 1. grouped view top level: group wrappers + loose rows (by recency) + // 2. row containers whose children are ALL rows → sorted directly + // 3. containers mixing headers and rows → segmented: each header keeps + // its following rows; segments ranked by their newest row + // Project *sections* (header + row-list wrappers) are then ranked among + // their siblings by their newest row, but only when the wrapper shape is + // unambiguous (exactly one header inside). + const recencyFlex = new Set(); + let recencyState = "off"; + function setRecencyState(s) { + if (recencyState === s) return; + recencyState = s; + try { + console.info("[zc-prism] recency: " + s); + } catch (_) {} + } + function taskTimeOf(rowEl) { + try { + let fiber = reactFiberOf(rowEl); + for (let hops = 0; fiber && hops < 12; hops++, fiber = fiber.return) { + const p = fiber.memoizedProps; + if (!p) continue; + const t = typeof p.task === "object" && p.task ? p.task : typeof p === "object" && typeof p.taskId === "string" && typeof p.updatedAt === "number" ? p : null; + if (!t) continue; + const v = typeof t.lastActivityAt === "number" ? t.lastActivityAt : t.updatedAt; + if (typeof v === "number" && Number.isFinite(v)) return v; + } + } catch (_) {} + return NaN; + } + function rowRootWithin(rowEl, container) { + let root = rowEl; + while (root.parentElement && root.parentElement !== container) root = root.parentElement; + return root.parentElement === container ? root : null; + } + function setOrder(el, ord) { + if (el.dataset.zcPtOrd === ord) return; + el.dataset.zcPtOrd = ord; + el.style.order = ord; + } + function flexColumn(el) { + if (el.dataset.zcPtFlex !== "1") { + el.dataset.zcPtFlex = "1"; + el.style.display = "flex"; + el.style.flexDirection = "column"; + } + recencyFlex.add(el); + } + const recencyTime = (e) => (Number.isFinite(e.time) ? e.time : -Infinity); + // key entries: {el, time, pos}, ranked newest first, ties keep DOM order. + // Non-keyed children keep their top/bottom bands: those before the first + // keyed child (draft inputs, pinned blocks) order -1, those after 9999. + function orderChildren(container, keyed) { + let anyTime = false; + for (const k of keyed) if (Number.isFinite(k.time)) anyTime = true; + if (!anyTime) return false; + const isKeyed = new Set(keyed.map((k) => k.el)); + const kids = Array.prototype.slice.call(container.children); + let firstKeyed = -1; + for (let i = 0; i < kids.length; i++) if (isKeyed.has(kids[i])) { firstKeyed = i; break; } + const sorted = keyed.slice().sort((a, b) => recencyTime(b) - recencyTime(a) || a.pos - b.pos); + flexColumn(container); + for (let i = 0; i < kids.length; i++) { + if (!isKeyed.has(kids[i])) setOrder(kids[i], i < firstKeyed ? "-1" : "9999"); + } + sorted.forEach((k, i) => setOrder(k.el, String(i))); + return true; + } + // A container mixing header-ish blocks with rows: each block "owns" the + // rows that follow it (a segment); segments ranked by newest row, rows + // sorted within their segment. Rank slots are 1000 apart so a segment's + // header + rows stay contiguous after reordering. + function orderSegments(container, children) { + const isRow = (el) => el.matches(ROW_SELECTOR); + const segments = []; + for (const ch of children) { + if (isRow(ch)) { + // a row belongs to the segment opened by the block before it + if (!segments.length) segments.push({ head: null, headIsBlock: false, rows: [] }); + segments[segments.length - 1].rows.push(ch); + continue; + } + segments.push({ head: ch, headIsBlock: true, rows: [] }); + } + if (segments.length < 2 && segments[0] && !segments[0].headIsBlock) return false; + const keyed = []; + const pinned = []; // rowless non-header blocks: leading → top, trailing → bottom + segments.forEach((seg, si) => { + if (!seg.headIsBlock) { + seg.rows.forEach((r, i) => { + keyed.push({ el: r, time: taskTimeOf(r), seg, pos: i }); + }); + return; + } + if (!seg.head.matches(HEADER_SELECTOR) && seg.rows.length === 0) { + // separators / draft rows / trailing footers with no rows — keep out + // of the ranked flow, on the side of the list they came from + pinned.push({ el: seg.head, top: si < segments.length - 1 }); + return; + } + let best = NaN; + seg.rows.forEach((r, i) => { + const t = taskTimeOf(r); + keyed.push({ el: r, time: t, seg, pos: i }); + if (Number.isFinite(t) && (Number.isNaN(best) || t > best)) best = t; + }); + keyed.push({ el: seg.head, time: best, seg }); + }); + let anyTime = false; + for (const k of keyed) if (Number.isFinite(k.time) && k.el !== k.seg.head) anyTime = true; + if (!anyTime) return false; + const bySeg = new Map(); + for (const k of keyed) { + if (k.el === k.seg.head) continue; + const prev = bySeg.get(k.seg); + if (!prev || (Number.isFinite(k.time) && (Number.isNaN(prev) || k.time > prev))) bySeg.set(k.seg, k.time); + } + const segs = Array.from(bySeg.keys()); + const sortedSegs = segs.slice().sort((a, b) => { + const ta = bySeg.get(a), tb = bySeg.get(b); + const fa = Number.isFinite(ta) ? ta : -Infinity; + const fb = Number.isFinite(tb) ? tb : -Infinity; + return fb - fa || segs.indexOf(a) - segs.indexOf(b); + }); + const rank = new Map(sortedSegs.map((s, i) => [s, i * 1000])); + flexColumn(container); + for (const p of pinned) setOrder(p.el, p.top ? "-1" : "9999"); + // rows within a segment ranked by their own recency, newest first + const rowsBySeg = new Map(); + for (const k of keyed) { + if (k.el === k.seg.head) continue; + if (!rowsBySeg.has(k.seg)) rowsBySeg.set(k.seg, []); + rowsBySeg.get(k.seg).push(k); + } + for (const [seg, list] of rowsBySeg) { + const base = rank.get(seg) || 0; + list.sort((a, b) => recencyTime(b) - recencyTime(a) || a.pos - b.pos); + list.forEach((k, i) => setOrder(k.el, String(base + 1 + i))); + } + for (const seg of segs) { + if (seg.head) setOrder(seg.head, String(rank.get(seg) || 0)); + } + return true; + } + function removeGroupRecency() { + for (const el of recencyFlex) { + if (!el.isConnected) continue; + try { + if (el.dataset.zcPtFlex === "1") { + el.style.display = ""; + el.style.flexDirection = ""; + delete el.dataset.zcPtFlex; + } + for (const ch of el.children) { + ch.style.order = ""; + delete ch.dataset.zcPtOrd; + } + } catch (_) {} + } + recencyFlex.clear(); + } + function applyRecency(root) { + if (!readSettings().groupRecency) { + setRecencyState("off"); + if (recencyFlex.size) removeGroupRecency(); + return; + } + const rows = root.querySelectorAll(ROW_SELECTOR); + if (!rows.length) { + setRecencyState("sidebar-hidden"); + if (recencyFlex.size) removeGroupRecency(); + return; + } + const anchor = root.querySelector("[data-grouped-layout-key]"); + let applied = 0; + // 1) grouped view top level: groups + loose rows ranked by newest task + if (anchor) { + const top = anchor.parentElement; + if (top && top !== document.body && top !== anchor) { + let virtual = false; + for (const ch of top.children) { + if (ch.hasAttribute("data-index")) { virtual = true; break; } + } + if (virtual) { + if (recencyFlex.size) removeGroupRecency(); + setRecencyState("virtualized-list-skipped"); + return; + } + const keyedTop = []; + let pos = 0; + let anyTime = false; + for (const ch of top.children) { + if (ch.hasAttribute("data-grouped-layout-key")) { + let best = NaN; + const grows = ch.querySelectorAll("[data-grouped-task-key]"); + if (grows.length) { + // rows sit a few wrappers deep inside the group; the task-list + // container is the group wrapper's direct ancestor of row #1 + const listEl = rowRootWithin(grows[0], ch); + const roots = new Map(); + let okList = !!listEl; + for (const r of grows) { + const t = taskTimeOf(r); + if (Number.isFinite(t)) { + anyTime = true; + if (Number.isNaN(best) || t > best) best = t; + } + const rootEl = listEl && rowRootWithin(r, listEl); + if (!rootEl) { + okList = false; + continue; + } + const prev = roots.get(rootEl); + if (!prev || (!Number.isFinite(prev.time) && Number.isFinite(t)) || t > prev.time) { + roots.set(rootEl, { el: rootEl, time: t, pos: prev ? prev.pos : roots.size }); + } + } + if (okList && roots.size && orderChildren(listEl, Array.from(roots.values()))) applied++; + } + keyedTop.push({ el: ch, time: best, pos: pos++ }); + continue; + } + const row = ch.matches("[data-grouped-task-key]") ? ch : ch.querySelector("[data-grouped-task-key]"); + if (row) { + const t = taskTimeOf(row); + if (Number.isFinite(t)) anyTime = true; + keyedTop.push({ el: ch, time: t, pos: pos++ }); + } else { + setOrder(ch, "-1"); + } + } + if (anyTime && orderChildren(top, keyedTop)) applied++; + } + } + // 2) project/timeline containers (li rows — their parentElement IS the + // list): sort all-row containers directly, segmented ones by block + const byParent = new Map(); + for (const r of rows) { + if (!r.hasAttribute("data-task-item-key")) continue; // grouped rows done above + const p = r.parentElement; + if (!p) continue; + if (!byParent.has(p)) byParent.set(p, []); + byParent.get(p).push(r); + } + for (const [parent, list] of byParent) { + if (list.length < 2) continue; + const kids = Array.prototype.slice.call(parent.children); + const rowSet = new Set(list); + let allRows = true; + for (const k of kids) { + if (!rowSet.has(k) && (k.matches ? !k.matches(ROW_SELECTOR) : true)) { allRows = false; break; } + } + let ok = false; + try { + // all-row containers sort directly; anything else (headers, footers, + // separators, drafts) goes through the segment model so stray blocks + // no longer disqualify the whole list + ok = allRows ? orderChildren(parent, list.map((el, i) => ({ el, time: taskTimeOf(el), pos: i }))) : orderSegments(parent, kids); + } catch (_) {} + if (ok) applied++; + } + // 3) project sections: rank header+list wrappers among their siblings + let sectionsRanked = 0; + if (!anchor) { + const secByParent = new Map(); + for (const h of root.querySelectorAll(HEADER_SELECTOR)) { + let S = null; + let node = h; + for (let up = 0; node && up < 8; up++, node = node.parentElement) { + if (node === document.body) break; + if (node.querySelector(ROW_SELECTOR)) { + if (node.querySelectorAll(HEADER_SELECTOR).length === 1) S = node; + break; + } + } + if (!S || !S.parentElement) continue; + const p = S.parentElement; + if (!secByParent.has(p)) secByParent.set(p, []); + secByParent.get(p).push({ S, h }); + } + for (const [p, list] of secByParent) { + if (list.length < 2) continue; + const keyed = []; + let anyTime = false; + for (const { S } of list) { + let best = NaN; + for (const r of S.querySelectorAll("[data-task-item-key]")) { + const t = taskTimeOf(r); + if (Number.isFinite(t)) { + anyTime = true; + if (Number.isNaN(best) || t > best) best = t; + } + } + keyed.push({ el: S, time: best, pos: keyed.length }); + } + if (anyTime && orderChildren(p, keyed)) sectionsRanked++; + } + } + const shape = + "gw=" + root.querySelectorAll("[data-grouped-layout-key]").length + + " li=" + root.querySelectorAll("li[data-task-item-key]").length + + " div=" + root.querySelectorAll("div[data-grouped-task-key]").length + + " hdr=" + root.querySelectorAll(HEADER_SELECTOR).length; + if (applied || sectionsRanked) { + setRecencyState("applied: containers=" + applied + " sections=" + sectionsRanked + " (" + shape + ")"); + } else { + setRecencyState("no-recognizable-list (" + shape + ")"); + if (recencyFlex.size) removeGroupRecency(); + } + } + + function pass(root) { + const map = readMap(); + const rows = root.querySelectorAll(ROW_SELECTOR); + const aliasMap = readAliasMap(); + const st = readSettings(); + const taskMap = readTaskMap(); + for (const el of rows) { + const path = projectOfRow(el); + if (!path) continue; + const tk = taskKeyOf(el); + const tv = tk !== null ? taskMap[tk] : undefined; + let v = typeof tv === "number" ? tv : map[path]; + if (v === -1) v = null; // explicit transparent beats project/auto + else if (v === undefined && st.autoColor) v = autoHue(path); + const want = typeof v === "number" ? String(v) : "off"; + if (el.dataset.zcPtH === want) continue; + el.dataset.zcPtH = want; + if (typeof v === "number") { + el.style.setProperty("--zc-pt-h", want.split(".")[0]); + el.dataset.zcPtOn = "1"; + } else { + el.style.removeProperty("--zc-pt-h"); + delete el.dataset.zcPtOn; + } + } + // timeline rows show the workspace leaf name in a span.truncate; swap in + // the alias when one is set. Text nodes are edited in place (nodeValue), + // never via textContent — detaching React's tracked node would crash its + // next commit with "removeChild ... not a child of this node". + for (const el of rows) { + const path = projectOfRow(el); + if (!path) continue; + const alias = aliasMap[path]; + const leaf = leafOf(path); + if (leaf === alias) continue; + const applied = rowAliasApplied.get(path); + if (!alias) { + if (applied !== undefined) { + rowAliasApplied.delete(path); + for (const lb of el.querySelectorAll("span.truncate")) { + for (const n of lb.childNodes) { + if (n.nodeType === 3 && n.nodeValue === applied) n.nodeValue = leaf; + } + } + } + continue; + } + let hit = false; + for (const lb of el.querySelectorAll("span.truncate")) { + for (const n of lb.childNodes) { + if (n.nodeType === 3 && n.nodeValue === leaf) { + n.nodeValue = alias; + hit = true; + } + } + } + if (hit) rowAliasApplied.set(path, alias); + else if (applied !== undefined) rowAliasApplied.set(path, applied); + } + // grouped-view rows show the project's picked icon before the title; + // icon color = project color (explicit > auto), transparent → foreground + const iconMap = readIconMap(); + for (const el of rows) { + if (!el.hasAttribute("data-grouped-task-key")) continue; + const path = projectOfRow(el); + if (!path) continue; + const pv = map[path]; + const hue = + typeof pv === "number" ? (pv === -1 ? null : pv) : st.autoColor ? autoHue(path) : null; + try { + syncRowIcon(el, path, iconMap, hue === null ? null : "hsl(" + hue + " 70% 55%)"); + } catch (_) {} + } + const headers = root.querySelectorAll(HEADER_SELECTOR); + for (const el of headers) { + const path = projectOfHeader(el); + if (!path) continue; + let v = map[path]; + if (v === -1) v = null; + else if (v === undefined && st.autoColor) v = autoHue(path); + const want = typeof v === "number" ? String(v) : "off"; + if (el.dataset.zcPtH !== want) { + el.dataset.zcPtH = want; + if (typeof v === "number") { + el.style.setProperty("--zc-pt-h", want.split(".")[0]); + el.dataset.zcPtOn = "1"; + } else { + el.style.removeProperty("--zc-pt-h"); + delete el.dataset.zcPtOn; + } + } + try { + syncHeaderIcon(el, path); + } catch (_) {} + try { + syncHeaderAlias(el, path); + } catch (_) {} + } + // Automations page cards: explicit per-automation hue, else the color of + // the project the automation belongs to (same precedence as rows). + for (const el of root.querySelectorAll(AUTOMATION_SELECTOR)) { + try { + const v = automationHue(el); + const want = typeof v === "number" ? String(v) : "off"; + if (el.dataset.zcPtH === want) continue; + el.dataset.zcPtH = want; + if (typeof v === "number") { + el.style.setProperty("--zc-pt-h", want.split(".")[0]); + el.dataset.zcPtOn = "1"; + } else { + el.style.removeProperty("--zc-pt-h"); + delete el.dataset.zcPtOn; + } + } catch (_) {} + } + try { + enhanceMenus(); + } catch (_) {} + try { + applyRecency(root || document); + } catch (_) {} + } + + function injectStyle() { + if (document.getElementById("zc-prism-style")) return; + const style = document.createElement("style"); + style.id = "zc-prism-style"; + style.textContent = [ + "li[data-task-item-key], div[data-grouped-task-key] { position: relative; }", + "li[data-task-item-key][data-zc-pt-on]::before, div[data-grouped-task-key][data-zc-pt-on]::before {", + " content: \"\"; position: absolute; inset: 0; border-radius: inherit;", + " background: hsl(var(--zc-pt-h, 0) 70% 55% / 0.09);", + " pointer-events: none; }", + "li[data-task-item-key][data-zc-pt-on]::after, div[data-grouped-task-key][data-zc-pt-on]::after {", + " content: \"\"; position: absolute; left: 1px; top: 50%; transform: translateY(-50%);", + " width: 3px; height: 60%; border-radius: 2px;", + " background: hsl(var(--zc-pt-h, 0) 70% 60% / 0.55);", + " pointer-events: none; }", + HEADER_SELECTOR + " { position: relative; }", + HEADER_SELECTOR + "[data-zc-pt-on]::before {", + " content: \"\"; position: absolute; inset: 0; border-radius: inherit;", + " background: hsl(var(--zc-pt-h, 0) 60% 50% / 0.06);", + " pointer-events: none; }", + AUTOMATION_SELECTOR + " { position: relative; }", + AUTOMATION_SELECTOR + "[data-zc-pt-on]::before {", + " content: \"\"; position: absolute; inset: 0; border-radius: inherit;", + " background: hsl(var(--zc-pt-h, 0) 70% 55% / 0.09);", + " pointer-events: none; }", + AUTOMATION_SELECTOR + "[data-zc-pt-on]::after {", + " content: \"\"; position: absolute; left: 1px; top: 50%; transform: translateY(-50%);", + " width: 3px; height: 60%; border-radius: 2px;", + " background: hsl(var(--zc-pt-h, 0) 70% 60% / 0.55);", + " pointer-events: none; }", + ".zc-pt-menu {", + " position: fixed; z-index: 2147483646; width: 224px; padding: 10px;", + " border-radius: 10px; background: rgba(28,30,32,0.98);", + " border: 1px solid rgba(255,255,255,0.09);", + " box-shadow: 0 12px 32px rgba(0,0,0,0.4); color: #e8eaec;", + " font: 12px -apple-system, system-ui, sans-serif; }", + ".zc-pt-title { margin: 0 0 8px; font-weight: 600; opacity: 0.9;", + " overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }", + ".zc-pt-grid { display: grid; grid-template-columns: repeat(7, 24px); gap: 6px; }", + ".zc-pt-swatch { width: 24px; height: 24px; border-radius: 7px; cursor: pointer;", + " border: 1px solid rgba(255,255,255,0.15); box-sizing: border-box;", + " display: flex; align-items: center; justify-content: center;", + " font: 600 11px -apple-system, system-ui, sans-serif; color: rgba(0,0,0,0.6); }", + ".zc-pt-swatch:hover { transform: scale(1.12); }", + ".zc-pt-swatch[data-current='1'] { outline: 2px solid #fff; outline-offset: 1px; }", + ".zc-pt-swatch.zc-pt-auto { background: transparent; color: #cfd3d6; font-weight: 500; }", + ".zc-pt-swatch.zc-pt-none { background: linear-gradient(135deg, transparent 42%, #e05252 42%, #e05252 58%, transparent 58%); }", + ".zc-pt-sep { margin: 10px 0 6px; font-size: 11px; opacity: 0.55; }", + ".zc-pt-isw { width: 24px; height: 24px; border-radius: 7px; cursor: pointer;", + " display: flex; align-items: center; justify-content: center; color: #b9bec3; }", + ".zc-pt-isw:hover { color: #fff; background: rgba(255,255,255,0.08); }", + ".zc-pt-isw[data-current='1'] { outline: 2px solid #fff; outline-offset: 1px; }", + ".zc-pt-isw svg { width: 15px; height: 15px; }", + ".zc-pt-isw.zc-pt-none { font-size: 11px; font-weight: 500; }", + ".zc-pt-input { width: 100%; box-sizing: border-box; margin-bottom: 8px;", + " padding: 6px 8px; border-radius: 7px; border: 1px solid rgba(255,255,255,0.14);", + " background: rgba(255,255,255,0.06); color: #e8eaec;", + " font: 12px -apple-system, system-ui, sans-serif; outline: none; }", + ".zc-pt-input:focus { border-color: rgba(255,255,255,0.35); }", + ".zc-pt-btnrow { display: flex; gap: 6px; justify-content: flex-end; }", + ".zc-pt-btn { padding: 5px 10px; border-radius: 7px; cursor: pointer;", + " border: 1px solid rgba(255,255,255,0.14); color: #cfd3d6; }", + ".zc-pt-btn:hover { background: rgba(255,255,255,0.08); }", + ".zc-pt-btn.zc-pt-primary { background: #2f6feb; border-color: #2f6feb; color: #fff; }", + ".zc-pt-btn.zc-pt-primary:hover { background: #3d7bff; }", + ".zc-pt-tg { display: flex; align-items: center; justify-content: space-between;", + " gap: 12px; padding: 5px 0; cursor: pointer; }", + ".zc-pt-tg:hover { opacity: 0.85; }", + ".zc-pt-tg-sw { width: 28px; height: 16px; border-radius: 9px; flex: none;", + " background: rgba(255,255,255,0.16); position: relative; transition: background 0.15s; }", + ".zc-pt-tg-sw::after { content: \"\"; position: absolute; top: 2px; left: 2px;", + " width: 12px; height: 12px; border-radius: 50%; background: #b9bec3; transition: left 0.15s; }", + ".zc-pt-tg[data-on='1'] .zc-pt-tg-sw { background: #2f6feb; }", + ".zc-pt-tg[data-on='1'] .zc-pt-tg-sw::after { left: 14px; background: #fff; }", + ].join("\n"); + document.head.appendChild(style); + } + + // ---- picker popover ---- + let menu = null; + function closeMenu() { + if (!menu) return; + const m = menu; + menu = null; + try { + m.remove(); + } catch (_) {} + document.removeEventListener("pointerdown", onDocPointer, true); + document.removeEventListener("keydown", onDocKey, true); + document.removeEventListener("contextmenu", onMenuCtx, true); + } + function onDocPointer(e) { + if (menu && !menu.contains(e.target)) closeMenu(); + } + function onDocKey(e) { + if (e.key === "Escape") closeMenu(); + } + function onMenuCtx(e) { + e.preventDefault(); + e.stopPropagation(); + } + + // ---- rename popover ---- + function openRename(path, x, y) { + try { + closeMenu(); + injectStyle(); + menu = document.createElement("div"); + menu.className = "zc-pt-menu"; + const title = document.createElement("div"); + title.className = "zc-pt-title"; + title.textContent = "重命名项目(仅显示名)"; + menu.appendChild(title); + const input = document.createElement("input"); + input.className = "zc-pt-input"; + input.value = readAliasMap()[path] || leafOf(path); + input.spellcheck = false; + menu.appendChild(input); + const row = document.createElement("div"); + row.className = "zc-pt-btnrow"; + const reset = document.createElement("div"); + reset.className = "zc-pt-btn"; + reset.textContent = "恢复原名"; + reset.addEventListener("click", () => { + const m = readAliasMap(); + delete m[path]; + writeAliasMap(m); + pass(document); + closeMenu(); + }); + const save = document.createElement("div"); + save.className = "zc-pt-btn zc-pt-primary"; + save.textContent = "保存"; + save.addEventListener("click", () => { + const m = readAliasMap(); + const v = input.value.trim(); + if (v && v !== leafOf(path)) m[path] = v; + else delete m[path]; + writeAliasMap(m); + pass(document); + closeMenu(); + }); + row.appendChild(reset); + row.appendChild(save); + menu.appendChild(row); + document.body.appendChild(menu); + input.addEventListener("keydown", (ev) => { + ev.stopPropagation(); + if (ev.key === "Enter") save.click(); + else if (ev.key === "Escape") closeMenu(); + }); + const r = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(x, window.innerWidth - r.width - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, window.innerHeight - r.height - 8)) + "px"; + document.addEventListener("pointerdown", onDocPointer, true); + document.addEventListener("keydown", onDocKey, true); + document.addEventListener("contextmenu", onMenuCtx, true); + setTimeout(() => { + try { + input.focus(); + input.select(); + } catch (_) {} + }, 30); + } catch (err) { + try { + console.error("[zc-prism] rename", err); + } catch (_) {} + } + } + + // ---- native "..." menu / context menu enhancement ---- + let pendingProject = null; + let pendingTask = null; + let pendingAutomation = null; + // lucide "ban" glyph — used by the one-click "cancel color" entries + const BAN_PATHS = ''; + function dismissNativeMenu(menuEl) { + // React attaches its listeners at the root container, so an Escape + // dispatched on `document` never reaches the menu; dispatched on the + // menu element itself it bubbles through the root and Radix closes the + // menu through its own state path. Never detach the node here: React + // unmounts it later and a forced removal makes that removeChild throw. + try { + menuEl.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }), + ); + } catch (_) {} + // If the synthetic Escape was ignored, just get the menu out of the way; + // the next pointerdown dismisses it for real. Unhide on any later + // pointerdown in case the app reuses the node for the next menu. + setTimeout(() => { + try { + if (!menuEl || !menuEl.isConnected) return; + menuEl.style.display = "none"; + const unhide = () => { + document.removeEventListener("pointerdown", unhide, true); + try { + if (menuEl.isConnected && menuEl.style.display === "none") menuEl.style.display = ""; + } catch (_) {} + }; + document.addEventListener("pointerdown", unhide, true); + } catch (_) {} + }, 80); + } + function enhanceMenus() { + const menus = document.querySelectorAll('[role="menu"]:not([data-zc-pt-menu])'); + if (!menus.length) return; + const now = Date.now(); + const task = pendingTask && now - pendingTask.at < 4000 ? pendingTask : null; + const proj = !task && pendingProject && now - pendingProject.at < 4000 ? pendingProject : null; + const auto = + !task && !proj && pendingAutomation && now - pendingAutomation.at < 4000 + ? pendingAutomation + : null; + if (!task && !proj && !auto) return; + for (const menuEl of menus) { + const wantTask = task && !proj; + const wantAuto = !task && !proj && !!auto; + let template = null; + if (wantAuto) { + // the automation card dropdown is detected structurally: its + // pause/delete items carry automation-action-* testids, so no + // keyword guessing against localized labels + template = menuEl.querySelector( + '[role="menuitem"][data-testid^="automation-action-"]', + ); + } else { + const kw = wantTask ? /移除|归档|置顶|固定|重命名|删除|移到/ : /移除/; + for (const it of menuEl.querySelectorAll('[role="menuitem"]')) { + if ((it.textContent || "").search(kw) !== -1) { + template = it; + break; + } + } + if (!template && wantTask) { + template = menuEl.querySelector('[role="menuitem"]'); + } + } + if (!template) continue; + menuEl.dataset.zcPtMenu = wantAuto ? auto.id : wantTask ? task.key : proj.path; + const mk = (label, paths, fn) => { + const el = template.cloneNode(true); + el.setAttribute("data-zc-pt-item", "1"); + const svg = el.querySelector("svg"); + const iconMarkup = + '' + + paths + + ""; + if (svg) svg.outerHTML = iconMarkup; + for (const n of Array.from(el.childNodes)) { + if (n !== el.querySelector("svg")) n.remove(); + } + el.appendChild(document.createTextNode(" " + label)); + el.addEventListener("click", (ev) => { + ev.stopPropagation(); + dismissNativeMenu(menuEl); + if (wantAuto) fn(auto.id, auto.title, ev.clientX || 200, ev.clientY || 200); + else if (wantTask) fn(task.key, task.title, ev.clientX || 200, ev.clientY || 200); + else fn(proj.path, ev.clientX || 200, ev.clientY || 200); + }); + return el; + }; + if (wantAuto) { + menuEl.appendChild(mk("调整颜色(此自动化)", iconInner("palette"), openAutomationPicker)); + menuEl.appendChild( + mk("取消颜色(此自动化)", BAN_PATHS, (id) => { + const m = readAutoMap(); + m[id] = -1; + writeAutoMap(m); + pass(document); + }), + ); + } else if (wantTask) { + menuEl.appendChild(mk("调整颜色(此对话)", iconInner("palette"), openTaskPicker)); + menuEl.appendChild( + mk("取消颜色(此对话)", BAN_PATHS, (key) => { + const m = readTaskMap(); + m[key] = -1; + writeTaskMap(m); + pass(document); + }), + ); + } else { + menuEl.appendChild(mk("调整颜色", iconInner("palette"), openPicker)); + menuEl.appendChild( + mk("取消颜色", BAN_PATHS, (path) => { + const m = readMap(); + m[path] = -1; + writeMap(m); + pass(document); + }), + ); + menuEl.appendChild(mk("重命名", iconInner("pen-tool"), openRename)); + } + } + } + + function markCurrent(grid, current) { + for (const el of grid.children) { + if (el.dataset.v === String(current)) el.dataset.current = "1"; + else delete el.dataset.current; + } + } + + function openPicker(path, x, y) { + try { + closeMenu(); + injectStyle(); + const leaf = aliasFor(path); + menu = document.createElement("div"); + menu.className = "zc-pt-menu"; + + const title = document.createElement("div"); + title.className = "zc-pt-title"; + title.textContent = leaf; + menu.appendChild(title); + + const grid = document.createElement("div"); + grid.className = "zc-pt-grid"; + for (const h of HUES) { + const sw = document.createElement("div"); + sw.className = "zc-pt-swatch"; + sw.style.background = "hsl(" + h + " 70% 55%)"; + sw.dataset.v = String(h); + sw.title = "色相 " + h; + sw.addEventListener("click", () => { + const map = readMap(); + map[path] = h; + writeMap(map); + pass(document); + markCurrent(grid, h); + }); + grid.appendChild(sw); + } + const noneSw = document.createElement("div"); + noneSw.className = "zc-pt-swatch zc-pt-none"; + noneSw.dataset.v = "-1"; + noneSw.title = "透明(明确不染色,优先于自动配色)"; + noneSw.addEventListener("click", () => { + const map = readMap(); + map[path] = -1; + writeMap(map); + pass(document); + markCurrent(grid, -1); + }); + grid.appendChild(noneSw); + const auto = document.createElement("div"); + auto.className = "zc-pt-swatch zc-pt-auto"; + auto.textContent = "自"; + auto.dataset.v = "auto"; + auto.title = "跟随全局自动配色(开启=按路径哈希,关闭=无颜色)"; + auto.addEventListener("click", () => { + const map = readMap(); + delete map[path]; + writeMap(map); + pass(document); + markCurrent(grid, "auto"); + }); + grid.appendChild(auto); + menu.appendChild(grid); + markCurrent(grid, String(readMap()[path] ?? "auto")); + + const sep = document.createElement("div"); + sep.className = "zc-pt-sep"; + sep.textContent = "图标"; + menu.appendChild(sep); + + const igrid = document.createElement("div"); + igrid.className = "zc-pt-grid"; + for (const id of Object.keys(ICONS)) { + const b = document.createElement("div"); + b.className = "zc-pt-isw"; + b.dataset.v = id; + b.title = id; + b.innerHTML = + '' + + iconInner(id) + + ""; + b.addEventListener("click", () => { + const imap = readIconMap(); + imap[path] = id; + writeIconMap(imap); + pass(document); + markCurrent(igrid, id); + }); + igrid.appendChild(b); + } + const none = document.createElement("div"); + none.className = "zc-pt-isw zc-pt-none"; + none.textContent = "无"; + none.dataset.v = "none"; + none.title = "不使用项目图标"; + none.addEventListener("click", () => { + const imap = readIconMap(); + delete imap[path]; + writeIconMap(imap); + pass(document); + markCurrent(igrid, "none"); + }); + igrid.appendChild(none); + menu.appendChild(igrid); + markCurrent(igrid, readIconMap()[path] ?? "none"); + + const sep2 = document.createElement("div"); + sep2.className = "zc-pt-sep"; + sep2.textContent = "Prism 全局"; + menu.appendChild(sep2); + const st = readSettings(); + for (const tg of [ + { key: "dimTitles", label: "压暗对话标题" }, + { key: "brightenThinking", label: "调亮思考动画" }, + { key: "autoColor", label: "自动配色(未指定的项目)" }, + { key: "groupRecency", label: "侧边栏按最近活跃排序" }, + ]) { + const row = document.createElement("div"); + row.className = "zc-pt-tg"; + row.dataset.on = st[tg.key] ? "1" : "0"; + const lb = document.createElement("span"); + lb.textContent = tg.label; + const sw = document.createElement("span"); + sw.className = "zc-pt-tg-sw"; + row.appendChild(lb); + row.appendChild(sw); + row.addEventListener("click", () => { + const cur = readSettings(); + cur[tg.key] = !cur[tg.key]; + writeSettings(cur); + applySettingsStyle(); + pass(document); // reapplies or tears down the recency ordering + row.dataset.on = cur[tg.key] ? "1" : "0"; + }); + menu.appendChild(row); + } + + document.body.appendChild(menu); + const r = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(x, window.innerWidth - r.width - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, window.innerHeight - r.height - 8)) + "px"; + document.addEventListener("pointerdown", onDocPointer, true); + document.addEventListener("keydown", onDocKey, true); + document.addEventListener("contextmenu", onMenuCtx, true); + } catch (err) { + try { + console.error("[zc-prism] picker", err); + } catch (_) {} + } + } + + // Per-conversation picker: overrides the color of a single conversation + // row; "自" clears the override so the row follows its project again. + function openTaskPicker(key, title, x, y) { + try { + if (!key) return; + closeMenu(); + injectStyle(); + menu = document.createElement("div"); + menu.className = "zc-pt-menu"; + const cap = document.createElement("div"); + cap.className = "zc-pt-title"; + cap.textContent = title || "此对话"; + menu.appendChild(cap); + const hint = document.createElement("div"); + hint.className = "zc-pt-sep"; + hint.style.margin = "0 0 8px"; + hint.textContent = "只影响这一条对话,未选时跟随项目"; + menu.appendChild(hint); + const grid = document.createElement("div"); + grid.className = "zc-pt-grid"; + for (const h of HUES) { + const sw = document.createElement("div"); + sw.className = "zc-pt-swatch"; + sw.style.background = "hsl(" + h + " 70% 55%)"; + sw.dataset.v = String(h); + sw.title = "色相 " + h; + sw.addEventListener("click", () => { + const m = readTaskMap(); + m[key] = h; + writeTaskMap(m); + pass(document); + markCurrent(grid, h); + }); + grid.appendChild(sw); + } + const noneSw = document.createElement("div"); + noneSw.className = "zc-pt-swatch zc-pt-none"; + noneSw.dataset.v = "-1"; + noneSw.title = "透明(本条不染色,优先于项目颜色)"; + noneSw.addEventListener("click", () => { + const m = readTaskMap(); + m[key] = -1; + writeTaskMap(m); + pass(document); + markCurrent(grid, -1); + }); + grid.appendChild(noneSw); + const auto = document.createElement("div"); + auto.className = "zc-pt-swatch zc-pt-auto"; + auto.textContent = "自"; + auto.dataset.v = "auto"; + auto.title = "清除对话颜色,恢复跟随项目"; + auto.addEventListener("click", () => { + const m = readTaskMap(); + delete m[key]; + writeTaskMap(m); + pass(document); + markCurrent(grid, "auto"); + }); + grid.appendChild(auto); + menu.appendChild(grid); + markCurrent(grid, String(readTaskMap()[key] ?? "auto")); + document.body.appendChild(menu); + const r = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(x, window.innerWidth - r.width - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, window.innerHeight - r.height - 8)) + "px"; + document.addEventListener("pointerdown", onDocPointer, true); + document.addEventListener("keydown", onDocKey, true); + document.addEventListener("contextmenu", onMenuCtx, true); + } catch (err) { + try { + console.error("[zc-prism] task picker", err); + } catch (_) {} + } + } + + // Per-automation picker: overrides the color of a single automation card; + // "自" clears the override so the card follows its project again. + function openAutomationPicker(id, title, x, y) { + try { + if (!id) return; + closeMenu(); + injectStyle(); + menu = document.createElement("div"); + menu.className = "zc-pt-menu"; + const cap = document.createElement("div"); + cap.className = "zc-pt-title"; + cap.textContent = title || "此自动化"; + menu.appendChild(cap); + const hint = document.createElement("div"); + hint.className = "zc-pt-sep"; + hint.style.margin = "0 0 8px"; + hint.textContent = "只影响这一条自动化,未选时跟随所在项目"; + menu.appendChild(hint); + const grid = document.createElement("div"); + grid.className = "zc-pt-grid"; + for (const h of HUES) { + const sw = document.createElement("div"); + sw.className = "zc-pt-swatch"; + sw.style.background = "hsl(" + h + " 70% 55%)"; + sw.dataset.v = String(h); + sw.title = "色相 " + h; + sw.addEventListener("click", () => { + const m = readAutoMap(); + m[id] = h; + writeAutoMap(m); + pass(document); + markCurrent(grid, h); + }); + grid.appendChild(sw); + } + const noneSw = document.createElement("div"); + noneSw.className = "zc-pt-swatch zc-pt-none"; + noneSw.dataset.v = "-1"; + noneSw.title = "透明(本条不染色,优先于项目颜色)"; + noneSw.addEventListener("click", () => { + const m = readAutoMap(); + m[id] = -1; + writeAutoMap(m); + pass(document); + markCurrent(grid, -1); + }); + grid.appendChild(noneSw); + const auto = document.createElement("div"); + auto.className = "zc-pt-swatch zc-pt-auto"; + auto.textContent = "自"; + auto.dataset.v = "auto"; + auto.title = "清除自动化颜色,恢复跟随项目"; + auto.addEventListener("click", () => { + const m = readAutoMap(); + delete m[id]; + writeAutoMap(m); + pass(document); + markCurrent(grid, "auto"); + }); + grid.appendChild(auto); + menu.appendChild(grid); + markCurrent(grid, String(readAutoMap()[id] ?? "auto")); + document.body.appendChild(menu); + const r = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(x, window.innerWidth - r.width - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, window.innerHeight - r.height - 8)) + "px"; + document.addEventListener("pointerdown", onDocPointer, true); + document.addEventListener("keydown", onDocKey, true); + document.addEventListener("contextmenu", onMenuCtx, true); + } catch (err) { + try { + console.error("[zc-prism] automation picker", err); + } catch (_) {} + } + } + + document.addEventListener( + "contextmenu", + (e) => { + try { + const target = e.target instanceof Element ? e.target : null; + if (!target) return; + const header = target.closest(HEADER_SELECTOR); + if (header) { + const path = projectOfHeader(header); + if (path) { + e.preventDefault(); + e.stopPropagation(); + openPicker(path, e.clientX, e.clientY); + } + return; + } + const acard = target.closest(AUTOMATION_SELECTOR); + if (acard) { + const id = automationIdOf(acard); + if (id) { + e.preventDefault(); + e.stopPropagation(); + openAutomationPicker(id, automationTitleOf(acard), e.clientX, e.clientY); + } + return; + } + if (e.altKey) { + const row = target.closest(ROW_SELECTOR); + if (row) { + const path = projectOfRow(row); + if (path) { + e.preventDefault(); + e.stopPropagation(); + openPicker(path, e.clientX, e.clientY); + } + } + } + } catch (err) { + try { + console.error("[zc-prism] ctx", err); + } catch (_) {} + } + }, + true, + ); + + let queued = false; + const observer = new MutationObserver(() => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + pass(document); + }); + }); + + function start() { + injectStyle(); + applySettingsStyle(); + pass(document); + observer.observe(document.body, { childList: true, subtree: true }); + try { + console.info("[zc-prism] renderer shim loaded"); + } catch (_) {} + // remember what was last interacted with, so the native menus can be + // enhanced with our entries when they open (project header "..." menu → + // project color/rename; row context menu → per-conversation color; + // automation card "..." menu → per-automation color) + document.addEventListener( + "pointerdown", + (e) => { + try { + const t = e.target instanceof Element ? e.target : null; + if (!t) return; + const row = t.closest(ROW_SELECTOR); + if (row) { + const key = taskKeyOf(row); + if (key) { + pendingTask = { key, title: taskTitleOf(row), at: Date.now() }; + pendingAutomation = null; + return; + } + } + const header = t.closest(HEADER_SELECTOR); + if (header) { + const p = projectOfHeader(header); + if (p) { + pendingProject = { path: p, at: Date.now() }; + pendingTask = null; + pendingAutomation = null; + return; + } + } + const acard = t.closest(AUTOMATION_SELECTOR); + if (acard) { + const id = automationIdOf(acard); + if (id) { + pendingAutomation = { id, title: automationTitleOf(acard), at: Date.now() }; + pendingTask = null; + pendingProject = null; + return; + } + } + // Press anywhere else: drop the pending target, so a menu opened + // from it (account popup, model picker, …) never inherits one. + pendingTask = null; + pendingProject = null; + pendingAutomation = null; + } catch (_) {} + }, + true, + ); + window.__zcPrism = { + version: 14, + pass, + openPicker, + openTaskPicker, + openAutomationPicker, + openRename, + applyRecency, + removeGroupRecency, + taskKeyOf, + automationIdOf, + get recencyState() { + return recencyState; + }, + }; + } + + if (document.body) start(); + else document.addEventListener("DOMContentLoaded", start, { once: true }); + } catch (err) { + try { + console.error("[zc-prism]", err); + } catch (_) { + /* never break the app because of the tint */ + } + } +})(); diff --git a/marketplace.json b/marketplace.json index 350c9420..699d1573 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1386,6 +1386,35 @@ "unity-cli", "skills" ] + }, + { + "name": "prism", + "displayName": "Prism", + "displayName_i18n": { + "en": "Prism", + "zh-CN": "棱镜" + }, + "source": "./external_plugins/prism", + "description": "EXPERIMENTAL, OPT-IN: give every project in the ZCode desktop sidebar its own color, icon and display name, recolor individual conversations and automation cards, and keep the sidebar ordered by the most recently active conversation. Nothing is modified until you create the opt-in marker (~/.zcode/prism-opt-in); a SessionStart hook then installs a small renderer shim into the ZCode.app bundle (macOS), re-applies it after app updates, and automatically restores the original bundle once the marker is removed.", + "description_i18n": { + "en": "EXPERIMENTAL, OPT-IN: give every project in the ZCode desktop sidebar its own color, icon and display name, recolor individual conversations and automation cards, and keep the sidebar ordered by the most recently active conversation. Nothing is modified until you create the opt-in marker (~/.zcode/prism-opt-in); a SessionStart hook then installs a small renderer shim into the ZCode.app bundle (macOS), re-applies it after app updates, and automatically restores the original bundle once the marker is removed.", + "zh-CN": "实验性、需显式开启:为 ZCode 桌面端侧边栏的每个项目提供专属颜色、图标与显示名,支持单条对话与自动化卡片换色,并可让侧边栏按最近活跃自动排序。创建开启标记(~/.zcode/prism-opt-in)前不做任何修改;开启后 SessionStart 钩子向 ZCode.app 包内安装轻量渲染层 shim(仅限 macOS,应用更新后自动恢复),移除标记即自动还原原始应用包。" + }, + "version": "0.6.0", + "author": { + "name": "Carlos", + "url": "https://github.com/saralaaga" + }, + "category": "developer-tools", + "keywords": [ + "sidebar", + "colors", + "icons", + "rename", + "appearance", + "customization" + ], + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/prism/icon.png" } ] }