diff --git a/SOURCE_REVISIONS.md b/SOURCE_REVISIONS.md index addd1b4..a6f1c2e 100644 --- a/SOURCE_REVISIONS.md +++ b/SOURCE_REVISIONS.md @@ -3,7 +3,7 @@ | Component | Source | |---|---| | `dsh-touched-git` | Local repository `main` at `c33e67bdb43620b9a65fd5684971ade1caa14153`; one test-only absolute-home fallback was made portable with `homedir()` | -| Other local plugins | Imported from non-Git directories under `~/.dsh/plugins` | +| Other local plugins | Initially imported from non-Git directories under `~/.dsh/plugins`; maintained in this repository and deployed back to the live plugin directories | | DSH CLI | npm `@deepseek-ai/dsh@0.1.1-rc.2` | | OpenVSCode Server | Exact 1.109.5 runtime bundled and SHA-256 pinned by `manifests/editor-runtime.tsv`; upstream release metadata retained in `plugins/dsh-touched-git/editor-release.json` | | cloudflared | Exact 2026.8.3 Linux x86-64 binary bundled and SHA-256 pinned by `manifests/cloudflared.tsv` | diff --git a/plugins/dsh-compact-sidebar/lib/client.js b/plugins/dsh-compact-sidebar/lib/client.js index 009d989..03548e8 100644 --- a/plugins/dsh-compact-sidebar/lib/client.js +++ b/plugins/dsh-compact-sidebar/lib/client.js @@ -42,6 +42,7 @@ window.__ModuleLoader__.load({ if (HIDE_NEW_SESSION) { rules.push('[class*="_root"] [class*="_newSession"]{display:none !important;}'); } + rules.push('[data-sidebar-collapsed] [class*="_sidebarCol"] [class*="_regionArea"]{display:none !important;}'); const STYLE_ID = "dsh-compact-sidebar-style"; const cssText = rules.join(""); diff --git a/plugins/dsh-compact-sidebar/package.json b/plugins/dsh-compact-sidebar/package.json index ba6f913..2e03bc1 100644 --- a/plugins/dsh-compact-sidebar/package.json +++ b/plugins/dsh-compact-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "dsh-compact-sidebar", - "description": "dsh web plugin: remove the dead vertical space between the logo row and the New Session button by tightening the sidebar logo row. Client-only.", - "version": "0.1.0", + "description": "dsh web plugin: hide optional sidebar chrome and suppress the workspace or agent tree while the native sidebar rail is collapsed. Client-only.", + "version": "0.1.1", "private": true, "type": "module", "engines": { diff --git a/plugins/dsh-ui-tweaks/lib/client.js b/plugins/dsh-ui-tweaks/lib/client.js index 1e07959..da18883 100644 --- a/plugins/dsh-ui-tweaks/lib/client.js +++ b/plugins/dsh-ui-tweaks/lib/client.js @@ -89,6 +89,9 @@ window.__ModuleLoader__.load({ // ----------------------------------------------------------------------- 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() { @@ -213,6 +216,24 @@ window.__ModuleLoader__.load({ ); } + 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(""); } @@ -230,6 +251,87 @@ window.__ModuleLoader__.load({ // ---- (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) => { @@ -267,6 +369,8 @@ window.__ModuleLoader__.load({ /** 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; @@ -310,15 +414,6 @@ window.__ModuleLoader__.load({ const start = () => { installStyle(); - // Features 1 & 2 are pure CSS — nothing more to wire. - if (!COLLAPSE_HEADER) { - return () => { - const style = document.getElementById(STYLE_ID); - if (style) style.remove(); - }; - } - - // Feature 3 — dynamic header dropdown. enhance(); const observer = new MutationObserver(scheduleEnhance); @@ -329,7 +424,9 @@ window.__ModuleLoader__.load({ if (!host) closeAll(); }; const onKey = (e) => { - if (e.key === "Escape") closeAll(); + if (e.key !== "Escape") return; + closeAll(); + if (zenActive) setZenActive(false); }; document.addEventListener("click", onDocClick, true); document.addEventListener("keydown", onKey, true); @@ -339,6 +436,7 @@ window.__ModuleLoader__.load({ 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()); diff --git a/plugins/dsh-ui-tweaks/package.json b/plugins/dsh-ui-tweaks/package.json index 4c520a2..1ea794e 100644 --- a/plugins/dsh-ui-tweaks/package.json +++ b/plugins/dsh-ui-tweaks/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ui-tweaks", - "description": "dsh web plugin: a bundle of toggleable UI tweaks — match the sidebar background to the main content, replace the sidebar brand logo with plain text while keeping the native collapse/expand toggle, hide the New Session button, and collapse the conversation-header utility buttons into a single dropdown menu. Client-only.", - "version": "0.1.0", + "description": "dsh web plugin: cohesive sidebar, header, composer-collapse, and Zen-mode controls for the DSH Web chat interface. Client-only.", + "version": "0.2.0", "private": true, "type": "module", "engines": { diff --git a/test/run.sh b/test/run.sh index 229117f..9fe29d1 100755 --- a/test/run.sh +++ b/test/run.sh @@ -7,6 +7,7 @@ cd "$repository_directory" bash -n install.sh uninstall.sh bin/dsh-app bin/free-port-3080.sh test/*.sh node --check scripts/apply-compatibility-patches.mjs node test/profile-manifest.test.mjs +node test/ui-controls.test.mjs python3 test/editor-artifacts.test.py python3 test/editor-runtime.test.py python3 test/cloudflared-artifact.test.py diff --git a/test/ui-controls.test.mjs b/test/ui-controls.test.mjs new file mode 100644 index 0000000..35c66c3 --- /dev/null +++ b/test/ui-controls.test.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import vm from 'node:vm' + +class Element { + constructor(tagName) { + this.tagName = tagName + this.attributes = new Map() + this.children = [] + this.listeners = new Map() + this.dataset = {} + this.parentElement = null + this.className = '' + this.textContent = '' + this.title = '' + this.type = '' + } + + append(...children) { + for (const child of children) this.appendChild(child) + } + + appendChild(child) { + child.parentElement = this + this.children.push(child) + return child + } + + addEventListener(name, listener) { + this.listeners.set(name, listener) + } + + click() { + this.listeners.get('click')?.({ preventDefault() {}, stopPropagation() {}, target: this }) + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)) + } + + getAttribute(name) { + return this.attributes.get(name) ?? null + } + + removeAttribute(name) { + this.attributes.delete(name) + } + + querySelector(selector) { + if (selector === ':scope > .dsh-chat-controls') return this.children.find((child) => child.hasClass('dsh-chat-controls')) ?? null + if (selector.startsWith('.')) return this.descendants().find((child) => child.hasClass(selector.slice(1))) ?? null + return null + } + + hasClass(name) { + return this.className.split(/\s+/).includes(name) + } + + descendants() { + return this.children.flatMap((child) => [child, ...child.descendants()]) + } + + remove() { + if (!this.parentElement) return + this.parentElement.children = this.parentElement.children.filter((child) => child !== this) + this.parentElement = null + } +} + +const documentElement = new Element('html') +const head = new Element('head') +const body = new Element('body') +const seat = new Element('div') +seat.setAttribute('data-composer-seat', '') +body.appendChild(seat) +const documentListeners = new Map() + +const allElements = () => [documentElement, head, body, ...head.descendants(), ...body.descendants()] +const document = { + documentElement, + head, + body, + createElement: (tagName) => new Element(tagName), + getElementById: (id) => allElements().find((element) => element.id === id) ?? null, + querySelectorAll(selector) { + if (selector === '[data-composer-seat]') return allElements().filter((element) => element.attributes.has('data-composer-seat')) + if (selector === '.dsh-chat-controls') return allElements().filter((element) => element.hasClass('dsh-chat-controls')) + if (selector === '.dsh-hdr-menu-trigger' || selector === '[data-dsh-hdr-menu]' || selector === '[class*="_headerUtilities"]') return [] + return [] + }, + addEventListener: (name, listener) => documentListeners.set(name, listener), + removeEventListener: (name) => documentListeners.delete(name), +} + +const storage = new Map() +const window = { + document, + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, String(value)), + }, +} +window.window = window +let definition +window.__ModuleLoader__ = { load: (candidate) => { definition = candidate } } + +class MutationObserver { + observe() {} + disconnect() {} +} + +const source = await readFile(new URL('../plugins/dsh-ui-tweaks/lib/client.js', import.meta.url), 'utf8') +vm.runInNewContext(source, { + window, + document, + MutationObserver, + requestAnimationFrame: (callback) => callback(), + setTimeout, +}) + +const plugin = definition.factory(() => {}) +let cleanup +plugin.apply({ effect: (start) => { cleanup = start() } }) + +const controls = seat.querySelector(':scope > .dsh-chat-controls') +assert(controls) +const collapse = controls.querySelector('.dsh-composer-collapse-toggle') +const zen = controls.querySelector('.dsh-zen-toggle') +assert.equal(collapse.textContent, '⌄ Chat') +assert.equal(zen.textContent, 'Zen') + +collapse.click() +assert.equal(seat.getAttribute('data-dsh-composer-collapsed'), '1') +assert.equal(storage.get('dsh-ui-tweaks:composer-collapsed'), '1') +assert.equal(collapse.textContent, '⌃ Chat') + +zen.click() +assert.equal(documentElement.getAttribute('data-dsh-zen'), '1') +assert.equal(zen.textContent, 'Exit Zen') +assert.equal(seat.getAttribute('data-dsh-composer-collapsed'), '1') + +documentListeners.get('keydown')?.({ key: 'Escape' }) +assert.equal(documentElement.getAttribute('data-dsh-zen'), null) +assert.equal(zen.textContent, 'Zen') + +cleanup() +assert.equal(seat.querySelector(':scope > .dsh-chat-controls'), null) +assert.equal(seat.getAttribute('data-dsh-composer-collapsed'), null) +assert.equal(documentElement.getAttribute('data-dsh-zen'), null) +assert(source.includes('[data-sidebar-collapsed] [class*="_sidebarCol"] [class*="_regionArea"]')) + +console.log('UI controls test passed')