DSH-Arch-Setup/plugins/dsh-ui-tweaks/lib/client.js
Шурупов Илья Викторович 500b5532a8 Add in-chat collapse and Zen controls
Hide workspace and agent content when the native sidebar rail is collapsed. Add persistent whole-composer collapse controls and a temporary Zen mode that removes side panels, chat header, and composer while keeping an in-chat exit control available. Cover state transitions, Escape handling, React rerender idempotence, and cleanup with a dependency-free DOM regression test.
2026-09-14 10:49:53 +03:00

459 lines
21 KiB
JavaScript

/**
* dsh-ui-tweaks — browser half.
*
* A single client-only plugin that bundles six independent UI tweaks, each
* toggleable in the CONFIG block below:
*
* 0) BASE_BG — soften the near-black base background token
* (`--dsw-alias-bg-base`, #151517 by default). Drives the main content and,
* via feature (1), the sidebar, so both tone together.
*
* 1) MATCH_SIDEBAR_BG — the left sidebar root paints
* `background: var(--dsw-specific-sidebar-fill)` while the main content
* paints `var(--dsw-alias-bg-base)`. We alias the former token to the
* latter so the sidebar shares the transcript background (theme-agnostic).
*
* 2) SIDEBAR_BRAND_TEXT / HIDE_NEW_SESSION — keep the sidebar's logo row so
* its NATIVE collapse/expand toggle stays available (that toggle is what
* the compact-sidebar tweak used to remove), but replace the DeepSeek brand
* logo with plain text (e.g. "Harness"), and optionally hide the New
* Session button. Sidebar CSS-module classes are content-hashed, so we
* match the stable suffix; the brand button is pinpointed with
* `[class*="_brand"]:has([class*="_brandIdentity"])`.
*
* 3) COLLAPSE_HEADER — collapse the conversation-header utility pills
* (Session log, Stop All, Resume…, Git) behind one `⋯` dropdown. We never
* move or synthetically click the real buttons — their React handlers stay
* intact; we only restyle the `.headerUtilities` container into a panel and
* add a vanilla trigger. A MutationObserver re-applies after React
* re-renders. Open/close state lives in `data-*` attrs React won't clobber.
*
* 4) PANEL_BG — tone the elevated surfaces so they sit close to the base
* background: the "Message the agent" composer (`--dsw-specific-input-major`),
* code blocks (`--dsw-alias-markdown-code-block` + `…-banner`), the Ongoing
* Goal bar (`--dsw-specific-tip`), and generic panels/dropdowns
* (`--dsw-alias-bg-layer-1/2`). All are overridden to one shade.
*
* 5) STYLE_PROCESSED_LIKE_PROMPT — restyle the smooth-stream "Processed <time>"
* collapsible (stable class `dshss-processed`) to match the prompt composer:
* full-width rounded pill, thin border, panel background, soft shadow.
*
* All static CSS shares one <style>; feature 3 also adds dynamic wiring.
* Everything is HMR-safe via a single combined cleanup.
* @module dsh-ui-tweaks/client
*/
window.__ModuleLoader__.load({
id: "dsh-ui-tweaks",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// ---- CONFIG (edit here) -----------------------------------------------
// (0) Background tone — the app's base background (harness default
// #151517). This drives the main content AND, via feature (1), the
// sidebar, so both change together. Set to "" to keep the harness default.
const BASE_BG = "#121317";
// (1) Sidebar background — paint the sidebar with the main content fill.
const MATCH_SIDEBAR_BG = true;
// Fill the sidebar takes. Default = main content bg (seamless). Use any
// color or var, e.g. "var(--dsw-alias-bg-layer-2)" for a subtle tint.
const SIDEBAR_FILL = "var(--dsw-alias-bg-base)";
// (2) Sidebar header — keep the logo row (and its native collapse/expand
// toggle button), but replace the DeepSeek brand logo with plain text.
// Set to "" to show no brand text at all (leaving only the toggle).
const SIDEBAR_BRAND_TEXT = "Harness";
// Remove the New Session button.
const HIDE_NEW_SESSION = true;
// (3) Header menu — collapse header utility buttons into one dropdown.
const COLLAPSE_HEADER = true;
// Label shown on the collapsed trigger button.
const TRIGGER_LABEL = "⋯";
// Close the dropdown after clicking one of its items.
const CLOSE_ON_ITEM_CLICK = true;
// (4) Panel + prompt surfaces — tone the elevated panels (the composer,
// code blocks, the Ongoing Goal bar, and generic dropdowns/inputs) to sit
// a touch off the base background. Default = slightly lighter than BASE_BG.
// Set to "" to leave the harness defaults.
const PANEL_BG = "#24262b";
// (5) Processed bar — restyle the smooth-stream "Processed <time>"
// collapsible (class `dshss-processed`, its own plugin's FOLD_CSS) to look
// like the prompt composer: full-width rounded pill with a thin border,
// the panel background, and a soft shadow.
const STYLE_PROCESSED_LIKE_PROMPT = true;
// -----------------------------------------------------------------------
const STYLE_ID = "dsh-ui-tweaks-style";
const COMPOSER_STORAGE_KEY = "dsh-ui-tweaks:composer-collapsed";
let composerCollapsed = readStoredComposerState();
let zenActive = false;
/** Assemble the combined static CSS for whichever features are enabled. */
function buildCss() {
const rules = [];
// (0) base background tone (main content + sidebar via feature 1)
if (BASE_BG) {
rules.push("*{--dsw-alias-bg-base:" + BASE_BG + " !important;}");
}
// (4) elevated panel + prompt surfaces (composer, code blocks, goal bar,
// generic panels) — one shade so they sit close to the base background.
if (PANEL_BG) {
rules.push(
"*{" +
"--dsw-specific-input-major:" + PANEL_BG + " !important;" + // composer card
"--dsw-alias-markdown-code-block:" + PANEL_BG + " !important;" + // code block body
"--dsw-alias-markdown-code-block-banner:" + PANEL_BG + " !important;" +// code block copy bar
"--dsw-specific-tip:" + PANEL_BG + " !important;" + // Ongoing Goal bar
"--dsw-alias-bg-layer-1:" + PANEL_BG + " !important;" + // generic panels/inputs
"--dsw-alias-bg-layer-2:" + PANEL_BG + " !important;" + // generic elevated
"}"
);
}
// (1) sidebar fill
if (MATCH_SIDEBAR_BG) {
rules.push("*{--dsw-specific-sidebar-fill:" + SIDEBAR_FILL + " !important;}");
}
// (2) sidebar header: keep the native collapse toggle, restyle the brand.
// Hide the original brand contents (logo mark + wordmark, text or svg).
rules.push('[class*="_brandIdentity"]{display:none !important;}');
if (SIDEBAR_BRAND_TEXT) {
// JSON.stringify safely double-quotes/escapes the label for CSS content.
const label = JSON.stringify(String(SIDEBAR_BRAND_TEXT));
rules.push(
// The brand BUTTON is the only element that *contains* the identity,
// so `:has()` pinpoints it regardless of class hashing or nesting.
'[class*="_brand"]:has([class*="_brandIdentity"]){overflow:visible !important;}',
'[class*="_brand"]:has([class*="_brandIdentity"])::after{content:' + label +
";font-size:18px;font-weight:600;letter-spacing:.04em;" +
"color:var(--dsw-alias-label-primary);white-space:nowrap;}",
// In the collapsed rail, hide the brand so only the toggle remains.
'[class*="_collapsed"] [class*="_brand"]:has([class*="_brandIdentity"]){display:none !important;}'
);
} else {
rules.push('[class*="_brand"]:has([class*="_brandIdentity"]){display:none !important;}');
}
if (HIDE_NEW_SESSION) {
rules.push('[class*="_root"] [class*="_newSession"]{display:none !important;}');
}
// (3) header menu (static parts)
if (COLLAPSE_HEADER) {
rules.push(
// host anchors the absolutely-positioned panel
'[data-dsh-hdr-menu="host"]{position:relative}',
// the utility row becomes a dropdown panel
'[data-dsh-hdr-menu="panel"]{' +
"position:absolute;top:calc(100% + 8px);right:0;z-index:2147483000;" +
"margin-left:0 !important;flex-direction:column !important;" +
"align-items:stretch !important;gap:6px !important;padding:8px;min-width:210px;" +
"background:var(--dsw-alias-bg-elevated, var(--dsw-specific-sidebar-fill, var(--dsw-alias-bg-base,#1f1f1f)));" +
"border:1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12));" +
"border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.45)}",
// hidden unless the host is marked open
'[data-dsh-hdr-menu="host"]:not([data-open="1"]) [data-dsh-hdr-menu="panel"]{display:none !important}',
// stack items full width, left aligned
'[data-dsh-hdr-menu="panel"] > *{width:100%;box-sizing:border-box;justify-content:flex-start !important}',
// known grouped entry (fleet-control) stacks vertically too
'[data-dsh-hdr-menu="panel"] .dsh-fleet-control{flex-direction:column;align-items:stretch;gap:6px;width:100%}',
'[data-dsh-hdr-menu="panel"] .dsh-fleet-control > *{width:100%;justify-content:flex-start}',
// the trigger pill
".dsh-hdr-menu-trigger{border:1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12));" +
"height:32px;min-width:32px;color:var(--dsw-alias-label-primary);background:transparent;" +
"border-radius:18px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;" +
"gap:5px;padding:6px 12px;font-size:15px;line-height:20px;white-space:nowrap;" +
"font-family:var(--dsw-font-family)}",
".dsh-hdr-menu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}",
'.dsh-hdr-menu-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover)}'
);
}
// (5) Processed bar restyled like the prompt composer (.uV2eYG_card:
// 1px border, input-major bg, 22px radius, shadow-lv2, full width). The
// `dshss-processed` class is stable (its own plugin's FOLD_CSS), so we
// override those exact declarations with !important.
if (STYLE_PROCESSED_LIKE_PROMPT) {
rules.push(
// Collapsed pill — looks like the prompt composer. Label + chevron
// stay grouped on the left (flex-start) so the arrow sits right next
// to the text; a tiny spacer keeps the box full width.
".dshss-processed{" +
"box-sizing:border-box !important;" +
"width:100% !important;max-width:var(--dsh-chat-content-width) !important;" +
"align-self:center !important;" +
"justify-content:flex-start !important;gap:8px !important;" +
"min-height:0 !important;padding:5px 14px !important;" +
"border:1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-border-l2, rgba(255,255,255,.12))) !important;" +
"border-radius:22px !important;" +
"background:var(--dsw-specific-input-major) !important;" +
"box-shadow:var(--dsw-shadow-lv2) !important;" +
"color:var(--dsw-alias-label-secondary) !important;}",
// Keep the chevron aligned to the text baseline, not stretched.
".dshss-processed .dshss-processed-chevron{align-self:center !important;margin:0 !important;}",
".dshss-processed:hover{" +
"background:color-mix(in srgb, var(--dsw-specific-input-major), #ffffff 6%) !important;" +
"border-color:var(--dsw-alias-border-l2, rgba(255,255,255,.18)) !important;" +
"color:var(--dsw-alias-label-primary) !important;}",
// Expanded — drop the pill and blend into the page background: use the
// base bg, no border, no shadow, so the opened section reads as inline
// content rather than a floating capsule.
'.dshss-processed[aria-expanded="true"]{' +
"background:var(--dsw-alias-bg-base) !important;" +
"border-color:transparent !important;" +
"box-shadow:none !important;" +
"color:var(--dsw-alias-label-primary) !important;}",
// Expanded hover — subtle highlight since it otherwise blends into bg.
'.dshss-processed[aria-expanded="true"]:hover{' +
"background:color-mix(in srgb, var(--dsw-alias-bg-base), #ffffff 6%) !important;}"
);
}
rules.push(
'[data-sidebar-collapsed] [class*="_sidebarCol"] [class*="_regionArea"]{display:none !important;}',
'[data-composer-seat]{position:relative}',
'.dsh-chat-controls{position:absolute;right:24px;bottom:calc(100% + 6px);z-index:12;display:flex;align-items:center;gap:6px}',
'.dsh-chat-control{height:28px;min-width:28px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));border-radius:14px;background:var(--dsw-specific-input-major);color:var(--dsw-alias-label-secondary);font:var(--dsw-font-xs-13);cursor:pointer;box-shadow:var(--dsw-shadow-lv1)}',
'.dsh-chat-control:hover{color:var(--dsw-alias-label-primary);background:color-mix(in srgb,var(--dsw-specific-input-major),#fff 6%)}',
'[data-composer-seat][data-dsh-composer-collapsed="1"]{min-height:38px;justify-content:center}',
'[data-composer-seat][data-dsh-composer-collapsed="1"]>:not(.dsh-chat-controls){display:none !important}',
'[data-composer-seat][data-dsh-composer-collapsed="1"]>.dsh-chat-controls{position:static;align-self:flex-end;margin:4px 24px 6px 0}',
'html[data-dsh-zen="1"] [class*="_frame"][style*="grid-template-columns"]{grid-template-columns:0 minmax(0,1fr) 0 !important}',
'html[data-dsh-zen="1"] [class*="_sidebarCol"],html[data-dsh-zen="1"] [class*="_detailsCol"],html[data-dsh-zen="1"] [class*="_handle"]{display:none !important}',
'html[data-dsh-zen="1"] [data-phase]>[class*="_header"]{display:none !important}',
'html[data-dsh-zen="1"] [data-composer-seat]{min-height:38px;justify-content:center}',
'html[data-dsh-zen="1"] [data-composer-seat]>:not(.dsh-chat-controls){display:none !important}',
'html[data-dsh-zen="1"] [data-composer-seat]>.dsh-chat-controls{position:static;align-self:flex-end;margin:4px 24px 6px 0}',
'html[data-dsh-zen="1"] .dsh-composer-collapse-toggle{display:none !important}'
);
return rules.join("");
}
/** Insert (or update) the shared override <style>. */
function installStyle() {
let el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = STYLE_ID;
el.dataset.plugin = "dsh-ui-tweaks";
(document.head || document.documentElement).appendChild(el);
}
el.textContent = buildCss();
}
// ---- (3) header-menu dynamic behavior ---------------------------------
function readStoredComposerState() {
try {
return window.localStorage.getItem(COMPOSER_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function storeComposerState() {
try {
window.localStorage.setItem(COMPOSER_STORAGE_KEY, composerCollapsed ? "1" : "0");
} catch {}
}
function updateChatControls() {
document.querySelectorAll("[data-composer-seat]").forEach((seat) => {
if (composerCollapsed) seat.setAttribute("data-dsh-composer-collapsed", "1");
else seat.removeAttribute("data-dsh-composer-collapsed");
const collapse = seat.querySelector(".dsh-composer-collapse-toggle");
if (collapse) {
const collapseLabel = composerCollapsed ? "⌃ Chat" : "⌄ Chat";
if (collapse.textContent !== collapseLabel) collapse.textContent = collapseLabel;
collapse.title = composerCollapsed ? "Expand bottom chat bar" : "Collapse bottom chat bar";
collapse.setAttribute("aria-expanded", composerCollapsed ? "false" : "true");
}
const zen = seat.querySelector(".dsh-zen-toggle");
if (zen) {
const zenLabel = zenActive ? "Exit Zen" : "Zen";
if (zen.textContent !== zenLabel) zen.textContent = zenLabel;
zen.title = zenActive ? "Exit Zen mode" : "Enter Zen mode";
zen.setAttribute("aria-pressed", zenActive ? "true" : "false");
}
});
}
function setComposerCollapsed(collapsed) {
composerCollapsed = collapsed;
storeComposerState();
updateChatControls();
}
function setZenActive(active) {
zenActive = active;
if (active) document.documentElement.setAttribute("data-dsh-zen", "1");
else document.documentElement.removeAttribute("data-dsh-zen");
updateChatControls();
}
function makeChatControls() {
const controls = document.createElement("div");
controls.className = "dsh-chat-controls";
const collapse = document.createElement("button");
collapse.type = "button";
collapse.className = "dsh-chat-control dsh-composer-collapse-toggle";
collapse.setAttribute("aria-label", "Toggle bottom chat bar");
collapse.addEventListener("click", () => setComposerCollapsed(!composerCollapsed));
const zen = document.createElement("button");
zen.type = "button";
zen.className = "dsh-chat-control dsh-zen-toggle";
zen.setAttribute("aria-label", "Toggle Zen mode");
zen.addEventListener("click", () => setZenActive(!zenActive));
controls.append(collapse, zen);
return controls;
}
function enhanceChatControls() {
document.querySelectorAll("[data-composer-seat]").forEach((seat) => {
if (!seat.querySelector(":scope > .dsh-chat-controls")) seat.appendChild(makeChatControls());
});
updateChatControls();
}
function removeChatControls() {
setZenActive(false);
document.querySelectorAll(".dsh-chat-controls").forEach((controls) => controls.remove());
document.querySelectorAll("[data-composer-seat]").forEach((seat) => seat.removeAttribute("data-dsh-composer-collapsed"));
}
/** Close every open header menu (optionally sparing one host). */
function closeAll(except) {
document.querySelectorAll('[data-dsh-hdr-menu="host"][data-open="1"]').forEach((host) => {
if (host === except) return;
host.removeAttribute("data-open");
const t = host.querySelector(".dsh-hdr-menu-trigger");
if (t) t.setAttribute("aria-expanded", "false");
});
}
/** Build the trigger button for a given host (the .headerActions element). */
function makeTrigger(host) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "dsh-hdr-menu-trigger";
btn.textContent = TRIGGER_LABEL;
btn.title = "Session actions";
btn.setAttribute("aria-label", "Session actions menu");
btn.setAttribute("aria-expanded", "false");
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const open = host.getAttribute("data-open") === "1";
closeAll(host);
if (open) {
host.removeAttribute("data-open");
btn.setAttribute("aria-expanded", "false");
} else {
host.setAttribute("data-open", "1");
btn.setAttribute("aria-expanded", "true");
}
});
return btn;
}
/** Enhance every header-utilities region currently in the DOM (idempotent). */
function enhance() {
enhanceChatControls();
if (!COLLAPSE_HEADER) return;
const panels = document.querySelectorAll('[class*="_headerUtilities"]');
panels.forEach((panel) => {
const host = panel.parentElement;
if (!host) return;
panel.setAttribute("data-dsh-hdr-menu", "panel");
host.setAttribute("data-dsh-hdr-menu", "host");
// close-on-item-click wiring (once)
if (CLOSE_ON_ITEM_CLICK && panel.getAttribute("data-dsh-hdr-wired") !== "1") {
panel.setAttribute("data-dsh-hdr-wired", "1");
panel.addEventListener("click", (e) => {
if (e.target && e.target.closest("button,a,[role='menuitem'],[role='button']")) {
// let the real handler run, then close on next tick
setTimeout(() => closeAll(), 0);
}
});
}
// ensure exactly one trigger, placed right after the panel
if (!host.querySelector(".dsh-hdr-menu-trigger")) {
const trigger = makeTrigger(host);
if (panel.nextSibling) host.insertBefore(trigger, panel.nextSibling);
else host.appendChild(trigger);
}
});
}
let scheduled = false;
function scheduleEnhance() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
enhance();
});
}
/** No client services required — pure DOM/CSS. */
const inject = [];
/** Client plugin body: install CSS, then wire the header dropdown if on. */
function apply(ctx) {
const start = () => {
installStyle();
enhance();
const observer = new MutationObserver(scheduleEnhance);
observer.observe(document.body, { childList: true, subtree: true });
const onDocClick = (e) => {
const host = e.target && e.target.closest && e.target.closest('[data-dsh-hdr-menu="host"]');
if (!host) closeAll();
};
const onKey = (e) => {
if (e.key !== "Escape") return;
closeAll();
if (zenActive) setZenActive(false);
};
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKey, true);
return () => {
observer.disconnect();
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKey, true);
closeAll();
removeChatControls();
const style = document.getElementById(STYLE_ID);
if (style) style.remove();
document.querySelectorAll(".dsh-hdr-menu-trigger").forEach((n) => n.remove());
document.querySelectorAll('[data-dsh-hdr-menu]').forEach((n) => {
n.removeAttribute("data-dsh-hdr-menu");
n.removeAttribute("data-open");
n.removeAttribute("data-dsh-hdr-wired");
});
};
};
if (ctx && typeof ctx.effect === "function") ctx.effect(start, "dsh-ui-tweaks: sidebar + header tweaks");
else start();
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
},
});