From 082d7977da7adc96318ad2f96620f70af2032c64 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 18:00:40 +0000 Subject: [PATCH 1/7] Fix Cowork continuity and Files latency --- CHANGELOG.md | 30 ++++++++- README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- public/index.html | 4 +- scripts/run-test-suite.mjs | 2 +- src/client/app.ts | 45 ++++++++++--- src/client/channel.ts | 14 +++- src/client/cowork.ts | 102 ++++++++++++++---------------- src/client/styles.css | 7 +- src/server/channel-computers.ts | 11 +++- src/server/db.ts | 2 +- src/server/index.ts | 14 ++-- test/channel-computers.mjs | 2 +- test/channel-surfaces.mjs | 8 ++- test/cowork-browser.mjs | 91 +++++++++++++++++++++++++- test/fake-container.mjs | 2 + test/files-latency.mjs | 51 +++++++++++++++ test/sweep-server-integration.mjs | 2 + test/workspace-interactions.mjs | 3 +- 20 files changed, 307 insertions(+), 91 deletions(-) create mode 100644 test/files-latency.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5193279..cd0db4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.21] - 2026-07-27 + +### Fixed + +- Cowork's bare Option/Alt shortcut now carries the mic control belonging to + the focused Notes, Docs, or agent input and uses a capture-phase fallback + when the macOS desktop shell swallows the corresponding keyup. +- Long Cowork Notes give CodeMirror a finite editor frame and scroll through + its actual viewport in Write mode. +- Leaving Cowork, changing sections, or opening another file now destroys the + outgoing Yjs document and reloads the authoritative workspace file into a + fresh document. The stale browser recovery copy that could resurrect or + repeatedly duplicate prior text has been removed. +- Files directory, tree, text, and content reads now paint from the host mirror + immediately. One explicit background refresh coalesces the expensive channel + computer export and repaints the current listing when it completes. + +### Tests + +- Browser coverage toggles both Cowork editor and agent dictation through bare + Option/Alt, scrolls a 120-line Note, and proves section changes, full Cowork + navigation, and a five-line external replacement all reopen exactly once + without a recovery prompt. +- A fake Apple channel computer with an intentionally slow export proves cached + Files reads stay responsive, simultaneous refreshes coalesce, and guest-only + files appear after the background refresh. + ## [0.0.20] - 2026-07-27 ### Added @@ -583,7 +610,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.20...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.21...HEAD +[0.0.21]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.21 [0.0.20]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.20 [0.0.19]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.19 [0.0.18]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.18 diff --git a/README.md b/README.md index 9d3c2d1..ef74b25 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.20` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.21` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/package-lock.json b/package-lock.json index 7df63ca..a2885e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.20", + "version": "0.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.20", + "version": "0.0.21", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 581e27c..7a50697 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.20", + "version": "0.0.21", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/public/index.html b/public/index.html index 221f7b8..119ee21 100644 --- a/public/index.html +++ b/public/index.html @@ -30,12 +30,12 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +
- + diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 5669c3a..085815b 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -45,7 +45,7 @@ const suites = [ "test/routing.mjs", "test/routing-disabled-account.mjs", "test/desktop.mjs", "test/update-service.mjs", "test/channel-computers.mjs", "test/channel-computers-isolated-backends.mjs", "test/channel-computers-backend-migration.mjs", "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", - "test/feedback.mjs", "test/feedback-browser.mjs", "test/cowork-browser.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", "test/release-governance.mjs", + "test/feedback.mjs", "test/feedback-browser.mjs", "test/cowork-browser.mjs", "test/files-latency.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", "test/release-governance.mjs", "test/channel-surfaces.mjs", "test/workspace-interactions.mjs", "test/sweep-fleet-telemetry.mjs", "test/sweep-server-integration.mjs", "test/thread-followup-chat.mjs", "test/notifications.mjs", "test/mobile-push.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/mobile.mjs", "test/web-research.mjs", "test/workflows.mjs"], ]; diff --git a/src/client/app.ts b/src/client/app.ts index b29a7bd..8c913af 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -2762,8 +2762,9 @@ type BrowserSpeechRecognition = { }; type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition; type SpeechTextTarget = HTMLTextAreaElement | { value: () => string; replace: (value: string) => void; focus: () => void }; +type FocusedSpeechTarget = { input: SpeechTextTarget; button: HTMLButtonElement | null }; let activeSpeech: { recognition: BrowserSpeechRecognition; input: SpeechTextTarget; button: HTMLButtonElement } | null = null; -let focusedSpeechTarget: SpeechTextTarget | null = null; +let focusedSpeechTarget: FocusedSpeechTarget | null = null; function setListeningIndicator(listening: boolean): void { let indicator = document.querySelector("[data-listening-indicator]"); @@ -2866,17 +2867,19 @@ export function mountSpeechToTextControl(input: SpeechTextTarget, label = "Toggl dataset: { speechToggle: "" }, }, microphoneIcon()) as HTMLButtonElement; button.onclick = () => { void toggleSpeechToText(input, button); }; + if (input instanceof HTMLTextAreaElement) input.addEventListener("focus", () => setFocusedSpeechTarget(input, button)); return button; } -export function setFocusedSpeechTarget(input: SpeechTextTarget | null): void { focusedSpeechTarget = input; } +export function setFocusedSpeechTarget(input: SpeechTextTarget | null, button: HTMLButtonElement | null = null): void { + focusedSpeechTarget = input ? { input, button } : null; +} function composer(parentId: number | null): HTMLElement { const channel = S.channels.find((item) => item.id === S.channelId); const humanOnly = ["collab", "human"].includes(channel?.kind || ""); const attachBar = h("div", { class: "flex flex-wrap gap-2 px-1 pt-1 empty:hidden" }); const input = h("textarea", { class: "max-h-44 min-h-[24px] w-full resize-none bg-transparent px-1 py-1 text-[15px] text-fg outline-none placeholder:text-faint", rows: 1, dataset: { composerParent: parentId == null ? "root" : String(parentId) }, placeholder: parentId ? "Reply…" : humanOnly ? "Message your coworkers…" : "Start a session — mention the resident agent or @skipper" }) as HTMLTextAreaElement; - input.addEventListener("focus", () => setFocusedSpeechTarget(input)); const mentionBox = h("div", { class: "absolute bottom-full left-0 right-0 z-20 mb-2 hidden max-h-[50vh] w-full max-w-sm overflow-y-auto overflow-hidden rounded-lg border border-line bg-surface shadow-xl sm:right-auto sm:w-72" }); const draftKey = `1helm.draft.${S.me.id}.${S.channelId}.${parentId == null ? "root" : parentId}`; const savedDraft = localStorage.getItem(draftKey); @@ -2936,6 +2939,7 @@ function composer(parentId: number | null): HTMLElement { dataset: { speechToggle: "" }, onclick: () => { void toggleSpeechToText(input); }, }, microphoneIcon()) as HTMLButtonElement; + input.addEventListener("focus", () => setFocusedSpeechTarget(input, micButton)); const box = h("div", { class: "relative rounded-lg border border-line bg-surface shadow-[0_1px_2px_rgba(0,0,0,0.06)] transition focus-within:border-accent focus-within:shadow-[0_0_0_3px_var(--c-accent-soft)]" }, mentionBox, attachBar, @@ -3269,13 +3273,33 @@ const fmtSize = (n: number): string => n < 1024 ? n + " B" : n < 1048576 ? (n / let altTapStarted = 0; let altTapOnly = false; +let altTapFallback: number | null = null; +const clearAltTapFallback = (): void => { + if (altTapFallback != null) window.clearTimeout(altTapFallback); + altTapFallback = null; +}; window.addEventListener("keydown", (event) => { if (event.key === "Alt" && !event.repeat && !event.ctrlKey && !event.metaKey && !event.shiftKey) { + clearAltTapFallback(); altTapStarted = performance.now(); altTapOnly = true; + if (focusedSpeechTarget?.button?.isConnected || activeComposerInput()) { + // CodeMirror/Electron can swallow the corresponding bare Option keyup. + // A short fallback handles that case, while any following keydown still + // cancels Option-character combinations before dictation can start. + altTapFallback = window.setTimeout(() => { + altTapFallback = null; + if (!altTapOnly || !document.hasFocus()) return; + const focused = focusedSpeechTarget?.button?.isConnected ? focusedSpeechTarget : null; + const input = focused?.input || activeComposerInput(); + if (!input || (input instanceof HTMLTextAreaElement && (input.disabled || input.offsetParent === null))) return; + altTapOnly = false; + void toggleSpeechToText(input, focused?.button || undefined); + }, 250); + } return; } - if (altTapOnly) altTapOnly = false; + if (altTapOnly) { altTapOnly = false; clearAltTapFallback(); } if (event.key !== "Escape") return; if (S.mobileMenuOpen) { closeMobileMenu(); return; } // Close terminal first when both are open (thread stays). @@ -3283,18 +3307,21 @@ window.addEventListener("keydown", (event) => { if (S.terminalOpen) { closeDockedTerminal(); return; } if (S.notesOpen) { closeDockedNotes(); return; } if (S.threadRoot) closeThread(); -}); +}, true); window.addEventListener("keyup", (event) => { if (event.key !== "Alt") return; const isSingleTap = altTapOnly && performance.now() - altTapStarted < 800; altTapOnly = false; + clearAltTapFallback(); if (!isSingleTap || !document.hasFocus()) return; - const input = focusedSpeechTarget || activeComposerInput(); + const focused = focusedSpeechTarget?.button?.isConnected ? focusedSpeechTarget : null; + const input = focused?.input || activeComposerInput(); if (!input) return; if (input instanceof HTMLTextAreaElement && (input.disabled || input.offsetParent === null)) return; - void toggleSpeechToText(input); -}); -window.addEventListener("blur", () => { altTapOnly = false; }); + event.preventDefault(); + void toggleSpeechToText(input, focused?.button || undefined); +}, true); +window.addEventListener("blur", () => { altTapOnly = false; clearAltTapFallback(); }); window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => { if (event.matches && S.mobileMenuOpen) closeMobileMenu(); }); function registerServiceWorker(): void { diff --git a/src/client/channel.ts b/src/client/channel.ts index d568506..ef86d75 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -481,6 +481,7 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa const crumbs = h("nav", { class: "flex min-w-0 flex-1 items-center gap-1 overflow-x-auto font-mono text-xs", "aria-label": "File breadcrumbs", dataset: { fileBreadcrumbs: "" } }); const fileInput = h("input", { type: "file", multiple: true, class: "hidden" }) as HTMLInputElement; let directoryCache: ChannelFile[] | null = null; + let mirrorRefresh: Promise | null = null; const open = (entry: ChannelFile): void => { if (entry.kind === "directory") { currentPath = entry.path; selected = null; void load(); return; } const target = coworkPath(entry.path); @@ -572,6 +573,15 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa drawInfo(); status.textContent = `${result.files.length} item${result.files.length === 1 ? "" : "s"}`; } catch (error) { panelError(main, error); } }; + const refreshMirror = (): Promise => { + if (mirrorRefresh) return mirrorRefresh; + status.textContent = "Refreshing from the channel computer…"; + mirrorRefresh = api(`/api/channels/${channelId}/files/refresh`, { method: "POST" }) + .then(async () => { directoryCache = null; await Promise.all([load(), refreshDirectories()]); }) + .catch((error) => { status.textContent = `Showing cached files · ${(error as Error).message}`; }) + .finally(() => { mirrorRefresh = null; }); + return mirrorRefresh; + }; search.oninput = () => { filter = search.value.trim().toLowerCase(); void load(); }; const sortSelect = h("select", { class: "field h-9 w-auto min-w-28 text-xs", "aria-label": "Sort files", onchange: (event: Event) => { sort = (event.target as HTMLSelectElement).value as typeof sort; void load(); } }, h("option", { value: "name" }, "Name"), h("option", { value: "modified" }, "Modified"), h("option", { value: "size" }, "Size")); const newFolder = async (): Promise => { const name = await appPrompt("Folder name"); if (!name) return; try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: currentPath, name } }); directoryCache = null; await load(); } catch (error) { status.textContent = (error as Error).message; } }; @@ -582,8 +592,8 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa h("div", { class: "flex min-h-0 flex-1" }, h("aside", { class: "hidden min-h-0 w-60 shrink-0 flex-col border-r border-line bg-raised/35 md:flex" }, h("div", { class: "border-b border-line p-3" }, h("div", { class: "eyebrow text-muted" }, "Folders")), tree), h("section", { class: "flex min-h-0 min-w-0 flex-1 flex-col" }, h("div", { class: "flex flex-wrap items-center gap-2 border-b border-line bg-raised/25 px-3 py-2" }, crumbs, h("div", { class: "w-full sm:w-48" }, search), sortSelect), main), info)); - fileBrowserSurfaces.set(channelId, { node: root, reload: async () => { directoryCache = null; await load(); } }); - clear(container); container.append(root); void load(); + fileBrowserSurfaces.set(channelId, { node: root, reload: async () => { await load(); void refreshMirror(); } }); + clear(container); container.append(root); void load(); void refreshMirror(); } type ChannelNoteView = { name: string; size: number; modified: number; content?: string }; diff --git a/src/client/cowork.ts b/src/client/cowork.ts index 8aad3a1..c0945b1 100644 --- a/src/client/cowork.ts +++ b/src/client/cowork.ts @@ -140,7 +140,6 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const activeSession = (): SectionSession => sessions.get(section)!; const activeSection = () => SECTIONS.find((candidate) => candidate.id === section)!; const threadKey = (path: string): string => `1helm.cowork.thread.${channelId}.${path || section}`; - const draftKey = (path: string): string => `1helm.cowork.draft.${channelId}.${path}`; const syncCollaborationActivity = (): void => { const current = activeSession(); @@ -149,38 +148,37 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const disposeEditor = (session: SectionSession): void => { session.loadVersion += 1; + setFocusedSpeechTarget(null); session.presenceCleanup?.(); session.presenceCleanup = null; session.mounted?.destroy(); session.mounted = null; session.collaboration?.destroy(); session.collaboration = null; session.view = null; }; - const closeEditorForHiddenCowork = (session: SectionSession): void => { - session.presenceCleanup?.(); session.presenceCleanup = null; - session.mounted?.destroy(); session.mounted = null; - session.collaboration?.destroy(); session.collaboration = null; - session.view = null; + /** y-websocket rooms are ephemeral. A disconnected Y.Doc must never be + * reconnected after its server room has been reseeded from the file: Yjs + * would merge both histories and duplicate the whole document. Every hidden + * editor is destroyed and must reload the authoritative file into a new doc. */ + const resetEditor = (session: SectionSession): void => { + disposeEditor(session); + session.loaded = false; + session.content = ""; + session.saved = ""; }; - /** A Yjs room is intentionally ephemeral server-side. Once its last socket - * closes, reopening the old client document would merge two unrelated Yjs - * histories and replay a complete file as duplicate text. Destroy the - * transport while Cowork is hidden, then reopen from the saved file. */ const disconnectEditors = (): void => { - for (const candidate of sessions.values()) { - if (!candidate.collaboration) continue; - closeEditorForHiddenCowork(candidate); - candidate.loaded = false; - candidate.content = ""; - candidate.saved = ""; - } + for (const candidate of sessions.values()) if (candidate.path) resetEditor(candidate); }; const updateSectionNav = (): void => { clear(sectionNav); for (const item of SECTIONS) sectionNav.append(h("button", { class: `cowork-section ${section === item.id ? "is-active" : ""}`, type: "button", "aria-current": section === item.id ? "page" : undefined, - onclick: () => { if (section === item.id) return; section = item.id; filter = ""; selectedEntry = null; search.value = ""; coworkContextPending = true; syncCollaborationActivity(); void draw(); }, + onclick: () => { + if (section === item.id) return; + resetEditor(activeSession()); + section = item.id; filter = ""; selectedEntry = null; search.value = ""; coworkContextPending = true; syncCollaborationActivity(); void draw(); + }, }, icon(item.icon, 15), item.label)); }; @@ -203,13 +201,12 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: status.textContent = "Saving…"; try { const result = await api<{ file: EditableFile }>(`/api/channels/${channelId}/files/text`, { method: "PATCH", body: { path: session.path, content: session.content } }); - session.saved = result.file.content; localStorage.removeItem(draftKey(session.path)); status.textContent = "Saved"; + session.saved = result.file.content; status.textContent = "Saved"; } catch (error) { status.textContent = (error as Error).message; } }; const markChanged = (session: SectionSession, content: string): void => { session.content = content; - localStorage.setItem(draftKey(session.path), JSON.stringify({ content, base: session.saved, updated: Date.now() })); status.textContent = "Saving…"; window.setTimeout(() => { if (activeSession() === session && status.textContent === "Saving…") status.textContent = "Saved live"; }, 700); }; @@ -237,30 +234,30 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const mounted = mountCodeMirror(session.collaboration!, session.path, mode, (content) => markChanged(session, content), () => { void saveFile(); }); session.mounted = mounted; const preview = h("div", { class: "md cowork-markdown-preview hidden" }); - const editStage = h("div", { class: mode === "docs" ? "cowork-doc-page" : "min-h-0 flex-1 overflow-hidden" }, mounted.node); + const editStage = h("div", { class: mode === "docs" ? "cowork-doc-page" : "cowork-notes-editor-frame min-h-0 flex-1 overflow-hidden" }, mounted.node); const format = (label: string, prefix: string, suffix = prefix, placeholder = "text") => h("button", { class: "btn-ghost text-xs", type: "button", title: label, onclick: () => mounted.format?.(prefix, suffix, placeholder) }, label); const previewButton = mode !== "code" ? h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { session.preview = !session.preview; preview.classList.toggle("hidden", !session.preview); editStage.classList.toggle("hidden", session.preview); preview.innerHTML = md(session.mounted?.getContent?.() || session.content || "_This file is empty._"); (previewButton as HTMLButtonElement).textContent = session.preview ? "Write" : "Preview"; } }, "Preview") : null; - const dictation = mode === "notes" || mode === "docs" ? mountSpeechToTextControl({ + const speechTarget = { value: () => mounted.getContent?.() || "", - replace: (content) => mounted.replaceContent?.(content), + replace: (content: string) => mounted.replaceContent?.(content), focus: () => mounted.focus(), - }, `Dictate ${mode === "docs" ? "document" : "note"}`) : null; - if (mode === "notes" || mode === "docs") mounted.node.addEventListener("focusin", () => setFocusedSpeechTarget({ - value: () => mounted.getContent?.() || "", - replace: (content) => mounted.replaceContent?.(content), - focus: () => mounted.focus(), - })); + }; + const dictation = mode === "notes" || mode === "docs" ? mountSpeechToTextControl(speechTarget, `Dictate ${mode === "docs" ? "document" : "note"}`) : null; + if (dictation) mounted.node.addEventListener("focusin", () => setFocusedSpeechTarget(speechTarget, dictation)); const toolbar = commonToolbar(session, `/workspace/${session.path}`, mode !== "code" ? format("Heading", "## ", "", "Heading") : null, mode !== "code" ? format("Bold", "**", "**") : null, mode !== "code" ? format("Italic", "_", "_") : null, mode === "docs" ? format("List", "- ", "", "List item") : null, dictation, previewButton); - return h("div", { class: `flex min-h-0 flex-1 flex-col ${mode === "docs" ? "cowork-doc-canvas" : ""}` }, toolbar, h("div", { class: `cowork-text-stage flex min-h-0 flex-1 flex-col overflow-auto ${mode === "notes" ? "cowork-notes-edit-stage" : ""}` }, editStage, preview)); + const stage = mode === "notes" + ? h("div", { class: "cowork-notes-edit-stage flex min-h-0 flex-1 flex-col overflow-hidden" }, editStage, preview) + : h("div", { class: "cowork-text-stage flex min-h-0 flex-1 flex-col overflow-auto" }, editStage, preview); + return h("div", { class: `flex min-h-0 flex-1 flex-col ${mode === "docs" ? "cowork-doc-canvas" : ""}` }, toolbar, stage); }; const whiteboardEditor = (session: SectionSession): HTMLElement => { @@ -356,39 +353,30 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: if (!session.loaded) { const openingPath = session.path; const loadVersion = ++session.loadVersion; + let openingCollaboration: CoworkDocument | null = null; workspace.append(h("div", { class: "grid h-full place-items-center text-sm text-muted" }, "Opening file…")); try { const result = await api<{ file: EditableFile }>(`/api/channels/${channelId}/files/text?path=${encodeURIComponent(openingPath)}`); if (session.loadVersion !== loadVersion || session.path !== openingPath) return; session.content = result.file.content; session.saved = result.file.content; session.loaded = true; - session.collaboration = connectCoworkDocument(channelId, openingPath, me); + openingCollaboration = connectCoworkDocument(channelId, openingPath, me); + session.collaboration = openingCollaboration; syncCollaborationActivity(); await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => reject(new Error("The collaborative editor could not connect.")), 12_000); - const synced = (ready: boolean) => { if (!ready) return; window.clearTimeout(timeout); session.collaboration?.provider.off("sync", synced); resolve(); }; - session.collaboration!.provider.on("sync", synced); + const synced = (ready: boolean) => { if (!ready) return; window.clearTimeout(timeout); openingCollaboration?.provider.off("sync", synced); resolve(); }; + openingCollaboration!.provider.on("sync", synced); }); - if (session.loadVersion !== loadVersion || session.path !== openingPath) { disposeEditor(session); return; } - const shared = session.path.endsWith(".whiteboard.json") || session.path.endsWith(".slides.json") ? session.collaboration.scene.get("json") || "" : session.collaboration.text.toString(); - const storedDraft = localStorage.getItem(draftKey(session.path)); - let draft: { content: string; base: string | null } | null = null; - if (storedDraft) { - try { - const parsed = JSON.parse(storedDraft) as { content?: unknown; base?: unknown }; - if (typeof parsed.content === "string") draft = { content: parsed.content, base: typeof parsed.base === "string" ? parsed.base : null }; - } catch { draft = { content: storedDraft, base: null }; } - } - if (draft?.content === shared) localStorage.removeItem(draftKey(session.path)); - else if (draft && draft.content !== shared) { - const safeRestore = draft.base !== null && draft.base === shared; - const restore = safeRestore || await appConfirm(`Restore unsaved changes to ${visibleName(session.path)}? The file has changed since that draft was captured.`); - if (restore) { - if (session.path.endsWith(".whiteboard.json") || session.path.endsWith(".slides.json")) session.collaboration.scene.set("json", draft.content); - else session.collaboration.doc.transact(() => { session.collaboration!.text.delete(0, session.collaboration!.text.length); session.collaboration!.text.insert(0, draft!.content); }); - session.content = draft.content; - } else localStorage.removeItem(draftKey(session.path)); - } else session.content = shared || session.content; + if (session.loadVersion !== loadVersion || session.path !== openingPath || session.collaboration !== openingCollaboration) return; + const shared = openingPath.endsWith(".whiteboard.json") || openingPath.endsWith(".slides.json") ? openingCollaboration.scene.get("json") || "" : openingCollaboration.text.toString(); + // The synchronized file is authoritative. Keeping a second recovery + // copy in localStorage previously resurrected stale text after agents or + // collaborators changed the file and was the source of the misleading + // “Restore unsaved changes?” loop. The server flushes live Yjs changes + // on its debounce and again when the last editor disconnects. + session.content = shared || session.content; } catch (error) { + if (session.loadVersion !== loadVersion || session.path !== openingPath) return; disposeEditor(session); clear(workspace); workspace.append(h("div", { class: "grid h-full place-items-center p-8 text-center" }, h("div", {}, h("span", { class: "text-accent" }, fileIcon({ path: session.path, name: visibleName(session.path), size: 0, modified: 0, kind: "file" }, 32)), h("h3", { class: "mt-3 font-semibold text-fg" }, visibleName(session.path)), h("p", { class: "mt-2 text-sm text-muted" }, (error as Error).message || "File type not supported to view.")))); return; } clear(workspace); @@ -400,8 +388,10 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const openPath = async (path: string): Promise => { const normalized = path.replace(/^\/?workspace\/?/, "").replace(/^\/+/, ""); - const nextSection = sectionForPath(normalized); section = nextSection; const session = activeSession(); - if (session.path !== normalized) { disposeEditor(session); session.path = normalized; session.loaded = false; session.content = ""; session.saved = ""; session.preview = false; session.activeSlide = 0; } + const nextSection = sectionForPath(normalized); + if (section !== nextSection) resetEditor(activeSession()); + section = nextSection; const session = activeSession(); + if (session.path !== normalized) { resetEditor(session); session.path = normalized; session.preview = false; session.activeSlide = 0; } session.folder = normalized.split("/").slice(0, -1).join("/") || activeSection().folder; selectedEntry = { path: normalized, name: visibleName(normalized), kind: "file", size: 0, modified: 0 }; chatRootId = Number(localStorage.getItem(threadKey(normalized)) || 0); coworkContextPending = true; syncCollaborationActivity(); await draw(); }; @@ -460,7 +450,7 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const input = h("textarea", { class: "min-h-20 w-full resize-none bg-transparent p-2 text-sm text-fg outline-none placeholder:text-faint", rows: 3, placeholder: session.path ? `Ask @${channel.agent?.name || "agent"} about this file…` : "Open a file to give the agent its path…", disabled: !session.path, value: agentDrafts.get(session.path) || "" }) as HTMLTextAreaElement; const dictate = mountSpeechToTextControl(input, "Dictate Cowork agent request"); input.oninput = () => agentDrafts.set(session.path, input.value); - input.onfocus = () => setFocusedSpeechTarget(input); + input.onfocus = () => setFocusedSpeechTarget(input, dictate); const send = async (): Promise => { const message = input.value.trim(); if (!message || !session.path) return; input.disabled = true; diff --git a/src/client/styles.css b/src/client/styles.css index d22b3b8..25eaf25 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -901,9 +901,10 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .cowork-codemirror { min-height: 0; flex: 1 1 auto; overflow: hidden; background: color-mix(in srgb, var(--c-surface) 95%, transparent); font-size: 13px; } .cowork-codemirror > .cm-editor { height: 100%; } .cowork-codemirror-notes { font-size: 14px; } -.cowork-notes-edit-stage { overflow-y: auto; } -.cowork-notes-edit-stage .cowork-codemirror { min-height: 100%; flex: 0 0 auto; } -.cowork-notes-edit-stage .cowork-codemirror > .cm-editor { min-height: 100%; height: auto; } +.cowork-notes-editor-frame { min-height: 0; flex: 1 1 0%; height: 0; } +.cowork-notes-edit-stage .cowork-codemirror { min-height: 0; height: 100%; } +.cowork-notes-edit-stage .cowork-codemirror > .cm-editor { height: 100%; } +.cowork-notes-edit-stage .cm-scroller { overflow-y: auto; } .cowork-codemirror-code { background: color-mix(in srgb, #071019 72%, var(--c-surface)); } .cowork-markdown-preview { min-height: 100%; overflow-y: auto; background: var(--c-surface); padding: 1.5rem 2rem; } .cowork-doc-canvas { background: color-mix(in srgb, var(--c-raised) 75%, var(--c-bg)); } diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 4f2552e..2d8a129 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.20"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.21"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ @@ -138,6 +138,7 @@ const explicitComputerId = (channelId: number): string => `1helm-${installationI const hostWorldRoot = (channelId: number): string => join(DATA_DIR, "channels", String(channelId)); const hostWorkspace = (channelId: number): string => join(hostWorldRoot(channelId), "workspace"); const hostFiles = (channelId: number): string => join(hostWorldRoot(channelId), "files"); +const workspaceMirrorRefreshes = new Map>(); function withChannelLock(channelId: number, fn: () => Promise): Promise { const previous = channelLocks.get(channelId) || Promise.resolve(); @@ -785,7 +786,13 @@ export async function syncGuestToHost(channelId: number): Promise { export async function refreshChannelWorkspaceMirror(channelId: number): Promise { const computer = channelComputer(channelId); if (!computer || !isolatedBackend(computer) || computer.observed_state !== "running") return; - await syncGuestToHost(channelId); + const current = workspaceMirrorRefreshes.get(channelId); + if (current) return current; + const refresh = syncGuestToHost(channelId).finally(() => { + if (workspaceMirrorRefreshes.get(channelId) === refresh) workspaceMirrorRefreshes.delete(channelId); + }); + workspaceMirrorRefreshes.set(channelId, refresh); + await refresh; } /** Resolve an agent artifact only after its canonical guest filesystem is mirrored. */ diff --git a/src/server/db.ts b/src/server/db.ts index 9f7fb4d..848ce71 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1005,7 +1005,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.20"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.21"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's diff --git a/src/server/index.ts b/src/server/index.ts index 10d333a..14f758d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1366,9 +1366,9 @@ const server = createServer(async (req, res) => { return json(res, 200, { threads }); } if (action === "files" && m === "GET") { - await refreshChannelWorkspaceMirror(channelId); try { if (!url.searchParams.has("path")) { + await refreshChannelWorkspaceMirror(channelId); const files = syncWorkspaceArtifacts(channelId, null, "agent"); return json(res, 200, { path: "", files, artifacts: q("SELECT * FROM artifacts WHERE channel_id=? ORDER BY modified DESC", channelId) }); } @@ -1489,12 +1489,20 @@ const server = createServer(async (req, res) => { if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); if (!q1("SELECT 1 FROM channels WHERE id=? AND kind='channel'", channelId)) return json(res, 404, { error: "Agent-channel file surface not found." }); try { - await refreshChannelWorkspaceMirror(channelId); + if (m === "POST") await refreshChannelWorkspaceMirror(channelId); if (m === "GET") return json(res, 200, { directories: listWorkspaceDirectories(channelId) }); const b = await jbody(req); return json(res, 201, { directory: createWorkspaceDirectory(channelId, String(b.path || ""), String(b.name || "")) }); } catch (error) { return json(res, 400, { error: (error as Error).message }); } } + const channelFilesRefresh = p.match(/^\/api\/channels\/(\d+)\/files\/refresh$/); + if (channelFilesRefresh && m === "POST") { + const channelId = Number(channelFilesRefresh[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + if (!q1("SELECT 1 FROM channels WHERE id=? AND kind='channel'", channelId)) return json(res, 404, { error: "Agent-channel file surface not found." }); + try { await refreshChannelWorkspaceMirror(channelId); return json(res, 200, { ok: true }); } + catch (error) { return json(res, 400, { error: (error as Error).message }); } + } const worldEntry = p.match(/^\/api\/channels\/(\d+)\/files\/entries$/); if (worldEntry && ["POST", "PATCH", "DELETE"].includes(m)) { const channelId = Number(worldEntry[1]); @@ -1519,7 +1527,6 @@ const server = createServer(async (req, res) => { const channelId = Number(worldText[1]); if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); try { - await refreshChannelWorkspaceMirror(channelId); const b = m === "GET" ? null : await jbody(req); const path = m === "GET" ? String(url.searchParams.get("path") || "") : String(b?.path || ""); if (m === "GET") return json(res, 200, { file: readWorkspaceTextFile(channelId, path) }); @@ -1539,7 +1546,6 @@ const server = createServer(async (req, res) => { if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); if (!q1("SELECT 1 FROM channels WHERE id=? AND kind='channel'", channelId)) return json(res, 404, { error: "Agent-channel file not found." }); try { - await refreshChannelWorkspaceMirror(channelId); const file = resolveWorldFile(channelId, url.searchParams.get("path") || ""); const disposition = url.searchParams.get("download") === "1" ? "attachment" : "inline"; res.writeHead(200, { "content-type": MIME[extname(file)] || "application/octet-stream", "content-disposition": `${disposition}; filename*=UTF-8''${encodeURIComponent(file.split("/").pop() || "file")}`, ...SECURITY_HEADERS }); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 207705f..c3c31ab 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.20"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.21"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/channel-surfaces.mjs b/test/channel-surfaces.mjs index c7386a4..3e0bb8c 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -190,12 +190,14 @@ test("channel UI source exposes file-backed Cowork, traditional Files, audio pre assert.match(channelSource, /icon\("folder"/, "Files uses a recognizable folder icon"); assert.match(coworkSource, /const SECTIONS[\s\S]*"notes"[\s\S]*"whiteboards"[\s\S]*"code"[\s\S]*"docs"[\s\S]*"presentations"/, "Cowork exposes five file-backed work modes"); assert.match(coworkSource, /mountSpeechToTextControl\(input, "Dictate Cowork agent request"\)/, "Cowork agent requests expose the shared explicit dictation control"); - assert.match(coworkSource, /mountSpeechToTextControl\(\{[\s\S]*Dictate \$\{mode === "docs" \? "document" : "note"\}/, "Cowork Notes and Docs expose the shared explicit dictation control"); + assert.match(coworkSource, /mountSpeechToTextControl\(speechTarget, `Dictate \$\{mode === "docs" \? "document" : "note"\}`\)/, "Cowork Notes and Docs expose the shared explicit dictation control"); assert.match(coworkSource, /if \(!value && channel\.agent\?\.kind === "skipper"\) return h\("img"/, "Cowork renders Skipper with a real product avatar instead of an initial"); - assert.match(coworkSource, /disconnectEditors[\s\S]*closeEditorForHiddenCowork[\s\S]*candidate\.loaded = false/, "Cowork discards disconnected Yjs histories before a hidden surface is reopened"); + assert.match(coworkSource, /const resetEditor[\s\S]*session\.loaded = false[\s\S]*disconnectEditors[\s\S]*resetEditor\(candidate\)/, "Cowork discards disconnected Yjs histories before a hidden surface is reopened"); assert.match(channelSource, /onclick: \(\) => \{ selected = entry; redrawSelection\(\); \}/, "Files paints selection immediately without re-fetching the guest mirror"); assert.match(channelSource, /const refreshDirectories = async/, "Files loads its recursive folder tree independently of the current directory listing"); - assert.match(stylesSource, /\.cowork-notes-edit-stage \{ overflow-y: auto; \}/, "long Cowork Notes edit sessions have their own vertical scroll container"); + assert.match(stylesSource, /\.cowork-notes-edit-stage \.cm-scroller \{ overflow-y: auto; \}/, "long Cowork Notes edit sessions scroll in CodeMirror's real viewport"); + assert.match(channelSource, /files\/refresh[\s\S]*void refreshMirror\(\)/, "Files paints the host cache first and refreshes the VM mirror independently"); + assert.match(serverSource, /channelFilesRefresh[\s\S]*refreshChannelWorkspaceMirror\(channelId\)/, "Files has one explicit coalesced VM refresh route instead of syncing every click"); assert.match(coworkSource, /\.\.\.\(coworkContextPending \? \{ coworkPath: session\.path \} : \{\}\)/, "a Cowork panel's first message submits its open path for authenticated enrichment"); assert.match(serverSource, /if \(b\.coworkPath\)[\s\S]*b\.parentId \? \[\] : \[`Working file: \/workspace\/\$\{path\}`\]/, "the server adds the validated open file path only to a new Cowork thread"); assert.match(serverSource, /coworkViewerUsernames\(cid, path, Number\(user\.id\)\)[\s\S]*Working with:/, "the server adds active co-viewers to the first Cowork agent message"); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index 21b534d..b8b0560 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -86,7 +86,7 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil const markdownThread = (await api(`/api/channels/${channel.id}/messages`, { body: { body: "** Goal **\n\n** Session Status **" } }, token)).message; for (let index = 0; index < 18; index++) await api(`/api/channels/${channel.id}/messages`, { body: { body: `Mobile scroll fixture ${index + 1}: ${"readable history ".repeat(18)}` } }, token); - browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); + browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox", "--use-fake-ui-for-media-stream"] }); const page = await browser.newPage(); const errors = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -148,6 +148,78 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.keyboard.down(primaryModifier); await page.keyboard.press("s"); await page.keyboard.up(primaryModifier); await waitFor(async () => (await api(`/api/channels/${channel.id}/files/text?path=notes%2Ffield-notes.md`, {}, token)).file.content.includes("Unsaved continuity proof"), "saved Cowork note"); assert.equal(await page.$$eval('[data-speech-toggle]', (buttons) => buttons.some((button) => button.getAttribute("aria-label") === "Dictate note")), true, "Cowork Notes exposes dictation"); + // Install a deterministic SpeechRecognition seam in the page and exercise + // the real bare Alt event path while CodeMirror owns focus. This previously + // failed because the shortcut knew the editor target but not its mic button. + await page.evaluate(() => { + class FakeRecognition { + start() { window.__speechStarts = (window.__speechStarts || 0) + 1; } + stop() { this.onend?.(); } + } + window.SpeechRecognition = FakeRecognition; + }); + await page.click(editorContent); + await page.keyboard.down("Alt"); await page.keyboard.up("Alt"); + await page.waitForFunction(() => window.__speechStarts === 1); + assert.equal(await page.$eval('[aria-label="Dictate note"]', (button) => button.getAttribute("aria-pressed")), "true", "bare Alt starts Cowork Notes dictation"); + await page.keyboard.down("Alt"); await page.keyboard.up("Alt"); + await page.waitForFunction(() => document.querySelector('[aria-label="Dictate note"]')?.getAttribute("aria-pressed") === "false"); + await page.keyboard.down("Alt"); await page.keyboard.press("x"); await page.keyboard.up("Alt"); + await new Promise((resolve) => setTimeout(resolve, 350)); + assert.equal(await page.evaluate(() => window.__speechStarts), 1, "an Option/Alt character combination does not start dictation"); + + // The CodeMirror scroller itself must own a finite viewport and scroll—not + // an outer wrapper whose inner editor clips overflowing content. + await page.click(editorContent); + await page.keyboard.down(primaryModifier); await page.keyboard.press("End"); await page.keyboard.up(primaryModifier); + await page.keyboard.type(`\n${Array.from({ length: 120 }, (_, index) => `Scrollable note row ${index + 1}`).join("\n")}`); + const noteScroll = await page.$eval('[aria-label="Notes editor"] .cm-scroller', (scroller) => { + const before = scroller.scrollTop; + scroller.scrollTop = Math.max(1, scroller.scrollHeight - scroller.clientHeight); + return { before, after: scroller.scrollTop, clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight }; + }); + assert.ok(noteScroll.clientHeight > 0 && noteScroll.scrollHeight > noteScroll.clientHeight && noteScroll.after > 0, JSON.stringify(noteScroll)); + await page.keyboard.down(primaryModifier); await page.keyboard.press("s"); await page.keyboard.up(primaryModifier); + await waitFor(async () => (await api(`/api/channels/${channel.id}/files/text?path=notes%2Ffield-notes.md`, {}, token)).file.content.includes("Scrollable note row 120"), "saved long Cowork note"); + + // Switch to another Cowork section while this page is the only client. That + // destroys the server room. Returning must create a fresh Y.Doc rather than + // reconnecting stale history and duplicating the complete text. + await page.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Docs")?.click()); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("proposal.md")); + await page.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Notes")?.click()); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("field-notes.md")); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("field-notes.md"))?.click()); + await page.waitForSelector('[aria-label="Notes editor"] .cm-content'); + await page.$eval('[aria-label="Notes editor"] .cm-scroller', (scroller) => { scroller.scrollTop = scroller.scrollHeight; }); + await page.waitForFunction(() => document.querySelector('[aria-label="Notes editor"] .cm-content')?.textContent.includes("Scrollable note row 120")); + await new Promise((resolve) => setTimeout(resolve, 800)); + const sectionReopenedText = (await api(`/api/channels/${channel.id}/files/text?path=notes%2Ffield-notes.md`, {}, token)).file.content; + assert.equal((sectionReopenedText.match(/Scrollable note row 120/g) || []).length, 1, "switching Cowork sections never duplicates saved text"); + + // Reproduce the reported agent-edit path: an external writer replaces the + // open document, the live room adopts it, and every later reopen must retain + // exactly the five requested lines without a recovery prompt or replay. + await page.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Docs")?.click()); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("proposal.md")); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("proposal.md"))?.click()); + await page.waitForSelector('[aria-label="Docs editor"] .cm-content'); + await api(`/api/channels/${channel.id}/files/text`, { method: "PATCH", body: { path: "docs/proposal.md", content: "Hello World\n".repeat(5) } }, token); + await page.waitForFunction(() => (document.querySelector('[aria-label="Docs editor"] .cm-content')?.textContent.match(/Hello World/g) || []).length === 5); + await page.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Notes")?.click()); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("field-notes.md")); + await page.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Docs")?.click()); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("proposal.md")); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("proposal.md"))?.click()); + await page.waitForFunction(() => document.querySelector('[aria-label="Docs editor"] .cm-content')?.textContent.includes("Hello World")); + await new Promise((resolve) => setTimeout(resolve, 800)); + const externallyReopened = (await api(`/api/channels/${channel.id}/files/text?path=docs%2Fproposal.md`, {}, token)).file.content; + assert.equal((externallyReopened.match(/Hello World/g) || []).length, 5, "an agent-style replacement remains exactly-once after leaving and reopening a document"); + assert.equal(await page.$('.modal-overlay'), null, "authoritative agent edits never trigger a stale local recovery prompt"); + await page.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Notes")?.click()); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("field-notes.md")); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("field-notes.md"))?.click()); + await page.waitForFunction(() => document.querySelector('[aria-label="Notes editor"] .cm-content')?.textContent.includes("Unsaved continuity proof")); // Leave the sole Cowork client and return. The old document transport must // never merge its stale Yjs history with a fresh server room and duplicate // the complete file. @@ -160,6 +232,22 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil const reopenedText = await page.$eval('[aria-label="Notes editor"] .cm-content', (editor) => editor.textContent || ""); assert.equal((reopenedText.match(/Unsaved continuity proof/g) || []).length, 1, "leaving and reopening Cowork never duplicates saved text"); + // Focus the actual Cowork agent textarea and prove the same bare Alt route + // starts its own mic button rather than falling back to Chat. + await page.evaluate(() => document.querySelector('.cowork-agent-toggle')?.click()); + await page.waitForSelector('[data-cowork-agent]:not(.hidden) textarea'); + await page.focus('[data-cowork-agent] textarea'); + await page.keyboard.down("Alt"); await page.keyboard.up("Alt"); + try { await page.waitForFunction(() => window.__speechStarts === 2, { timeout: 5_000 }); } + catch (error) { + const state = await page.evaluate(() => ({ starts: window.__speechStarts, active: document.activeElement?.outerHTML, pressed: document.querySelector('[aria-label="Dictate Cowork agent request"]')?.getAttribute("aria-pressed") })); + throw new Error(`Cowork agent Alt shortcut did not start: ${JSON.stringify(state)}`, { cause: error }); + } + assert.equal(await page.$eval('[aria-label="Dictate Cowork agent request"]', (button) => button.getAttribute("aria-pressed")), "true", "bare Alt starts Cowork agent dictation"); + await page.keyboard.down("Alt"); await page.keyboard.up("Alt"); + await page.waitForFunction(() => document.querySelector('[aria-label="Dictate Cowork agent request"]')?.getAttribute("aria-pressed") === "false"); + await page.evaluate(() => document.querySelector('[aria-label="Close agent panel"]')?.click()); + // A second authenticated member joins the same ordinary workspace file. // Both clients see presence, remote cursors, and edits without a reload. const collaboratorContext = await browser.createBrowserContext(); @@ -175,6 +263,7 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await collaboratorPage.click('[aria-label="Notes editor"] .cm-content'); await collaboratorPage.keyboard.down(primaryModifier); await collaboratorPage.keyboard.press("a"); await collaboratorPage.keyboard.up(primaryModifier); await collaboratorPage.keyboard.press("ArrowRight"); await collaboratorPage.keyboard.type("\nLive words from Crew."); + await page.$eval('[aria-label="Notes editor"] .cm-scroller', (scroller) => { scroller.scrollTop = scroller.scrollHeight; }); await page.waitForFunction(() => document.querySelector('[aria-label="Notes editor"] .cm-content')?.textContent.includes("Live words from Crew")); await collaboratorPage.evaluate(() => { const editor = document.querySelector('[aria-label="Notes editor"] .cm-content'); diff --git a/test/fake-container.mjs b/test/fake-container.mjs index 84209e1..23950e8 100644 --- a/test/fake-container.mjs +++ b/test/fake-container.mjs @@ -120,6 +120,8 @@ if (script.includes("image-contract") && script.includes("/var/lib/1helm/owner") process.exit(0); } if (command[0] === "/bin/tar" && command.includes("-cf")) { + const delay = Math.max(0, Number(process.env.FAKE_CONTAINER_EXPORT_DELAY_MS || 0)); + if (delay) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay); const tar = spawnSync("tar", ["-C", root, "-cf", "-", "workspace"], { maxBuffer: 256 * 1024 ** 2 }); if (tar.status !== 0) fail(String(tar.stderr || "fake export failed")); process.stdout.write(tar.stdout); diff --git a/test/files-latency.mjs b/test/files-latency.mjs new file mode 100644 index 0000000..0fb7a94 --- /dev/null +++ b/test/files-latency.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const root = resolve(import.meta.dirname, ".."); + +test("Files directory reads stay responsive while an explicit VM refresh is slow", async (t) => { + const testRoot = mkdtempSync(join(tmpdir(), "1helm-files-latency-")); + t.after(() => rmSync(testRoot, { recursive: true, force: true })); + const dataDir = join(testRoot, "data"); + const fakeState = join(testRoot, "fake-state"); + const fakeCli = join(testRoot, "container"); + await mkdir(fakeState, { recursive: true }); + await writeFile(fakeCli, `#!/bin/sh\nexec "${process.execPath}" "${join(root, "test", "fake-container.mjs")}" "$@"\n`, { mode: 0o700 }); + await chmod(fakeCli, 0o700); + + process.env.CTRL_DATA_DIR = dataDir; + process.env.HELM_CHANNEL_COMPUTER_BACKEND = "apple"; + process.env.HELM_CONTAINER_CLI = fakeCli; + process.env.FAKE_CONTAINER_STATE = fakeState; + process.env.HELM_FLEET_INTERVAL_MS = "600000"; + process.env.HELM_FLEET_INITIAL_MS = "600000"; + + const db = await import("../src/server/db.ts"); + db.seed(); + const agents = await import("../src/server/agents.ts"); + const computers = await import("../src/server/channel-computers.ts"); + const userId = db.run("INSERT INTO users (username,pass,display,is_admin,created) VALUES ('captain','x','Captain',1,?)", Date.now()).lastInsertRowid; + db.run("INSERT INTO providers (name,base_url,api_key,kind,created) VALUES ('test','http://127.0.0.1','x','openai',?)", Date.now()); + const provisioned = agents.provisionChannel({ name: "latency", purpose: "Prove cached Files navigation.", userId }); + const computer = await computers.provisionChannelComputer(provisioned.channelId); + agents.createWorkspaceFile(provisioned.channelId, "", "cached.txt", "cached"); + await computers.ensureChannelComputerRunning(provisioned.channelId, "latency fixture"); + writeFileSync(join(fakeState, "machines", computer.machine_id, "workspace", "guest-only.txt"), "guest"); + + // Make the guest tar export observably slow. Cached listing must remain a + // local host operation while one coalesced refresh runs in the background. + process.env.FAKE_CONTAINER_EXPORT_DELAY_MS = "900"; + const refreshOne = computers.refreshChannelWorkspaceMirror(provisioned.channelId); + const refreshTwo = computers.refreshChannelWorkspaceMirror(provisioned.channelId); + const started = performance.now(); + const cached = agents.listWorkspaceDirectory(provisioned.channelId, ""); + const elapsed = performance.now() - started; + assert.ok(elapsed < 150, `cached Files listing took ${elapsed.toFixed(1)}ms`); + assert.ok(cached.files.some((entry) => entry.name === "cached.txt")); + await Promise.all([refreshOne, refreshTwo]); + assert.ok(agents.listWorkspaceDirectory(provisioned.channelId, "").files.some((entry) => entry.name === "guest-only.txt")); +}); diff --git a/test/sweep-server-integration.mjs b/test/sweep-server-integration.mjs index 859484d..c5cf792 100644 --- a/test/sweep-server-integration.mjs +++ b/test/sweep-server-integration.mjs @@ -156,6 +156,8 @@ test("server sweep seams stay scoped, human-only, Unicode-safe, and least-arsena assert.equal(imported.body.path, "workspace/briefs/brief.txt"); const listing = await api(base, `/api/channels/${channelId}/files?path=briefs`, token); assert.deepEqual(listing.body.files.map((file) => file.name), ["brief.txt"]); + assert.equal((await api(base, `/api/channels/${channelId}/files/refresh`, token, { method: "POST" })).status, 200); + assert.equal((await api(base, `/api/channels/${channelId}/files/refresh`, crewToken, { method: "POST" })).status, 403); const opened = await fetch(`${base}/api/channels/${channelId}/files/content?path=briefs%2Fbrief.txt`, { headers: { authorization: `Bearer ${token}` } }); assert.equal(await opened.text(), "bounded file"); assert.equal((await api(base, `/api/channels/${channelId}/files/directories`, token, { method: "POST", body: { path: "../", name: "escape" } })).status, 400); diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs index 13db7e9..89477fe 100644 --- a/test/workspace-interactions.mjs +++ b/test/workspace-interactions.mjs @@ -29,7 +29,8 @@ test("speech-to-text is explicit, graceful, and combination-safe", () => { assert.match(app, /data(?:set)?: \{ listeningIndicator: "" \}/, "dictation exposes a subtle global listening indicator"); assert.match(app, /Speech-to-text is not available in this browser/, "unsupported browsers get a useful explanation"); assert.match(app, /event\.key === "Alt" && !event\.repeat && !event\.ctrlKey && !event\.metaKey && !event\.shiftKey/, "only a bare Option\/Alt keydown starts tap detection"); - assert.match(app, /if \(altTapOnly\) altTapOnly = false/, "any combined keystroke cancels the single-tap shortcut"); + assert.match(app, /if \(altTapOnly\) \{ altTapOnly = false; clearAltTapFallback\(\); \}/, "any combined keystroke cancels the single-tap shortcut and its swallowed-keyup fallback"); + assert.match(app, /toggleSpeechToText\(input, focused\?\.button \|\| undefined\)/, "the bare Option\/Alt shortcut carries Cowork's explicit mic button into speech recognition"); assert.match(desktop, /permission !== "media"/, "the native shell keeps non-media permission requests denied"); assert.match(desktop, /mediaTypes\.includes\("audio"\) && !mediaTypes\.includes\("video"\)/, "the native permission exception is microphone-only"); assert.match(desktop, /askForMediaAccess\("microphone"\)/, "macOS uses its native microphone approval flow"); From 3a3e670378c1e4fd8d58cde7556e0cca11c240ee Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 19:28:10 +0000 Subject: [PATCH 2/7] Polish Cowork files and workspace navigation Signed-off-by: Joseph Yaksich --- CHANGELOG.md | 24 ++++ package-lock.json | 182 +++++++++++++++++++++++++++++- package.json | 1 + public/index.html | 4 +- public/sw.js | 6 +- src/client/app.ts | 64 +++++++---- src/client/channel.ts | 46 +++++++- src/client/cowork.ts | 26 +++-- src/client/styles.css | 15 ++- src/server/docx.ts | 70 ++++++++++++ src/server/index.ts | 21 ++++ test/channel-surfaces.mjs | 6 + test/cowork-browser.mjs | 63 +++++++++++ test/sweep-server-integration.mjs | 15 ++- test/workspace-interactions.mjs | 6 + 15 files changed, 504 insertions(+), 45 deletions(-) create mode 100644 src/server/docx.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd0db4e..804f400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Cowork new-file prompts now start blank. Names with an explicit extension are + preserved, while extensionless names receive the active section's `.md`, + `.whiteboard.json`, `.txt`, or `.slides.json` default. +- Presentation canvases anchor at the top of their scrollable stage so the + Excalidraw hamburger menu remains fully reachable, including when the canvas + is taller than the available area. +- Cowork Code uses the normal light surface with readable dark text in light + mode while retaining its navy editor treatment in dark mode. +- Offline shell fallbacks always resolve service-worker requests with a valid + response instead of producing a rejected FetchEvent conversion error. - Cowork's bare Option/Alt shortcut now carries the mic control belonging to the focused Notes, Docs, or agent input and uses a capture-phase fallback when the macOS desktop shell swallows the corresponding keyup. @@ -24,6 +34,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 immediately. One explicit background refresh coalesces the expensive channel computer export and repaints the current listing when it completes. +### Added + +- Files now keeps `/workspace` and the five Cowork folders first in its visual + navigation rail, with all remaining root files and folders grouped under an + expandable **Other** disclosure without changing the underlying filesystem. +- Selecting a Markdown file in Files exposes **Download - DOCX**, backed by an + authenticated, contained Office Open XML export. +- Desktop workspace navigation can collapse to a compact channel-icon rail and + expand again. The profile-bound preference persists, while the existing + mobile drawer remains unchanged. + ### Tests - Browser coverage toggles both Cowork editor and agent dictation through bare @@ -33,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A fake Apple channel computer with an intentionally slow export proves cached Files reads stay responsive, simultaneous refreshes coalesce, and guest-only files appear after the background refresh. +- Browser and server coverage verify blank Cowork naming, default and explicit + extensions, presentation menu reachability, light Code contrast, Files + grouping, real DOCX ZIP contents, and persistent desktop-only sidebar collapse. ## [0.0.20] - 2026-07-27 diff --git a/package-lock.json b/package-lock.json index a2885e8..eb2846e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", "@opencoredev/loginwithchatgpt-server": "^0.2.0", "codemirror": "6.0.2", + "docx": "9.7.1", "node-pty": "^1.1.0", "react": "18.3.1", "react-dom": "18.3.1", @@ -5790,6 +5791,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -6519,6 +6526,56 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/docx": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz", + "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7244,6 +7301,16 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -7458,6 +7525,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -7544,6 +7617,54 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/junk": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz", @@ -7790,6 +7911,21 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lie/node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -8314,6 +8450,12 @@ "node": ">=22.0.0" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -8980,6 +9122,12 @@ "node": ">=8" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -9459,6 +9607,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.35.3", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", @@ -10233,7 +10387,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, "license": "MIT" }, "node_modules/uuid": { @@ -10479,6 +10632,33 @@ } } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/xml-js/node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index 7a50697..2a16051 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", "@opencoredev/loginwithchatgpt-server": "^0.2.0", "codemirror": "6.0.2", + "docx": "9.7.1", "node-pty": "^1.1.0", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/public/index.html b/public/index.html index 119ee21..1748433 100644 --- a/public/index.html +++ b/public/index.html @@ -30,12 +30,12 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +
- + diff --git a/public/sw.js b/public/sw.js index c74a40c..30839c7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ /* 1Helm shell service worker — offline shell only; never pin API/WS or stale JS. */ -const CACHE = "1helm-shell-v3"; +const CACHE = "1helm-shell-v4"; const PRECACHE = [ "/", "/index.html", @@ -57,7 +57,7 @@ self.addEventListener("fetch", (event) => { caches.open(CACHE).then((c) => c.put("/index.html", copy)).catch(() => {}); return res; }) - .catch(() => caches.match("/index.html").then((r) => r || caches.match("/"))), + .catch(() => caches.match("/index.html").then(async (r) => r || await caches.match("/") || new Response("1Helm is offline.", { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } }))), ); return; } @@ -72,7 +72,7 @@ self.addEventListener("fetch", (event) => { } return res; }) - .catch(() => cached); + .catch(() => cached || Response.error()); return cached || network; }), ); diff --git a/src/client/app.ts b/src/client/app.ts index 8c913af..095519b 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -40,6 +40,7 @@ type State = { globalThreadsUnreadOnly: boolean; /** Profile-bound sidebar preference loaded from /api/me/ui-state. */ groupUnreadChannelsFirst: boolean; + desktopSidebarCollapsed: boolean; photonConfigured: boolean; selectedTextConversationId: number | null; }; @@ -53,6 +54,7 @@ export const S = { globalThreadsOpen: false, globalThreadsUnreadOnly: false, groupUnreadChannelsFirst: false, + desktopSidebarCollapsed: false, photonConfigured: false, selectedTextConversationId: null, threadUsage: { input_tokens: 0, output_tokens: 0 }, @@ -128,6 +130,7 @@ async function loadUiState(): Promise { const result = await api<{ state: Record }>("/api/me/ui-state"); hydrateNotificationPreferences(result.state.notification_preferences); S.groupUnreadChannelsFirst = result.state.group_unread_channels_first === true; + S.desktopSidebarCollapsed = result.state.desktop_sidebar_collapsed === true; const next: Record = {}; for (const [key, value] of Object.entries(result.state || {})) { const match = /^channel_view:(\d+)$/.exec(key); @@ -876,13 +879,14 @@ function mobileNavigation(): HTMLElement { } function sidebar(drawer = false): HTMLElement { + const collapsed = !drawer && S.desktopSidebarCollapsed; const chan = (c: Channel): HTMLElement => { const active = c.id === S.channelId && !S.globalThreadsOpen; const unread = Number(c.unread) > 0; const working = c.agent?.status === "working"; const labels = [c.name, working ? "working" : "", unread ? "unread" : ""].filter(Boolean); // Name carries the iOS-style unread badge on its upper-right corner (on the text, not the row end). - const name = h("span", { class: "channel-nav-label min-w-0 flex-1" }, + const name = collapsed ? null : h("span", { class: "channel-nav-label min-w-0 flex-1" }, h("span", { class: "channel-nav-name truncate" }, c.name), unread && h("span", { class: "channel-unread-badge", "aria-hidden": "true" }), ); @@ -894,11 +898,11 @@ function sidebar(drawer = false): HTMLElement { onclick: () => { closeMobileMenu(); void openChannel(c.id); }, }, c.kind === "dm" - ? h("span", { class: "relative grid h-4 w-4 shrink-0 place-items-center rounded-sm border border-white/10 text-[9px] font-semibold", style: `background:${color(c.name)};color:#f4f7fb` }, initials(c.name)) - : h("span", { class: "shrink-0 text-sidebar-muted" }, icon("hash", 14)), + ? h("span", { class: "relative grid h-4 w-4 shrink-0 place-items-center rounded-sm border border-white/10 text-[9px] font-semibold", style: `background:${color(c.name)};color:#f4f7fb` }, initials(c.name), collapsed && (unread || working) ? h("span", { class: `sidebar-compact-status ${working ? "is-working" : ""}`, "aria-hidden": "true" }) : null) + : h("span", { class: "relative shrink-0 text-sidebar-muted" }, icon("hash", 14), collapsed && (unread || working) ? h("span", { class: `sidebar-compact-status ${working ? "is-working" : ""}`, "aria-hidden": "true" }) : null), name, // Live agent turn — three bouncing dots after the name. - working && h("span", { + working && !collapsed && h("span", { class: "channel-working-dots shrink-0", title: "Agent working", "aria-hidden": "true", @@ -922,16 +926,22 @@ function sidebar(drawer = false): HTMLElement { return h("aside", { class: drawer ? "mobile-drawer fixed inset-y-0 left-0 z-10 flex w-[min(86vw,320px)] flex-col border-r border-white/5 bg-sidebar text-sidebar-fg shadow-2xl" - : "workspace-sidebar hidden w-64 shrink-0 flex-col border-r border-white/5 bg-sidebar text-sidebar-fg md:flex", - dataset: { sidebar: drawer ? "mobile" : "desktop" }, role: drawer ? "dialog" : undefined, + : `workspace-sidebar hidden ${collapsed ? "workspace-sidebar-collapsed w-16" : "w-64"} shrink-0 flex-col border-r border-white/5 bg-sidebar text-sidebar-fg md:flex`, + dataset: { sidebar: drawer ? "mobile" : "desktop", sidebarCollapsed: collapsed ? "true" : "false" }, role: drawer ? "dialog" : undefined, "aria-modal": drawer ? "true" : undefined, "aria-label": drawer ? "Workspace navigation" : undefined, }, - h("div", { class: "flex items-center justify-between border-b border-white/10 px-4 py-3.5" }, - h("div", { class: "flex min-w-0 flex-1 items-start gap-2.5 pr-1 font-semibold text-white" }, h("span", { class: `logo-plate h-7 w-7 shrink-0 rounded-md${S.workspace?.photo_url ? " logo-plate-photo" : ""}` }, h("img", { class: `logo-asset${S.workspace?.photo_url ? " logo-asset-fill" : ""}`, src: workspacePhotoSrc(S.workspace?.photo_url, "sidebar"), alt: S.workspace?.name || "1Helm" })), h("span", { class: "min-w-0 whitespace-normal break-words text-[15px] leading-5 tracking-[-0.01em]", dataset: { workspaceName: "" } }, S.workspace?.name || "1Helm")), - h("div", { class: "flex items-center gap-1" }, - h("button", { class: "grid h-9 w-9 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: theme === "light" ? "Switch to dark" : "Switch to light", onclick: toggleTheme }, icon(theme === "light" ? "moon" : "sun")), - drawer ? h("button", { class: "grid h-11 w-11 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: "Close navigation", "aria-label": "Close navigation", dataset: { drawerClose: "" }, onclick: closeMobileMenu }, icon("x", 20)) : null)), - h("div", { class: "flex-1 space-y-5 overflow-y-auto px-2 py-3", dataset: { continuityKey: `sidebar-${drawer ? "mobile" : "desktop"}-scroll` } }, + collapsed + ? h("div", { class: "flex flex-col items-center gap-1 border-b border-white/10 px-2 py-2" }, + h("span", { class: `logo-plate h-7 w-7 shrink-0 rounded-md${S.workspace?.photo_url ? " logo-plate-photo" : ""}`, title: S.workspace?.name || "1Helm" }, h("img", { class: `logo-asset${S.workspace?.photo_url ? " logo-asset-fill" : ""}`, src: workspacePhotoSrc(S.workspace?.photo_url, "sidebar"), alt: S.workspace?.name || "1Helm" })), + h("button", { class: "grid h-8 w-8 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: "Expand navigation", "aria-label": "Expand navigation", dataset: { sidebarCollapseToggle: "" }, onclick: toggleDesktopSidebar }, h("span", { class: "text-lg leading-none" }, "›"))) + : h("div", { class: "flex items-center justify-between border-b border-white/10 px-4 py-3.5" }, + h("div", { class: "flex min-w-0 flex-1 items-start gap-2.5 pr-1 font-semibold text-white" }, h("span", { class: `logo-plate h-7 w-7 shrink-0 rounded-md${S.workspace?.photo_url ? " logo-plate-photo" : ""}` }, h("img", { class: `logo-asset${S.workspace?.photo_url ? " logo-asset-fill" : ""}`, src: workspacePhotoSrc(S.workspace?.photo_url, "sidebar"), alt: S.workspace?.name || "1Helm" })), h("span", { class: "min-w-0 whitespace-normal break-words text-[15px] leading-5 tracking-[-0.01em]", dataset: { workspaceName: "" } }, S.workspace?.name || "1Helm")), + h("div", { class: "flex items-center gap-1" }, + h("button", { class: "grid h-9 w-9 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: theme === "light" ? "Switch to dark" : "Switch to light", onclick: toggleTheme }, icon(theme === "light" ? "moon" : "sun")), + drawer + ? h("button", { class: "grid h-11 w-11 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: "Close navigation", "aria-label": "Close navigation", dataset: { drawerClose: "" }, onclick: closeMobileMenu }, icon("x", 20)) + : h("button", { class: "grid h-9 w-9 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: "Collapse navigation", "aria-label": "Collapse navigation", dataset: { sidebarCollapseToggle: "" }, onclick: toggleDesktopSidebar }, icon("chevronLeft", 18)))), + h("div", { class: `flex-1 ${collapsed ? "space-y-2" : "space-y-5"} overflow-y-auto px-2 py-3`, dataset: { continuityKey: `sidebar-${drawer ? "mobile" : "desktop"}-scroll` } }, allChannels.length ? h("div", { class: "px-1 pb-1" }, h("button", { type: "button", @@ -942,26 +952,32 @@ function sidebar(drawer = false): HTMLElement { onclick: () => { closeMobileMenu(); openGlobalThreads(); }, }, h("span", { class: "shrink-0 text-sidebar-muted" }, icon("thread", 14)), - h("span", { class: "flex-1 truncate text-left" }, "Threads"))) : null, - favorites.length ? h("div", { dataset: { sidebarFavorites: "" } }, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Favorites"), h("div", { class: "space-y-px" }, ...favorites.map(chan))) : null, - unreads.length ? h("div", { dataset: { sidebarUnreads: "" } }, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Unreads"), h("div", { class: "space-y-px" }, ...unreads.map(chan))) : null, - channels.length || S.me.is_admin ? h("div", {}, sbSection("Agent channels", () => newChannel()), h("div", { class: "space-y-px" }, ...channels.map(chan))) : null, - archived.length ? h("div", {}, h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Archived"), h("div", { class: "space-y-px opacity-65" }, ...archived.map(chan))) : null, - h("div", {}, sbSection("Human channels", () => newHumanChannel()), h("div", { class: "space-y-px" }, ...human.map(chan), human.length === 0 && h("p", { class: "px-2 py-1 text-[13px] text-sidebar-muted" }, "No human channels yet"))), - h("div", {}, sbSection("Direct messages", () => newDM()), h("div", { class: "space-y-px" }, ...dms.map(chan), dms.length === 0 && h("p", { class: "px-2 py-1 text-[13px] text-sidebar-muted" }, "No conversations yet")))), + collapsed ? null : h("span", { class: "flex-1 truncate text-left" }, "Threads"))) : null, + favorites.length ? h("div", { dataset: { sidebarFavorites: "" } }, collapsed ? null : h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Favorites"), h("div", { class: "space-y-px" }, ...favorites.map(chan))) : null, + unreads.length ? h("div", { dataset: { sidebarUnreads: "" } }, collapsed ? null : h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Unreads"), h("div", { class: "space-y-px" }, ...unreads.map(chan))) : null, + channels.length || S.me.is_admin ? h("div", {}, collapsed ? null : sbSection("Agent channels", () => newChannel()), h("div", { class: "space-y-px" }, ...channels.map(chan))) : null, + archived.length ? h("div", {}, collapsed ? null : h("div", { class: "eyebrow px-2 pb-1 text-sidebar-muted" }, "Archived"), h("div", { class: "space-y-px opacity-65" }, ...archived.map(chan))) : null, + h("div", {}, collapsed ? null : sbSection("Human channels", () => newHumanChannel()), h("div", { class: "space-y-px" }, ...human.map(chan), !collapsed && human.length === 0 && h("p", { class: "px-2 py-1 text-[13px] text-sidebar-muted" }, "No human channels yet"))), + h("div", {}, collapsed ? null : sbSection("Direct messages", () => newDM()), h("div", { class: "space-y-px" }, ...dms.map(chan), !collapsed && dms.length === 0 && h("p", { class: "px-2 py-1 text-[13px] text-sidebar-muted" }, "No conversations yet")))), h("button", { - class: "mx-2 mb-1 flex min-h-10 items-center gap-2 rounded-md px-2 py-1.5 text-xs text-sidebar-muted hover:bg-sidebar-hover hover:text-white", + class: `${collapsed ? "mx-auto w-10 justify-center" : "mx-2"} mb-1 flex min-h-10 items-center gap-2 rounded-md px-2 py-1.5 text-xs text-sidebar-muted hover:bg-sidebar-hover hover:text-white`, type: "button", dataset: { feedbackAction: "" }, onclick: () => { closeMobileMenu(); openFeedback(); }, - }, icon("chat", 14), "Feedback"), - h("div", { class: "flex items-center gap-1 border-t border-white/10 p-1.5" }, - h("button", { class: "flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-sidebar-hover", title: "Open profile", onclick: (event: MouseEvent) => { closeMobileMenu(); openProfile(event.currentTarget as HTMLElement); } }, + }, icon("chat", 14), collapsed ? null : "Feedback"), + h("div", { class: `${collapsed ? "flex-col" : ""} flex items-center gap-1 border-t border-white/10 p-1.5` }, + h("button", { class: `${collapsed ? "justify-center" : "min-w-0 flex-1"} flex items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-sidebar-hover`, title: "Open profile", onclick: (event: MouseEvent) => { closeMobileMenu(); openProfile(event.currentTarget as HTMLElement); } }, avatar(S.me.display, "user", 8, S.me.avatar), - h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-white" }, S.me.display), h("div", { class: "flex items-center gap-1.5 truncate font-mono text-[10.5px] text-sidebar-muted" }, h("span", { class: "h-1.5 w-1.5 rounded-full bg-ok" }), "@" + S.me.username + (S.me.is_admin ? " · admin" : "")))), + collapsed ? null : h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-white" }, S.me.display), h("div", { class: "flex items-center gap-1.5 truncate font-mono text-[10.5px] text-sidebar-muted" }, h("span", { class: "h-1.5 w-1.5 rounded-full bg-ok" }), "@" + S.me.username + (S.me.is_admin ? " · admin" : "")))), h("button", { class: "grid h-10 w-10 shrink-0 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: S.me.is_admin ? "Settings" : "Provider settings", "aria-label": "Open settings", onclick: () => { closeMobileMenu(); openSettings(S.me.is_admin ? "agents" : "providers"); } }, icon("gear")))); } +function toggleDesktopSidebar(): void { + S.desktopSidebarCollapsed = !S.desktopSidebarCollapsed; + renderSidebar(); + void api("/api/me/ui-state", { method: "PATCH", body: { key: "desktop_sidebar_collapsed", value: S.desktopSidebarCollapsed } }).catch(() => undefined); +} + function openFeedback(): void { document.getElementById("feedback-modal")?.remove(); const overlay = h("div", { diff --git a/src/client/channel.ts b/src/client/channel.ts index ef86d75..d8a17c8 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -458,6 +458,7 @@ function coworkPath(path: string): { section: "notes" | "whiteboards" | "code" | type FileBrowserSurface = { node: HTMLElement; reload: () => Promise }; const fileBrowserSurfaces = new Map(); +const CORE_WORKSPACE_FOLDERS = ["notes", "whiteboards", "code", "docs", "presentations"] as const; /** Traditional two-pane browser over the channel's one authoritative /workspace. */ export function renderFiles(container: HTMLElement, channelId: number, initialPath = "", onOpenCowork?: (path: string) => void, preserveExisting = false): void { @@ -481,6 +482,8 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa const crumbs = h("nav", { class: "flex min-w-0 flex-1 items-center gap-1 overflow-x-auto font-mono text-xs", "aria-label": "File breadcrumbs", dataset: { fileBreadcrumbs: "" } }); const fileInput = h("input", { type: "file", multiple: true, class: "hidden" }) as HTMLInputElement; let directoryCache: ChannelFile[] | null = null; + let rootEntries: ChannelFile[] = []; + let otherExpanded = false; let mirrorRefresh: Promise | null = null; const open = (entry: ChannelFile): void => { if (entry.kind === "directory") { currentPath = entry.path; selected = null; void load(); return; } @@ -510,14 +513,45 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa }; const drawTree = (directories: ChannelFile[]): void => { clear(tree); - const all = [{ path: "", name: "workspace", size: 0, modified: 0, kind: "directory" as const }, ...directories]; - for (const directory of all) { - const depth = directory.path ? directory.path.split("/").length : 0; + const core = new Set(CORE_WORKSPACE_FOLDERS); + const rootName = (path: string): string => path.split("/")[0] || ""; + const appendDirectory = (directory: ChannelFile, depth: number, group: "workspace" | "core" | "other"): void => { tree.append(h("button", { class: `mb-0.5 flex min-h-9 w-full items-center gap-2 rounded-md pr-2 text-left text-xs ${currentPath === directory.path ? "bg-accent-soft text-fg" : "text-muted hover:bg-hover hover:text-fg"}`, - style: `padding-left:${8 + depth * 14}px`, type: "button", onclick: () => { currentPath = directory.path; selected = null; void load(); }, + style: `padding-left:${8 + depth * 14}px`, type: "button", dataset: { fileTreePath: directory.path || "/", fileTreeGroup: group }, + onclick: () => { currentPath = directory.path; selected = null; void load(); }, }, h("span", { class: currentPath === directory.path ? "text-accent" : "text-faint" }, icon(currentPath === directory.path ? "folderOpen" : "folder", 15)), h("span", { class: "truncate" }, directory.name))); + }; + appendDirectory({ path: "", name: "workspace", size: 0, modified: 0, kind: "directory" }, 0, "workspace"); + for (const folder of CORE_WORKSPACE_FOLDERS) { + const entry = directories.find((directory) => directory.path === folder) + || { path: folder, name: folder, size: 0, modified: 0, kind: "directory" as const }; + appendDirectory(entry, 1, "core"); + } + for (const folder of CORE_WORKSPACE_FOLDERS) { + for (const child of directories.filter((directory) => directory.path.startsWith(`${folder}/`))) { + appendDirectory(child, child.path.split("/").length, "core"); + } + } + const otherDirectories = directories.filter((directory) => !core.has(rootName(directory.path))); + const otherRootFiles = rootEntries.filter((entry) => entry.kind === "file" && !core.has(rootName(entry.path))); + const otherActive = Boolean(currentPath && !core.has(rootName(currentPath))); + if (otherActive) otherExpanded = true; + tree.append(h("button", { + class: `mb-0.5 mt-2 flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-left text-xs ${otherActive ? "bg-accent-soft text-fg" : "text-muted hover:bg-hover hover:text-fg"}`, + type: "button", dataset: { fileOtherToggle: "" }, "aria-expanded": String(otherExpanded), + onclick: () => { otherExpanded = !otherExpanded; drawTree(directories); }, + }, h("span", { class: `text-[10px] transition-transform ${otherExpanded ? "rotate-90" : ""}` }, "›"), h("span", { class: "text-faint" }, icon(otherExpanded ? "folderOpen" : "folder", 15)), h("span", { class: "truncate font-medium" }, "Other"))); + if (!otherExpanded) return; + for (const directory of otherDirectories) appendDirectory(directory, directory.path.split("/").length, "other"); + for (const file of otherRootFiles) { + tree.append(h("button", { + class: `mb-0.5 flex min-h-9 w-full items-center gap-2 rounded-md pr-2 text-left text-xs ${selected?.path === file.path ? "bg-accent-soft text-fg" : "text-muted hover:bg-hover hover:text-fg"}`, + style: "padding-left:22px", type: "button", dataset: { fileTreePath: file.path, fileTreeGroup: "other" }, + onclick: () => { selected = file; redrawSelection(); }, ondblclick: () => open(file), + }, h("span", { class: "text-faint" }, workspaceIcon(file, 15)), h("span", { class: "truncate" }, file.name))); } + if (!otherDirectories.length && !otherRootFiles.length) tree.append(h("p", { class: "px-6 py-2 text-[11px] text-faint", dataset: { fileOtherEmpty: "" } }, "No other files")); }; const drawInfo = (): void => { clear(info); @@ -534,7 +568,8 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void mutate("rename"); } }, "Rename"), h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void mutate("move"); } }, "Move"), h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void mutate("duplicate"); } }, "Duplicate"), - selected.kind === "file" ? h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(selected!.path)}&download=1`, selected!.name); } }, "Download") : null, + selected.kind === "file" ? h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(selected!.path)}&download=1`, selected!.name).catch((error) => appAlert((error as Error).message)); } }, "Download") : null, + selected.kind === "file" && /\.md$/i.test(selected.name) ? h("button", { class: "btn-subtle text-xs", type: "button", dataset: { downloadDocx: "" }, onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/docx?path=${encodeURIComponent(selected!.path)}`, selected!.name.replace(/\.md$/i, ".docx")).catch((error) => appAlert(`DOCX download failed: ${(error as Error).message}`)); } }, "Download - DOCX") : null, h("button", { class: "btn-ghost text-xs text-danger", type: "button", onclick: () => { void mutate("delete"); } }, "Delete"))); }; const refreshDirectories = async (): Promise => { @@ -552,6 +587,7 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa try { const result = await api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`); if (requestedPath !== currentPath) return; + if (!requestedPath) rootEntries = result.files; currentPath = result.path ?? requestedPath; heading.textContent = `/workspace${currentPath ? `/${currentPath}` : ""}`; main.dataset.fileDirectory = currentPath || "/"; if (directoryCache) drawTree(directoryCache); else void refreshDirectories(); diff --git a/src/client/cowork.ts b/src/client/cowork.ts index c0945b1..698ff71 100644 --- a/src/client/cowork.ts +++ b/src/client/cowork.ts @@ -26,12 +26,12 @@ type SectionSession = { }; type CoworkSurface = { node: HTMLElement; openPath: (path: string) => Promise; reload: () => Promise; setOpenThread: (callback: (root: Message) => void) => void; setActive: (active: boolean) => void }; -const SECTIONS: Array<{ id: CoworkSection; label: string; folder: string; icon: string; defaultName: string }> = [ - { id: "notes", label: "Notes", folder: "notes", icon: "fileText", defaultName: "untitled.md" }, - { id: "whiteboards", label: "Whiteboard", folder: "whiteboards", icon: "board", defaultName: "untitled.whiteboard.json" }, - { id: "code", label: "Code", folder: "code", icon: "code", defaultName: "untitled.txt" }, - { id: "docs", label: "Docs", folder: "docs", icon: "fileText", defaultName: "untitled-document.md" }, - { id: "presentations", label: "Presentations", folder: "presentations", icon: "presentation", defaultName: "untitled.slides.json" }, +const SECTIONS: Array<{ id: CoworkSection; label: string; folder: string; icon: string; defaultExtension: string }> = [ + { id: "notes", label: "Notes", folder: "notes", icon: "fileText", defaultExtension: ".md" }, + { id: "whiteboards", label: "Whiteboard", folder: "whiteboards", icon: "board", defaultExtension: ".whiteboard.json" }, + { id: "code", label: "Code", folder: "code", icon: "code", defaultExtension: ".txt" }, + { id: "docs", label: "Docs", folder: "docs", icon: "fileText", defaultExtension: ".md" }, + { id: "presentations", label: "Presentations", folder: "presentations", icon: "presentation", defaultExtension: ".slides.json" }, ]; const surfaces = new Map(); @@ -474,7 +474,19 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: agentToggle.onclick = () => { agentOpen = !agentOpen; if (agentOpen) { focusAgentOnDraw = true; coworkContextPending = true; } drawAgent(); }; search.oninput = () => { filter = search.value.trim().toLowerCase(); void loadFiles(); }; const createFolder = async (): Promise => { const name = await appPrompt("Folder name"); if (!name) return; try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: activeSession().folder, name } }); await loadFiles(); } catch (error) { status.textContent = (error as Error).message; } }; - const createFile = async (): Promise => { const item = activeSection(); const name = await appPrompt(`New ${item.label.toLowerCase()} file`, item.defaultName); if (!name) return; try { const result = await api<{ file: ChannelFile }>(`/api/channels/${channelId}/files/entries`, { body: { parent: activeSession().folder, name, content: starterContent(section) } }); await openPath(result.file.path); } catch (error) { status.textContent = (error as Error).message; } }; + const createFile = async (): Promise => { + const item = activeSection(); + const input = await appPrompt(`New ${item.label.toLowerCase()} file`); + const requested = input?.trim() || ""; + if (!requested) return; + // A supplied suffix is deliberate, including compound formats such as + // .slides.json. Only truly extensionless names receive the section default. + const name = /\.[^./]+(?:\.[^./]+)*$/.test(requested) ? requested : `${requested}${item.defaultExtension}`; + try { + const result = await api<{ file: ChannelFile }>(`/api/channels/${channelId}/files/entries`, { body: { parent: activeSession().folder, name, content: starterContent(section) } }); + await openPath(result.file.path); + } catch (error) { status.textContent = (error as Error).message; } + }; const draw = async (): Promise => { updateSectionNav(); drawBreadcrumb(); await Promise.all([loadFiles(), drawWorkspace()]); drawAgent(); }; const rail = h("aside", { class: "cowork-files flex min-h-0 w-[min(18rem,28vw)] shrink-0 flex-col border-r border-line bg-surface" }, h("div", { class: "space-y-2 border-b border-line p-3" }, h("div", { class: "flex items-center gap-2" }, breadcrumb, h("button", { class: "grid h-8 w-8 shrink-0 place-items-center rounded text-muted hover:bg-hover", title: "New folder", onclick: () => { void createFolder(); } }, icon("folder", 14)), h("button", { class: "grid h-8 w-8 shrink-0 place-items-center rounded bg-accent text-white", title: "New file", onclick: () => { void createFile(); } }, icon("plus", 14))), search), fileList, fileActions, h("div", { class: "min-h-9 border-t border-line px-3 py-2" }, status)); diff --git a/src/client/styles.css b/src/client/styles.css index 25eaf25..c97fea9 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -553,6 +553,10 @@ select.field { color: var(--c-sidebar-fg); } .nav-item-idle:hover { background: var(--c-sidebar-hover); color: #fff; } +.workspace-sidebar { transition: width .16s ease; } +.workspace-sidebar-collapsed .nav-item { min-height: 2.35rem; justify-content: center; gap: 0; padding-left: .5rem; padding-right: .5rem; } +.sidebar-compact-status { position: absolute; top: -4px; right: -5px; width: 7px; height: 7px; border: 1px solid var(--c-sidebar); border-radius: 999px; background: #fff; } +.sidebar-compact-status.is-working { background: var(--c-ok); } /* Unread badge sits ON the channel name (iOS app-icon style, upper-right of the text). */ .channel-nav-label { @@ -905,7 +909,8 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .cowork-notes-edit-stage .cowork-codemirror { min-height: 0; height: 100%; } .cowork-notes-edit-stage .cowork-codemirror > .cm-editor { height: 100%; } .cowork-notes-edit-stage .cm-scroller { overflow-y: auto; } -.cowork-codemirror-code { background: color-mix(in srgb, #071019 72%, var(--c-surface)); } +.cowork-codemirror-code { background: var(--c-surface); } +.dark .cowork-codemirror-code { background: color-mix(in srgb, #071019 72%, var(--c-surface)); } .cowork-markdown-preview { min-height: 100%; overflow-y: auto; background: var(--c-surface); padding: 1.5rem 2rem; } .cowork-doc-canvas { background: color-mix(in srgb, var(--c-raised) 75%, var(--c-bg)); } .cowork-doc-page { width: min(48rem, calc(100% - 2rem)); min-height: min(62rem, calc(100% - 2rem)); margin: 1rem auto; border: 1px solid var(--c-line); border-radius: .2rem; background: var(--c-surface); box-shadow: 0 20px 70px -45px #000; overflow: hidden; } @@ -922,8 +927,14 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .cowork-slide-action { min-width: 1.4rem; border-radius: .25rem; color: var(--c-muted); } .cowork-slide-action:hover:not(:disabled) { background: var(--c-hover); color: var(--c-fg); } .cowork-slide-action:disabled { opacity: .3; } -.cowork-slide-stage { display: grid; min-width: 0; flex: 1 1 auto; place-items: center; overflow: auto; padding: 1.5rem; background: color-mix(in srgb, var(--c-raised) 70%, var(--c-bg)); } +.cowork-slide-stage { display: grid; min-width: 0; flex: 1 1 auto; place-items: start center; overflow: auto; padding: 1.5rem; scroll-padding-top: 1.5rem; background: color-mix(in srgb, var(--c-raised) 70%, var(--c-bg)); } .cowork-slide-canvas { aspect-ratio: 16 / 9; width: min(55rem, 96%); flex: 0 0 auto; border: 1px solid var(--c-line); box-shadow: 0 22px 70px -40px #000; } +/* Excalidraw flips its full-height menu above the trigger when a 16:9 slide + * cannot fit the whole list below. That puts the first actions outside the + * clipped canvas. Keep the menu below its trigger and scroll its bounded body. */ +.cowork-slide-canvas .dropdown-menu { top: 100% !important; bottom: auto !important; transform: none !important; } +.cowork-slide-canvas .dropdown-menu { height: min(15rem, calc(100% - 4.75rem)); overflow: hidden; } +.cowork-slide-canvas .dropdown-menu .dropdown-menu-container { max-height: 100%; overflow-y: auto; overscroll-behavior: contain; } .cowork-presentation-view { width: 100%; height: 100%; } .cowork-agent-toggle { position: absolute; right: 1rem; bottom: 1rem; z-index: 5; display: grid; width: 3rem; height: 3rem; place-items: center; border: 3px solid var(--c-surface); border-radius: 999px; background: var(--c-surface); box-shadow: 0 10px 30px -15px #000, 0 0 0 1px var(--c-line); transition: transform .15s ease, box-shadow .15s ease; } .cowork-agent-toggle:hover { transform: translateY(-2px); box-shadow: 0 16px 34px -15px #000, 0 0 0 2px var(--c-accent-soft); } diff --git a/src/server/docx.ts b/src/server/docx.ts new file mode 100644 index 0000000..cdb3427 --- /dev/null +++ b/src/server/docx.ts @@ -0,0 +1,70 @@ +import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx"; + +const inlineRuns = (input: string, options: { code?: boolean; italics?: boolean } = {}): TextRun[] => { + if (options.code) return [new TextRun({ text: input, font: "Courier New", size: 20 })]; + const runs: TextRun[] = []; + const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_.*?_)/g; + let offset = 0; + for (const match of input.matchAll(pattern)) { + const index = match.index || 0; + if (index > offset) runs.push(new TextRun({ text: input.slice(offset, index), italics: options.italics })); + const value = match[0]; + if ((value.startsWith("**") && value.endsWith("**")) || (value.startsWith("__") && value.endsWith("__"))) { + runs.push(new TextRun({ text: value.slice(2, -2), bold: true, italics: options.italics })); + } else if (value.startsWith("`") && value.endsWith("`")) { + runs.push(new TextRun({ text: value.slice(1, -1), font: "Courier New", size: 20, italics: options.italics })); + } else { + runs.push(new TextRun({ text: value.slice(1, -1), italics: true })); + } + offset = index + value.length; + } + if (offset < input.length) runs.push(new TextRun({ text: input.slice(offset), italics: options.italics })); + return runs.length ? runs : [new TextRun({ text: input, italics: options.italics })]; +}; + +/** A contained Markdown file becomes a genuine Office Open XML document. + * The converter deliberately favors predictable Word structure over trying to + * reproduce every browser-only Markdown extension. */ +export async function markdownToDocx(markdown: string): Promise { + const children: Paragraph[] = []; + const lines = String(markdown || "").replace(/\r\n?/g, "\n").split("\n"); + let paragraph: string[] = []; + let code: string[] | null = null; + const flushParagraph = (): void => { + if (!paragraph.length) return; + children.push(new Paragraph({ children: inlineRuns(paragraph.join(" ")), spacing: { after: 160 } })); + paragraph = []; + }; + const flushCode = (): void => { + if (!code) return; + children.push(new Paragraph({ children: inlineRuns(code.join("\n"), { code: true }), spacing: { before: 80, after: 160 } })); + code = null; + }; + for (const line of lines) { + if (/^```/.test(line)) { + if (code) flushCode(); + else { flushParagraph(); code = []; } + continue; + } + if (code) { code.push(line); continue; } + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + flushParagraph(); + const levels = [HeadingLevel.HEADING_1, HeadingLevel.HEADING_2, HeadingLevel.HEADING_3, HeadingLevel.HEADING_4, HeadingLevel.HEADING_5, HeadingLevel.HEADING_6]; + children.push(new Paragraph({ heading: levels[heading[1].length - 1], children: inlineRuns(heading[2]), spacing: { before: 160, after: 100 } })); + continue; + } + const unordered = /^\s*[-*+]\s+(.*)$/.exec(line); + if (unordered) { flushParagraph(); children.push(new Paragraph({ bullet: { level: 0 }, children: inlineRuns(unordered[1]) })); continue; } + const ordered = /^\s*(\d+)[.)]\s+(.*)$/.exec(line); + if (ordered) { flushParagraph(); children.push(new Paragraph({ children: [new TextRun({ text: `${ordered[1]}. `, bold: true }), ...inlineRuns(ordered[2])] })); continue; } + const quote = /^\s*>\s?(.*)$/.exec(line); + if (quote) { flushParagraph(); children.push(new Paragraph({ indent: { left: 480 }, children: inlineRuns(quote[1], { italics: true }) })); continue; } + if (/^\s*([-*_])(?:\s*\1){2,}\s*$/.test(line)) { flushParagraph(); children.push(new Paragraph({ text: "────────────────────────" })); continue; } + if (!line.trim()) { flushParagraph(); continue; } + paragraph.push(line.trim()); + } + flushParagraph(); flushCode(); + if (!children.length) children.push(new Paragraph({ text: "" })); + return Packer.toBuffer(new Document({ sections: [{ properties: {}, children }] })); +} diff --git a/src/server/index.ts b/src/server/index.ts index 14f758d..85fedad 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -73,6 +73,7 @@ import { stopAllConnectors } from "./connectors.ts"; import { ensureImageGenerationSkill, listSkills, listTemplates, provisionSkill, skillsForAgent, setImageGenerationEnabled, imageGenerationAvailable, imageGenerationEnabledIds } from "./skills.ts"; import { inspectCatalogSkill, installCatalogSkill, refreshSkillCatalog, searchSkillCatalog, skillCatalogStatus } from "./skill-catalog.ts"; import { auditEvents, verifyAuditChain } from "./audit.ts"; +import { markdownToDocx } from "./docx.ts"; import { configurePhoton, continuePhotonConversation, deliverPhotonEvent, photonConversation, photonConversations, photonStatus, registerPhotonDispatcher, startPhotonConnector, stopPhotonConnector } from "./photon.ts"; import { photonSetupStatus, startPhotonSetup } from "./photon-auth.ts"; import { completeGmailConnection, gmailConnectionStatus, saveGmailOAuthClient, startGmailConnection } from "./gmail.ts"; @@ -1533,6 +1534,26 @@ const server = createServer(async (req, res) => { return json(res, 200, { file: saveWorkspaceTextFile(channelId, path, String(b?.content || "")) }); } catch (error) { return json(res, /not found/i.test((error as Error).message) ? 404 : 400, { error: (error as Error).message }); } } + const worldDocx = p.match(/^\/api\/channels\/(\d+)\/files\/docx$/); + if (worldDocx && m === "GET") { + const channelId = Number(worldDocx[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + if (!q1("SELECT 1 FROM channels WHERE id=? AND kind='channel'", channelId)) return json(res, 404, { error: "Agent-channel file not found." }); + try { + const path = String(url.searchParams.get("path") || ""); + if (!/\.md$/i.test(path)) return json(res, 400, { error: "Choose a Markdown (.md) file to download as DOCX." }); + const file = readWorkspaceTextFile(channelId, path); + const name = file.name.replace(/\.md$/i, ".docx"); + const output = await markdownToDocx(file.content); + res.writeHead(200, { + "content-type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(name)}`, + "content-length": output.byteLength, + ...SECURITY_HEADERS, + }); + return res.end(output); + } catch (error) { return json(res, /not found/i.test((error as Error).message) ? 404 : 400, { error: (error as Error).message }); } + } const coworkPresenceRoute = p.match(/^\/api\/channels\/(\d+)\/cowork\/presence$/); if (coworkPresenceRoute && m === "GET") { const channelId = Number(coworkPresenceRoute[1]); diff --git a/test/channel-surfaces.mjs b/test/channel-surfaces.mjs index 3e0bb8c..6da4f5a 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -195,7 +195,13 @@ test("channel UI source exposes file-backed Cowork, traditional Files, audio pre assert.match(coworkSource, /const resetEditor[\s\S]*session\.loaded = false[\s\S]*disconnectEditors[\s\S]*resetEditor\(candidate\)/, "Cowork discards disconnected Yjs histories before a hidden surface is reopened"); assert.match(channelSource, /onclick: \(\) => \{ selected = entry; redrawSelection\(\); \}/, "Files paints selection immediately without re-fetching the guest mirror"); assert.match(channelSource, /const refreshDirectories = async/, "Files loads its recursive folder tree independently of the current directory listing"); + assert.match(channelSource, /CORE_WORKSPACE_FOLDERS = \["notes", "whiteboards", "code", "docs", "presentations"\]/, "Files visually prioritizes the five Cowork roots"); + assert.match(channelSource, /fileOtherToggle/, "Files groups non-core root entries behind a visual Other disclosure"); + assert.match(channelSource, /files\/docx[\s\S]*catch\(\(error\) => appAlert\(`DOCX download failed:[\s\S]*Download - DOCX/, "Markdown files expose a real DOCX export action with visible failure handling"); assert.match(stylesSource, /\.cowork-notes-edit-stage \.cm-scroller \{ overflow-y: auto; \}/, "long Cowork Notes edit sessions scroll in CodeMirror's real viewport"); + assert.match(stylesSource, /\.cowork-codemirror-code \{ background: var\(--c-surface\); \}[\s\S]*\.dark \.cowork-codemirror-code/, "Code uses a legible light surface without changing its dark treatment"); + assert.match(stylesSource, /\.cowork-slide-stage[^}]*place-items: start center/, "oversized presentation canvases remain reachable from their top edge"); + assert.match(stylesSource, /\.cowork-slide-canvas \.dropdown-menu[^}]*top: 100% !important[\s\S]*height: min\(15rem[\s\S]*max-height: 100%/, "presentation menus stay below their trigger with a bounded scrollable body"); assert.match(channelSource, /files\/refresh[\s\S]*void refreshMirror\(\)/, "Files paints the host cache first and refreshes the VM mirror independently"); assert.match(serverSource, /channelFilesRefresh[\s\S]*refreshChannelWorkspaceMirror\(channelId\)/, "Files has one explicit coalesced VM refresh route instead of syncing every click"); assert.match(coworkSource, /\.\.\.\(coworkContextPending \? \{ coworkPath: session\.path \} : \{\}\)/, "a Cowork panel's first message submits its open path for authenticated enrichment"); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index b8b0560..af471c9 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -83,6 +83,8 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await createFile("code", "tool.ts", "export const ready = true;\n"); await createFile("docs", "proposal.md", "# Proposal\n"); await createFile("presentations", "review.slides.json", JSON.stringify({ version: 1, slides: [{ title: "Review", body: "Ready" }] }, null, 2)); + await api(`/api/channels/${channel.id}/files/directories`, { body: { path: "", name: "archive" } }, token); + await createFile("", "README.md", "# Workspace overview\n"); const markdownThread = (await api(`/api/channels/${channel.id}/messages`, { body: { body: "** Goal **\n\n** Session Status **" } }, token)).message; for (let index = 0; index < 18; index++) await api(`/api/channels/${channel.id}/messages`, { body: { body: `Mobile scroll fixture ${index + 1}: ${"readable history ".repeat(18)}` } }, token); @@ -328,6 +330,23 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("tool.ts")); await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("tool.ts"))?.click()); await page.waitForSelector('[aria-label="Code editor"][data-cowork-language="typescript"] .cm-lineNumbers'); + if (!(await page.evaluate(() => document.documentElement.classList.contains("light")))) { + await page.evaluate(() => [...document.querySelectorAll('button[title="Switch to light"]')][0]?.click()); + await page.waitForFunction(() => document.documentElement.classList.contains("light")); + } + const codeContrast = await page.$eval('[aria-label="Code editor"]', (editor) => { + const parse = (value) => (value.match(/[\d.]+/g) || []).slice(0, 3).map(Number); + const luminance = (rgb) => { + const values = rgb.map((value) => { const channel = value / 255; return channel <= .03928 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4; }); + return .2126 * values[0] + .7152 * values[1] + .0722 * values[2]; + }; + const style = getComputedStyle(editor.querySelector('.cm-editor')); + const background = getComputedStyle(editor).backgroundColor; + const foreground = style.color; + const [bright, dark] = [luminance(parse(background)), luminance(parse(foreground))].sort((a, b) => b - a); + return { background, foreground, ratio: (bright + .05) / (dark + .05) }; + }); + assert.ok(codeContrast.ratio >= 4.5, JSON.stringify(codeContrast)); await page.click('[aria-label="Code editor"] .cm-content'); await page.keyboard.down(primaryModifier); await page.keyboard.press("a"); await page.keyboard.up(primaryModifier); await page.keyboard.press("ArrowRight"); await page.keyboard.press("Enter"); await page.keyboard.press("Tab"); await page.keyboard.type("const searchableValue = 42;"); @@ -378,6 +397,14 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.evaluate(() => [...document.querySelectorAll('[data-cowork-file-actions] button')].find((button) => button.textContent.trim() === "Delete")?.click()); await page.waitForSelector('.modal-overlay'); await page.evaluate(() => [...document.querySelectorAll('.modal-overlay button')].find((button) => button.textContent.trim() === "Confirm")?.click()); await page.waitForFunction(() => !document.querySelector('[data-cowork-path="docs/drafts/renamed copy.md"]')); + await page.evaluate(() => document.querySelector('[aria-label="Cowork folder path"] button')?.click()); + await page.waitForFunction(() => !document.querySelector('[aria-label="Cowork folder path"]')?.textContent.includes("drafts")); + await clickCoworkTitle("New file"); + assert.equal(await page.$eval('.modal-overlay input', (input) => input.value), "", "Cowork new-file prompts start blank"); + await answerPrompt("default-document"); + await page.waitForSelector('[data-cowork-path="docs/default-document.md"]'); + await clickCoworkTitle("New file"); await answerPrompt("explicit.custom"); + await page.waitForSelector('[data-cowork-path="docs/explicit.custom"]'); // Presentations are one collaborative file with live slide structure, // Excalidraw drawing, reordering, deletion, persistence, and presentation mode. @@ -385,6 +412,17 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("review.slides.json")); await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("review.slides.json"))?.click()); await page.waitForSelector('[data-cowork-presentation][data-slide-count="1"] .cowork-slide-canvas .excalidraw'); + await page.click('.cowork-slide-canvas .main-menu-trigger'); + await page.waitForSelector('.cowork-slide-canvas .dropdown-menu'); + const menuGeometry = await page.$eval('.cowork-slide-canvas .dropdown-menu', (menu) => { + const menuBox = menu.getBoundingClientRect(); + const canvasBox = menu.closest('.cowork-slide-canvas').getBoundingClientRect(); + const stageBox = menu.closest('.cowork-slide-stage').getBoundingClientRect(); + return { menuTop: menuBox.top, menuBottom: menuBox.bottom, canvasTop: canvasBox.top, canvasBottom: canvasBox.bottom, stageTop: stageBox.top, viewportTop: 0 }; + }); + assert.ok(menuGeometry.menuTop >= Math.max(menuGeometry.canvasTop, menuGeometry.stageTop, menuGeometry.viewportTop) - 1, JSON.stringify(menuGeometry)); + assert.ok(menuGeometry.menuBottom <= menuGeometry.canvasBottom + 1, JSON.stringify(menuGeometry)); + await page.click('.cowork-slide-canvas .main-menu-trigger'); await collaboratorPage.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Presentations")?.click()); await collaboratorPage.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("review.slides.json")); await collaboratorPage.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("review.slides.json"))?.click()); @@ -430,6 +468,12 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.waitForSelector('[data-file-browser]'); const rootFolders = await page.$$eval('[data-file-directory="/"] [data-file-kind="directory"]', (items) => items.map((item) => ({ name: item.textContent, folderIcon: Boolean(item.querySelector('.file-grid-icon.is-folder')) }))); assert.ok(["notes", "whiteboards", "code", "docs", "presentations"].every((name) => rootFolders.some((item) => item.name.includes(name) && item.folderIcon))); + const treeBeforeOther = await page.$$eval('[data-file-folder-tree] [data-file-tree-path]', (items) => items.map((item) => item.getAttribute("data-file-tree-path"))); + assert.deepEqual(treeBeforeOther.filter((path) => path === "/" || !path.includes("/")).slice(0, 6), ["/", "notes", "whiteboards", "code", "docs", "presentations"]); + assert.equal(treeBeforeOther.includes("archive"), false, "Other starts collapsed"); + await page.click('[data-file-other-toggle]'); + await page.waitForSelector('[data-file-tree-path="archive"]'); + await page.waitForSelector('[data-file-tree-path="README.md"]'); await page.screenshot({ path: "/tmp/1helm-files-desktop.png" }); await page.evaluate(() => document.querySelector('[data-file-path="docs"]')?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }))); await page.waitForSelector('[data-file-directory="docs"]'); @@ -449,6 +493,7 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.waitForSelector('[data-file-path="docs/browser.md"]'); await page.click('[data-file-path="docs/browser.md"]'); await page.waitForFunction(() => document.querySelector('[data-file-metadata]')?.textContent.includes("browser.md")); + assert.equal(await page.$eval('[data-file-metadata] [data-download-docx]', (button) => button.textContent.trim()), "Download - DOCX"); await page.evaluate(() => [...document.querySelectorAll('[data-file-metadata] button')].find((button) => button.textContent.trim() === "Rename")?.click()); await submitPrompt("renamed.md"); await page.waitForSelector('[data-file-path="docs/renamed.md"]'); await page.click('[data-file-path="docs/renamed.md"]'); @@ -470,6 +515,23 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil assert.ok(markdown.strong.some((text) => text.trim().toLowerCase().startsWith("goal")) && markdown.strong.some((text) => text.trim().toLowerCase().startsWith("session status")), JSON.stringify(markdown)); assert.doesNotMatch(markdown.text, /\*\*/); + // Desktop navigation collapses to a compact rail, gives the main surface + // the reclaimed width, persists through a shell reload, and expands again. + const expandedWidth = await page.$eval('[data-sidebar="desktop"]', (sidebar) => sidebar.getBoundingClientRect().width); + const mainWidthBeforeCollapse = await page.$eval('#main', (main) => main.getBoundingClientRect().width); + await page.click('[data-sidebar="desktop"] [data-sidebar-collapse-toggle]'); + await page.waitForSelector('[data-sidebar="desktop"][data-sidebar-collapsed="true"]'); + const collapsedLayout = await page.evaluate(() => ({ + sidebar: document.querySelector('[data-sidebar="desktop"]').getBoundingClientRect().width, + main: document.querySelector('#main').getBoundingClientRect().width, + })); + assert.ok(collapsedLayout.sidebar < expandedWidth && collapsedLayout.main > mainWidthBeforeCollapse, JSON.stringify(collapsedLayout)); + await waitFor(async () => (await api("/api/me/ui-state", {}, token)).state.desktop_sidebar_collapsed === true, "persisted desktop sidebar collapse"); + await page.reload({ waitUntil: "networkidle0" }); + await page.waitForSelector('[data-sidebar="desktop"][data-sidebar-collapsed="true"] [aria-label="Expand navigation"]'); + await page.click('[data-sidebar="desktop"] [data-sidebar-collapse-toggle]'); + await page.waitForSelector('[data-sidebar="desktop"][data-sidebar-collapsed="false"]'); + // On a phone, scrolling away blurs the composer (allowing the OS keyboard // to dismiss), settings navigation stays compact, and the visible viewport // owns the application height without clipping below the screen. @@ -488,6 +550,7 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil assert.ok(Math.abs(mobile.shellBottom - mobile.viewport) <= 1 && mobile.cssHeight.endsWith("px"), JSON.stringify(mobile)); await page.click('[aria-label="Open navigation"]'); await page.waitForSelector('[data-sidebar="mobile"] [aria-label="Open settings"]'); + assert.equal(await page.$('[data-sidebar="mobile"] [data-sidebar-collapse-toggle]'), null, "desktop collapse never changes the mobile drawer"); await page.evaluate(() => document.querySelector('[data-sidebar="mobile"] [aria-label="Open settings"]')?.click()); await page.waitForSelector('[aria-label="Settings sections"]'); const settings = await page.$eval('[aria-label="Settings sections"]', (nav) => ({ height: nav.getBoundingClientRect().height, width: nav.getBoundingClientRect().width, scrolls: nav.scrollWidth > nav.clientWidth })); diff --git a/test/sweep-server-integration.mjs b/test/sweep-server-integration.mjs index c5cf792..84ed549 100644 --- a/test/sweep-server-integration.mjs +++ b/test/sweep-server-integration.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -160,6 +160,19 @@ test("server sweep seams stay scoped, human-only, Unicode-safe, and least-arsena assert.equal((await api(base, `/api/channels/${channelId}/files/refresh`, crewToken, { method: "POST" })).status, 403); const opened = await fetch(`${base}/api/channels/${channelId}/files/content?path=briefs%2Fbrief.txt`, { headers: { authorization: `Bearer ${token}` } }); assert.equal(await opened.text(), "bounded file"); + assert.equal((await api(base, `/api/channels/${channelId}/files/entries`, token, { method: "POST", body: { parent: "briefs", name: "launch.md", content: "# Launch\n\n**Ready** for review.\n\n- First step" } })).status, 201); + const docx = await fetch(`${base}/api/channels/${channelId}/files/docx?path=briefs%2Flaunch.md`, { headers: { authorization: `Bearer ${token}` } }); + assert.equal(docx.status, 200); + assert.equal(docx.headers.get("content-type"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + assert.match(docx.headers.get("content-disposition") || "", /attachment; filename\*=UTF-8''launch\.docx/); + const docxBytes = Buffer.from(await docx.arrayBuffer()); + assert.equal(docxBytes.subarray(0, 2).toString(), "PK", "DOCX is an Office Open XML ZIP, not renamed Markdown"); + const docxPath = join(dataDir, "launch.docx"); writeFileSync(docxPath, docxBytes); + const documentXml = spawnSync("unzip", ["-p", docxPath, "word/document.xml"], { encoding: "utf8" }); + assert.equal(documentXml.status, 0, documentXml.stderr); + assert.match(documentXml.stdout, /Launch/); assert.match(documentXml.stdout, /Ready/); assert.match(documentXml.stdout, /First step/); + assert.equal((await api(base, `/api/channels/${channelId}/files/docx?path=briefs%2Fbrief.txt`, token)).status, 400); + assert.equal((await api(base, `/api/channels/${channelId}/files/docx?path=briefs%2Flaunch.md`, crewToken)).status, 403); assert.equal((await api(base, `/api/channels/${channelId}/files/directories`, token, { method: "POST", body: { path: "../", name: "escape" } })).status, 400); const catalog = await api(base, "/api/skills", token); diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs index 89477fe..c7cc45f 100644 --- a/test/workspace-interactions.mjs +++ b/test/workspace-interactions.mjs @@ -9,12 +9,16 @@ const settings = await readFile(resolve(root, "src/client/settings.ts"), "utf8") const routing = await readFile(resolve(root, "src/client/routing.ts"), "utf8"); const desktop = await readFile(resolve(root, "desktop/main.cjs"), "utf8"); const server = await readFile(resolve(root, "src/server/index.ts"), "utf8"); +const serviceWorker = await readFile(resolve(root, "public/sw.js"), "utf8"); test("workspace sidebar interactions have durable, member-scoped contracts", () => { assert.match(app, /data(?:set)?:? \{ sidebarFavorites: "" \}/, "favorites have their own sidebar section"); assert.match(app, /\/api\/channels\/\$\{channel\.id\}\/favorite/, "the current channel favorite action calls its typed integration endpoint"); assert.match(app, /"\/api\/human-channels"/, "human-only channel creation uses its dedicated endpoint"); assert.match(app, /group_unread_channels_first/, "the sidebar loads the per-user unread grouping preference"); + assert.match(app, /desktop_sidebar_collapsed/, "desktop sidebar collapse is a profile-bound UI preference"); + assert.match(app, /dataset: \{ sidebar: drawer \? "mobile" : "desktop", sidebarCollapsed: collapsed \? "true" : "false" \}/, "desktop collapse state is visible for behavioral and accessibility checks"); + assert.match(app, /title: "Expand navigation"[\s\S]*title: "Collapse navigation"/, "the compact desktop rail always retains an explicit expand control"); assert.match(settings, /key: "group_unread_channels_first"/, "the setting persists through user UI state"); assert.match(app, /const members = Array\.isArray\(channel\?\.members\) \? channel\.members : \[\]/, "human suggestions come only from current-channel members"); assert.match(app, /channel\?\.kind === "channel" \? \[resident, skipper\] : \[\]/, "agent channels suggest their resident before Skipper"); @@ -56,6 +60,8 @@ test("profile, naming, routing, and usage language match the visible product con test("service-worker updates never reload an active editor or conversation", () => { assert.doesNotMatch(app, /controllerchange[\s\S]{0,300}location\.reload\(/, "service-worker takeover must not reload a live draft"); assert.match(app, /destroy an editor draft/, "the no-reload update contract is documented at the decision point"); + assert.match(serviceWorker, /new Response\("1Helm is offline\.", \{ status: 503/, "offline navigation always resolves to a valid Response"); + assert.match(serviceWorker, /catch\(\(\) => cached \|\| Response\.error\(\)\)/, "an uncached failed asset request never resolves FetchEvent with undefined"); }); test("Texts routes fail closed outside the Captain's private #main", () => { From 58af87e83b781e82a54f22204f4875a938f0884b Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 19:58:40 +0000 Subject: [PATCH 3/7] Keep presentation menus visible and interactive --- CHANGELOG.md | 11 ++++++----- public/index.html | 2 +- src/client/styles.css | 17 +++++++++-------- test/channel-surfaces.mjs | 2 +- test/cowork-browser.mjs | 18 +++++++++++++++--- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 804f400..218c3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cowork new-file prompts now start blank. Names with an explicit extension are preserved, while extensionless names receive the active section's `.md`, `.whiteboard.json`, `.txt`, or `.slides.json` default. -- Presentation canvases anchor at the top of their scrollable stage so the - Excalidraw hamburger menu remains fully reachable, including when the canvas - is taller than the available area. +- Presentation canvases anchor at the top of their scrollable stage, and the + Excalidraw hamburger menu escapes the editor's inner clip into a bounded, + scrollable overlay so its complete menu remains visible and interactive. - Cowork Code uses the normal light surface with readable dark text in light mode while retaining its navy editor treatment in dark mode. - Offline shell fallbacks always resolve service-worker requests with a valid @@ -55,8 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Files reads stay responsive, simultaneous refreshes coalesce, and guest-only files appear after the background refresh. - Browser and server coverage verify blank Cowork naming, default and explicit - extensions, presentation menu reachability, light Code contrast, Files - grouping, real DOCX ZIP contents, and persistent desktop-only sidebar collapse. + extensions, presentation-menu stage bounds and pointer hit-testing, light + Code contrast, Files grouping, real DOCX ZIP contents, and persistent + desktop-only sidebar collapse. ## [0.0.20] - 2026-07-27 diff --git a/public/index.html b/public/index.html index 1748433..c1cfb89 100644 --- a/public/index.html +++ b/public/index.html @@ -30,7 +30,7 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - + diff --git a/src/client/styles.css b/src/client/styles.css index c97fea9..eed68f3 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -927,14 +927,15 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .cowork-slide-action { min-width: 1.4rem; border-radius: .25rem; color: var(--c-muted); } .cowork-slide-action:hover:not(:disabled) { background: var(--c-hover); color: var(--c-fg); } .cowork-slide-action:disabled { opacity: .3; } -.cowork-slide-stage { display: grid; min-width: 0; flex: 1 1 auto; place-items: start center; overflow: auto; padding: 1.5rem; scroll-padding-top: 1.5rem; background: color-mix(in srgb, var(--c-raised) 70%, var(--c-bg)); } -.cowork-slide-canvas { aspect-ratio: 16 / 9; width: min(55rem, 96%); flex: 0 0 auto; border: 1px solid var(--c-line); box-shadow: 0 22px 70px -40px #000; } -/* Excalidraw flips its full-height menu above the trigger when a 16:9 slide - * cannot fit the whole list below. That puts the first actions outside the - * clipped canvas. Keep the menu below its trigger and scroll its bounded body. */ -.cowork-slide-canvas .dropdown-menu { top: 100% !important; bottom: auto !important; transform: none !important; } -.cowork-slide-canvas .dropdown-menu { height: min(15rem, calc(100% - 4.75rem)); overflow: hidden; } -.cowork-slide-canvas .dropdown-menu .dropdown-menu-container { max-height: 100%; overflow-y: auto; overscroll-behavior: contain; } +.cowork-slide-stage { container-type: size; position: relative; display: grid; min-width: 0; flex: 1 1 auto; place-items: start center; overflow: auto; padding: 1.5rem; scroll-padding-top: 1.5rem; background: color-mix(in srgb, var(--c-raised) 70%, var(--c-bg)); } +.cowork-slide-canvas { z-index: 1; aspect-ratio: 16 / 9; width: min(55rem, 96%); flex: 0 0 auto; overflow: visible; border: 1px solid var(--c-line); box-shadow: 0 22px 70px -40px #000; } +.cowork-slide-canvas > .excalidraw { overflow: visible; } +/* Excalidraw flips its long main menu above a short 16:9 slide and the shared + * canvas clip then hides it. Presentations deliberately let this one overlay + * escape the slide, anchor it below the top controls, and bound its own scroll + * to the visible presentation stage. */ +.cowork-slide-canvas .dropdown-menu { top: 3.25rem !important; bottom: auto !important; height: auto; overflow: visible; transform: none !important; } +.cowork-slide-canvas .dropdown-menu .dropdown-menu-container { max-height: min(25rem, max(8rem, calc(100cqh - 11rem))) !important; overflow-y: auto; overscroll-behavior: contain; box-shadow: 0 16px 40px -18px #000; } .cowork-presentation-view { width: 100%; height: 100%; } .cowork-agent-toggle { position: absolute; right: 1rem; bottom: 1rem; z-index: 5; display: grid; width: 3rem; height: 3rem; place-items: center; border: 3px solid var(--c-surface); border-radius: 999px; background: var(--c-surface); box-shadow: 0 10px 30px -15px #000, 0 0 0 1px var(--c-line); transition: transform .15s ease, box-shadow .15s ease; } .cowork-agent-toggle:hover { transform: translateY(-2px); box-shadow: 0 16px 34px -15px #000, 0 0 0 2px var(--c-accent-soft); } diff --git a/test/channel-surfaces.mjs b/test/channel-surfaces.mjs index 6da4f5a..344137f 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -201,7 +201,7 @@ test("channel UI source exposes file-backed Cowork, traditional Files, audio pre assert.match(stylesSource, /\.cowork-notes-edit-stage \.cm-scroller \{ overflow-y: auto; \}/, "long Cowork Notes edit sessions scroll in CodeMirror's real viewport"); assert.match(stylesSource, /\.cowork-codemirror-code \{ background: var\(--c-surface\); \}[\s\S]*\.dark \.cowork-codemirror-code/, "Code uses a legible light surface without changing its dark treatment"); assert.match(stylesSource, /\.cowork-slide-stage[^}]*place-items: start center/, "oversized presentation canvases remain reachable from their top edge"); - assert.match(stylesSource, /\.cowork-slide-canvas \.dropdown-menu[^}]*top: 100% !important[\s\S]*height: min\(15rem[\s\S]*max-height: 100%/, "presentation menus stay below their trigger with a bounded scrollable body"); + assert.match(stylesSource, /\.cowork-slide-stage \{ container-type: size[\s\S]*\.cowork-slide-canvas[^}]*overflow: visible[\s\S]*\.cowork-slide-canvas > \.excalidraw \{ overflow: visible[\s\S]*\.cowork-slide-canvas \.dropdown-menu[^}]*top: 3\.25rem !important[\s\S]*max-height: min\(25rem, max\(8rem, calc\(100cqh - 11rem\)\)\) !important/, "presentation menus escape both Excalidraw clips and stay bounded to the visible stage"); assert.match(channelSource, /files\/refresh[\s\S]*void refreshMirror\(\)/, "Files paints the host cache first and refreshes the VM mirror independently"); assert.match(serverSource, /channelFilesRefresh[\s\S]*refreshChannelWorkspaceMirror\(channelId\)/, "Files has one explicit coalesced VM refresh route instead of syncing every click"); assert.match(coworkSource, /\.\.\.\(coworkContextPending \? \{ coworkPath: session\.path \} : \{\}\)/, "a Cowork panel's first message submits its open path for authenticated enrichment"); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index af471c9..e9ef609 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -418,10 +418,22 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil const menuBox = menu.getBoundingClientRect(); const canvasBox = menu.closest('.cowork-slide-canvas').getBoundingClientRect(); const stageBox = menu.closest('.cowork-slide-stage').getBoundingClientRect(); - return { menuTop: menuBox.top, menuBottom: menuBox.bottom, canvasTop: canvasBox.top, canvasBottom: canvasBox.bottom, stageTop: stageBox.top, viewportTop: 0 }; + const container = menu.querySelector('.dropdown-menu-container'); + const firstAction = menu.querySelector('.dropdown-menu-item'); + const actionBox = firstAction?.getBoundingClientRect(); + const visibleAtAction = actionBox ? document.elementFromPoint(actionBox.left + Math.min(16, actionBox.width / 2), actionBox.top + actionBox.height / 2) : null; + return { + menuTop: menuBox.top, menuBottom: menuBox.bottom, menuHeight: menuBox.height, + canvasTop: canvasBox.top, canvasBottom: canvasBox.bottom, stageTop: stageBox.top, stageBottom: stageBox.bottom, + clientHeight: container?.clientHeight || 0, scrollHeight: container?.scrollHeight || 0, + firstActionVisible: Boolean(firstAction && visibleAtAction && (firstAction === visibleAtAction || firstAction.contains(visibleAtAction))), + firstAction: actionBox ? { text: firstAction.textContent?.trim(), top: actionBox.top, bottom: actionBox.bottom, left: actionBox.left, right: actionBox.right } : null, + hit: visibleAtAction ? { tag: visibleAtAction.tagName, className: String(visibleAtAction.className), text: visibleAtAction.textContent?.trim()?.slice(0, 80) } : null, + }; }); - assert.ok(menuGeometry.menuTop >= Math.max(menuGeometry.canvasTop, menuGeometry.stageTop, menuGeometry.viewportTop) - 1, JSON.stringify(menuGeometry)); - assert.ok(menuGeometry.menuBottom <= menuGeometry.canvasBottom + 1, JSON.stringify(menuGeometry)); + assert.ok(menuGeometry.menuTop >= menuGeometry.stageTop - 1 && menuGeometry.menuBottom <= menuGeometry.stageBottom + 1, JSON.stringify(menuGeometry)); + assert.ok(menuGeometry.menuHeight > 100 && menuGeometry.clientHeight > 100 && menuGeometry.scrollHeight >= menuGeometry.clientHeight && menuGeometry.firstActionVisible, JSON.stringify(menuGeometry)); + await page.screenshot({ path: "/tmp/1helm-presentation-menu.png" }); await page.click('.cowork-slide-canvas .main-menu-trigger'); await collaboratorPage.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Presentations")?.click()); await collaboratorPage.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("review.slides.json")); From 5a9145cec626bab8fe4b3c2d1814540ca63e42ca Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 20:36:38 +0000 Subject: [PATCH 4/7] Require synchronized desktop releases --- CHANGELOG.md | 6 +++ README.md | 5 +++ docs/GOVERNANCE.md | 18 +++++++-- docs/VISION.md | 12 +++--- docs/release-checklist.md | 70 ++++++++++++++++++++++++++++------ docs/release-lifecycle.md | 55 +++++++++++++++++++++----- docs/release-notes-template.md | 15 +++++++- test/release-governance.mjs | 21 ++++++++++ 8 files changed, 170 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 218c3bd..9e00810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Desktop release policy now fail-closes on platform parity: one version and + exact source commit must produce verified macOS DMG/updater, Linux host, and + signed Windows Setup/Squirrel artifacts before any tag or GitHub Release can + be published. A missing platform pauses the whole release instead of leaving + users on a stale update line. + - Cowork new-file prompts now start blank. Names with an explicit extension are preserved, while extensionless names receive the active section's `.md`, `.whiteboard.json`, `.txt`, or `.slides.json` default. diff --git a/README.md b/README.md index ef74b25..29df2f4 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,11 @@ digest-verified, health-checked updater — see the it works best on a dedicated machine: your crew works around the clock, and your everyday computer takes naps. +Mac, Linux, and Windows use one synchronized desktop release version. A release +is held in full until the DMG/updater ZIP, Linux host archive, and signed +Windows Setup/Squirrel feed have all passed native install and update +acceptance from the same source commit. + ### Connect from iPhone, iPad, or Android The mobile apps are thin, native gateways to a 1Helm you already run. Install diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 06f12b4..9e2a910 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -57,15 +57,20 @@ contract as the slice hardens. 8. A multi-item user request retains a numbered acceptance ledger in the pull request and GitHub Release. Do not collapse completed items into a generic summary or rely on generated commit notes as the user-facing release record. -9. The retained Apple Silicon release host owns both artifact production and - public-download installed-app verification. +9. Each supported desktop platform owns its native artifact and installed-app + verification lane. The retained Apple Silicon host owns macOS signing and + notarization; Linux owns the systemd/LXC artifact and updater acceptance; + Windows 11 x64 owns Authenticode, Squirrel, and WSL acceptance. ## Versioning - Semantic versioning on `package.json`. - **Do not** reuse a published version tag for different bits. -- A release requires a unique version, changelog, exact tag, verified public - artifact, and platform-appropriate clean installation evidence. +- A desktop release requires one unique version and exact commit, changelog, + complete Mac + Linux + Windows artifact matrix, and clean-install plus + prior-to-new update evidence on every platform. Partial platform releases + under the shared product version are prohibited. If one platform is blocked, + the entire tag/publication waits. - GitHub Release notes are a first-class product artifact. They must enumerate every user-visible fix and feature accepted for that release, using the same numbered ledger as the originating request when one exists. A short summary @@ -73,6 +78,11 @@ contract as the slice hardens. - macOS verification must use the exact publicly downloaded artifact, preserve Application Support, and prove signature/ticket/Gatekeeper, launch, version, loopback behavior, and retained state on the retained release host. +- Linux verification must use the digest-qualified release archive and prove a + real systemd update, health-failure rollback, and retained `/var/lib/1helm`. +- Windows verification must use Authenticode-signed Setup/Squirrel artifacts + and prove clean install, old-to-new update, loopback health, WSL lifecycle, + and retained application data on Windows 11 x64. Never hand-edit only a deployment target to fix the product. Fix in git, review, merge, and redeploy the exact source commit. diff --git a/docs/VISION.md b/docs/VISION.md index f68fab5..b9d155c 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -79,14 +79,12 @@ verification. - `https://1helm.com` is the standalone product and documentation site. - `https://demo.1helm.com` is a separate public sandbox, not the product site and not a dependency of an installed workspace. -- Apple Silicon macOS and Linux systemd are public host products with one - isolated Linux computer per ordinary resident. -- The native Windows x64 + WSL implementation and real-host lifecycle are - accepted, but its public installer is withheld until an Authenticode signing - identity is available. +- Apple Silicon macOS, Linux systemd, and native Windows x64 + WSL are the + synchronized public desktop-host product. Every named desktop release must + publish all three from one version and exact source commit. - 1Helm is self-hosted and open source. A hosted control plane, mobile clients, - blind community-skill execution, a signed public Windows installer, and a - native Linux desktop shell are not shipped. + blind community-skill execution, and a native Linux desktop shell are not + shipped. ## What 1Helm borrows—and what it does not diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 810da72..faaa280 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -6,7 +6,11 @@ Process: [release-lifecycle.md](./release-lifecycle.md). Policy: [GOVERNANCE.md] Docs-only governance can merge without a version bump; still update `CHANGELOG.md` Unreleased when operators should notice. -**Do not say “done” for a ship until applicable checks pass.** +**Hard gate:** every named desktop release must ship macOS, Linux, and Windows +from the same version and exact source commit. There is no Mac-only fast path. +Do not create the tag or GitHub Release, publish any platform, mark anything +latest, or say “done” until all three lanes pass. If one lane is blocked, pause +the whole release and report it. ## 1. Prepare @@ -63,21 +67,49 @@ MERGED_COMMIT="$(git rev-parse origin/main)" VERSION="$(node -p "require('./package.json').version")" ``` -## 5. Tag (optional but preferred for named cuts) +## 5. Build the complete desktop matrix before tagging ```bash -git tag -a "v${VERSION}" "$MERGED_COMMIT" -m "1Helm ${VERSION}" -git push origin "refs/tags/v${VERSION}" HEADLESS="dist/1Helm-${VERSION}-linux-node.tgz" DMG="dist/1Helm-${VERSION}-arm64.dmg" UPDATE_ZIP="dist/1Helm-${VERSION}-mac-arm64.zip" +WINDOWS_SETUP="dist/1Helm-${VERSION}-windows-x64-setup.exe" +WINDOWS_NUPKG="dist/1Helm-${VERSION}-full.nupkg" +WINDOWS_RELEASES="dist/RELEASES" ANDROID_APK="dist/1Helm-${VERSION}-universal.apk" RELEASE_NOTES="dist/1Helm-${VERSION}-release-notes.md" + +# Build from clean snapshots of the same MERGED_COMMIT on the platform owners: +# macOS arm64: npm ci && npm run typecheck && npm run build && npm test && npm run package:dmg:release +# Linux: npm ci && npm run typecheck && npm run build && npm test && npm run package:linux +# Windows x64: npm ci && npm run typecheck && npm run build && npm test && npm run package:windows:release + +for artifact in "$DMG" "$UPDATE_ZIP" "$HEADLESS" "$WINDOWS_SETUP" "$WINDOWS_NUPKG" "$WINDOWS_RELEASES"; do + test -s "$artifact" +done # Author RELEASE_NOTES from docs/release-notes-template.md. It must contain the -# complete numbered acceptance ledger, artifact digests, and verification. +# complete numbered acceptance ledger, every desktop artifact digest, and all +# three platform verification records. test -s "$RELEASE_NOTES" rg -q '^1\. ' "$RELEASE_NOTES" # multi-item ships must retain a numbered ledger -gh release create "v${VERSION}" "$DMG" "$UPDATE_ZIP" "$HEADLESS" "$ANDROID_APK" --title "1Helm ${VERSION}" --notes-file "$RELEASE_NOTES" --draft +``` + +The Windows `.nupkg` basename must exactly match the entry inside `RELEASES`. +All Windows executable code and Setup must pass Authenticode verification. Do +not use unsigned output from `npm run package:windows`; public candidates must +come from the fail-closed `package:windows:release` command on Windows x64. + +Only after Sections 6–8 pass for all three desktop platforms: + +```bash +git tag -a "v${VERSION}" "$MERGED_COMMIT" -m "1Helm ${VERSION}" +git push origin "refs/tags/v${VERSION}" +gh release create "v${VERSION}" \ + "$DMG" "$UPDATE_ZIP" "$HEADLESS" \ + "$WINDOWS_SETUP" "$WINDOWS_NUPKG" "$WINDOWS_RELEASES" \ + --title "1Helm ${VERSION}" --notes-file "$RELEASE_NOTES" --draft +# Upload mobile artifacts through their applicable distribution lane; their +# timing never permits a partial desktop release. # review notes, then: gh release edit "v${VERSION}" --draft=false ``` @@ -117,16 +149,30 @@ curl -fsS "http://127.0.0.1:18123/api/setup/status" Expect first-run / needs_setup on empty data dir. -## 7. Host updater acceptance +## 7. Host updater acceptance — all three lanes are required -- Verify the native ZIP was created only after the app was notarized and stapled; extract it and repeat strict signature, ticket, and Gatekeeper checks. -- Confirm the public Electron feed selects that ZIP for the prior Mac version and returns no update for the new version. +- **macOS:** verify the native ZIP was created only after the app was notarized + and stapled; extract it and repeat strict signature, ticket, and Gatekeeper + checks. Confirm the staged/public Electron feed selects that ZIP for the + prior Mac version and no update for the new version. - On the same retained Apple Silicon release host used for the clean build, install the **publicly downloaded** DMG/update with preserved Application Support, then verify the new version, loopback health, resident state, and data-directory identity. -- Upload the exact `npm run package:linux` artifact and require GitHub's recorded `sha256:` digest before publication. -- In a disposable systemd host running the prior release, invoke the same Captain host-update action, observe checking/downloading/installing/restarting, verify the new version and `/var/lib/1helm` identity, and exercise rollback with an intentionally unhealthy disposable fixture. +- **Linux:** verify the exact `npm run package:linux` archive, its source commit + and SHA-256, then stage equivalent release metadata. In a disposable systemd + host running the prior release, invoke the Captain host-update action, + observe checking/downloading/installing/restarting, verify the new version + and `/var/lib/1helm` identity, and exercise health-failure rollback. +- **Windows:** on Windows 11 x64, verify Authenticode on Setup, the packaged app, + and its executable code; confirm `.nupkg` and `RELEASES` consistency; clean + install Setup; exercise the real WSL 2 channel lifecycle; then expose staged + Squirrel metadata to the prior public version and prove download, + verification, restart installation, new version, loopback health, WSL state, + and app-data preservation. +- Before publication, compare each uploaded GitHub asset digest with the local + verified digest and assert the release contains the complete six-file + desktop matrix. A missing asset is a release blocker, not “not applicable.” ## 8. Clean deployment verify (when shipping install path) @@ -147,6 +193,8 @@ Local setup: needs_setup verified on clean CTRL_DATA_DIR Clean deploy: Mac host update: Linux update: +Windows update: +Desktop matrix: Android: iOS: CI: Actions green on main diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index fb6c7c5..8b4df6a 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -2,6 +2,27 @@ Process contract from intent to verified deploy. Commands: [release-checklist.md](./release-checklist.md). +## Immutable desktop release rule + +1Helm has one synchronized desktop-host release train. A named desktop release +is one version, one exact source commit, and one GitHub Release containing all +of the following: + +- Apple Silicon macOS DMG and native updater ZIP; +- Linux host archive; +- Windows x64 Setup executable, full Squirrel package, and `RELEASES` manifest. + +All three platform lanes are mandatory even when a change appears +platform-specific, because the application source and updater version advance +together. Do not tag, create or publish a GitHub Release, mark it latest, or +update public download/feed metadata until every lane is built, signed where +required, digest-verified, installed, and update-tested from the previous +release. If any lane is unavailable or fails, pause the whole release. Never +publish a Mac-only or otherwise partial set under the product version. + +Mobile distribution may have additional store/signing timing, but it never +weakens the Mac + Linux + Windows desktop invariant. + ```text intent / issue │ @@ -21,8 +42,17 @@ Process contract from intent to verified deploy. Commands: [release-checklist.md version bump on the release branch │ v - full numbered notes · tag · signed DMG + updater ZIP + Linux host artifact - + signed universal APK + App Store mobile archive + exact-commit candidates on every desktop lane + │ + v + Mac DMG + ZIP · Linux host archive · Windows Setup + nupkg + RELEASES + │ + v + clean install + prior→new updater acceptance on Mac, Linux, and Windows + │ + v + full numbered notes · tag · one complete GitHub Release + + mobile artifacts when applicable │ v verify (local health + clean install + public artifact) @@ -86,9 +116,14 @@ Draft PRs are allowed for long slices; mark ready only when the quality bar is m user-visible item must appear once, with the same numbering as the request when available. Include additional fixes, artifacts/digests, and verification evidence in their own sections. -6. Publish the verified DMG, native updater ZIP, Linux host artifact, any - directly distributed signed Android APK, and those complete release notes - through one GitHub Release. Submit an iOS build through App Store Connect +6. Before creating the tag or GitHub Release, finish the complete desktop + matrix from the exact merged commit: verified macOS DMG + updater ZIP, + Linux host archive, and Authenticode-signed Windows Setup + full `.nupkg` + + literal `RELEASES` manifest. +7. Publish those desktop artifacts and complete release notes together through + one GitHub Release. Never publish a subset or attach a platform later to a + version already described as complete. Include a directly distributed + signed Android APK when applicable. Submit iOS through App Store Connect rather than publishing an installable IPA as a generic download. Do not use GitHub's generated notes as the sole or primary body. @@ -116,8 +151,10 @@ workspace state. | Code landed | On `origin/main`, CI green | | Behavior fixed | Tests + manual/API check | | Install path still works | Clean `CTRL_DATA_DIR` boot through the wizard plus platform acceptance | -| Named release | Version, changelog, full numbered notes, exact tag, verified public artifact, and clean installation | -| Native host update | Published updater ZIP feed, installed-old-to-new Mac acceptance, and preserved Application Support | -| Linux host update | Digest-qualified artifact, real systemd old-to-new update, health check, and preserved `/var/lib/1helm` | +| Named desktop release | One version/commit, changelog, full numbered notes, exact tag, complete Mac + Linux + Windows asset matrix, and clean installation evidence for all three | +| Mac host update | Published notarized/stapled updater ZIP feed, installed-old-to-new acceptance, and preserved Application Support | +| Linux host update | Digest-qualified artifact, real systemd old-to-new update, health check/rollback, and preserved `/var/lib/1helm` | +| Windows host update | Authenticode-signed Setup + `.nupkg` + `RELEASES`, real Squirrel old-to-new update, WSL lifecycle smoke, and preserved app data | -If deploy verification was skipped, say so. Do not call a ship “done.” +If any platform artifact or acceptance run is skipped, the release is paused, +not partially shipped. Say exactly what is missing and do not call it “done.” diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index 0376ec0..f425da8 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -22,7 +22,15 @@ so plainly instead of silently omitting it. | Artifact | SHA-256 | | --- | --- | | `1Helm-x.y.z-arm64.dmg` | `` | -| Other published artifact, or `Not applicable` | `` | +| `1Helm-x.y.z-mac-arm64.zip` | `` | +| `1Helm-x.y.z-linux-node.tgz` | `` | +| `1Helm-x.y.z-windows-x64-setup.exe` | `` | +| `1Helm-x.y.z-full.nupkg` | `` | +| `RELEASES` | `` | + +Every desktop row is mandatory and must resolve to the same version and source +commit. “Not applicable” is forbidden for macOS, Linux, or Windows. If any row +is unavailable, this release must remain unpublished. Source commit: `` @@ -33,6 +41,11 @@ Source commit: `` - For macOS, state Developer ID signature, Apple notarization, stapling, Gatekeeper, public-download installation on the retained release host, app launch/smoke behavior, and Application Support preservation. +- For Linux, state archive/source/digest verification and the real prior-version + systemd update, health-check rollback, and `/var/lib/1helm` preservation. +- For Windows, state Authenticode verification, Setup clean install, Squirrel + prior-version update, WSL lifecycle smoke, loopback health, and app-data + preservation. - Name anything skipped or incomplete; do not call an incomplete release fully verified. diff --git a/test/release-governance.mjs b/test/release-governance.mjs index 15d6487..d4d4eed 100644 --- a/test/release-governance.mjs +++ b/test/release-governance.mjs @@ -36,3 +36,24 @@ test("one retained Mac Studio owns the complete macOS release gate", () => { assert.match(tracked, /publicly downloaded/i); assert.match(tracked, /Application Support preservation/i); }); + +test("desktop releases fail closed unless Mac, Linux, and Windows ship together", () => { + const checklist = read("docs/release-checklist.md"); + const lifecycle = read("docs/release-lifecycle.md"); + const governance = read("docs/GOVERNANCE.md"); + const notes = read("docs/release-notes-template.md"); + const vision = read("docs/VISION.md"); + + for (const source of [checklist, lifecycle, governance, notes, vision]) { + assert.match(source, /Mac(?:OS)?[^\n]+Linux[^\n]+Windows|macOS[^\n]+Linux[^\n]+Windows/i); + } + for (const artifact of [ + "arm64.dmg", "mac-arm64.zip", "linux-node.tgz", + "windows-x64-setup.exe", "full.nupkg", "RELEASES", + ]) assert.match(checklist + notes, new RegExp(artifact.replaceAll(".", "\\."), "i")); + assert.match(checklist + lifecycle + governance, /pause (?:the )?(?:whole|entire) release/i); + assert.match(checklist, /package:windows:release/); + assert.match(checklist, /Authenticode/i); + assert.match(checklist, /Squirrel/i); + assert.doesNotMatch(notes, /Other published artifact, or `Not applicable`/i); +}); From 6eab721eb11a4ace3d28b39dd4ebcc505b71422f Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 22:50:49 +0000 Subject: [PATCH 5/7] Make Windows Squirrel packaging path-safe --- scripts/package-windows.cjs | 11 +++++++++-- test/desktop.mjs | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index 2d0de9b..4417174 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -17,6 +17,10 @@ const CERT_SHA1 = String(process.env.WINDOWS_SIGN_CERT_SHA1 || "").replace(/\s+/ // scripts directory itself traversable, then retain only the three runtime // files below; otherwise the exact-file exceptions can never be reached. const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/)|deploy(?:$|\/)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; +// Excalidraw is compiled into public/bundle.js. Shipping its source package as +// well adds deeply nested Radix paths that legacy Squirrel/NuGet cannot +// releasify under Windows' 260-character path limit. +const IGNORE_CLIENT_BUILD_MODULES = /^\/node_modules\/@excalidraw(?:$|\/)/; if (process.platform !== "win32" || process.arch !== "x64") throw new Error("Windows packaging must run on Windows x64."); if (!/^\d+\.\d+\.\d+$/.test(VERSION)) throw new Error("package.json must contain a release version."); @@ -73,7 +77,7 @@ async function main() { dir: ROOT, name: PRODUCT, executableName: PRODUCT, appCopyright: "Copyright (c) 2026 Joseph Yaksich", win32metadata: { CompanyName: "Joseph Yaksich", FileDescription: PRODUCT, OriginalFilename: "1Helm.exe", ProductName: PRODUCT, InternalName: PRODUCT }, platform: "win32", arch: "x64", out: DIST, overwrite: true, prune: true, asar: false, icon: ico, - ignore: [IGNORE_NON_RUNTIME_ROOTS, /\.DS_Store$/, /\.log$/], + ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, /\.DS_Store$/, /\.log$/], }); const appExe = path.join(appDir, "1Helm.exe"); if (!fs.existsSync(appExe)) throw new Error("Packaged Windows application is missing 1Helm.exe."); @@ -89,7 +93,10 @@ async function main() { if (!fs.existsSync(script)) throw new Error("Packaged Windows app is missing its WSL setup script."); } - const installerDir = path.join(DIST, `windows-installer-${VERSION}`); + // Squirrel 1.x uses legacy .NET path handling while it expands the NuGet + // package. Keep its private scratch root deliberately short; only the + // canonical artifacts copied into DIST below are retained. + const installerDir = path.join(DIST, "w"); fs.rmSync(installerDir, { recursive: true, force: true }); const { createWindowsInstaller } = require("electron-winstaller"); await createWindowsInstaller({ diff --git a/test/desktop.mjs b/test/desktop.mjs index 8d671d2..42e1317 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -119,6 +119,8 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsPackager, /path\.join\(DIST, "RELEASES"\)/, "the GitHub asset keeps Squirrel's required literal RELEASES name"); assert.match(windowsPackager, /path\.join\(DIST, path\.basename\(nupkg\)\)/, "the uploaded package keeps the exact basename referenced by RELEASES"); assert.match(windowsPackager, /signPackagedExecutables\(appDir\)/, "release signing covers nested Windows executables before packaging"); + assert.match(windowsPackager, /IGNORE_CLIENT_BUILD_MODULES/, "Windows packaging omits client source already compiled into the browser bundle"); + assert.match(windowsPackager, /path\.join\(DIST, "w"\)/, "Squirrel uses a short private scratch root compatible with legacy Windows path handling"); for (const [source, requiredPaths] of [ [windowsPackager, ["/scripts", "/scripts/mnemosyne-bridge.py", "/scripts/install-wsl-runtime.ps1", "/scripts/windows-removal.cjs"]], [macPackager, ["/scripts", "/scripts/mnemosyne-bridge.py"]], From 0342cfa7781efae40e9eda7bef2f394de4d7b1ff Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 23:26:57 +0000 Subject: [PATCH 6/7] Document accepted unsigned Windows releases --- docs/GOVERNANCE.md | 13 +++++++++---- docs/release-checklist.md | 19 +++++++++++-------- docs/release-lifecycle.md | 7 ++++--- docs/release-notes-template.md | 2 +- test/release-governance.mjs | 2 ++ 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 9e2a910..46a2ec5 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -60,7 +60,10 @@ contract as the slice hardens. 9. Each supported desktop platform owns its native artifact and installed-app verification lane. The retained Apple Silicon host owns macOS signing and notarization; Linux owns the systemd/LXC artifact and updater acceptance; - Windows 11 x64 owns Authenticode, Squirrel, and WSL acceptance. + Windows 11 x64 owns Squirrel and WSL acceptance. Authenticode is optional + until 1Helm adopts a trusted Windows signing identity; unsigned artifacts + must be identified honestly, but their signature status is not a release + blocker. ## Versioning @@ -80,9 +83,11 @@ contract as the slice hardens. loopback behavior, and retained state on the retained release host. - Linux verification must use the digest-qualified release archive and prove a real systemd update, health-failure rollback, and retained `/var/lib/1helm`. -- Windows verification must use Authenticode-signed Setup/Squirrel artifacts - and prove clean install, old-to-new update, loopback health, WSL lifecycle, - and retained application data on Windows 11 x64. +- Windows verification must prove the Setup/Squirrel signature status, clean + install, old-to-new update, loopback health, WSL lifecycle, and retained + application data on Windows 11 x64. Do not substitute a self-signed + certificate or block an otherwise accepted release solely because the + artifacts are honestly disclosed as unsigned. Never hand-edit only a deployment target to fix the product. Fix in git, review, merge, and redeploy the exact source commit. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index faaa280..cda040c 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -82,7 +82,7 @@ RELEASE_NOTES="dist/1Helm-${VERSION}-release-notes.md" # Build from clean snapshots of the same MERGED_COMMIT on the platform owners: # macOS arm64: npm ci && npm run typecheck && npm run build && npm test && npm run package:dmg:release # Linux: npm ci && npm run typecheck && npm run build && npm test && npm run package:linux -# Windows x64: npm ci && npm run typecheck && npm run build && npm test && npm run package:windows:release +# Windows x64: npm ci && npm run typecheck && npm run build && npm test && npm run package:windows for artifact in "$DMG" "$UPDATE_ZIP" "$HEADLESS" "$WINDOWS_SETUP" "$WINDOWS_NUPKG" "$WINDOWS_RELEASES"; do test -s "$artifact" @@ -95,9 +95,11 @@ rg -q '^1\. ' "$RELEASE_NOTES" # multi-item ships must retain a numbered ledger ``` The Windows `.nupkg` basename must exactly match the entry inside `RELEASES`. -All Windows executable code and Setup must pass Authenticode verification. Do -not use unsigned output from `npm run package:windows`; public candidates must -come from the fail-closed `package:windows:release` command on Windows x64. +Record Authenticode status for Windows executable code and Setup. Trusted +signing is optional until 1Helm adopts a Windows signing identity: an honestly +disclosed `NotSigned` result is accepted and must not block the release. Never +substitute a self-signed identity. When a trusted identity is configured, use +the fail-closed `package:windows:release` command. Only after Sections 6–8 pass for all three desktop platforms: @@ -164,9 +166,10 @@ Expect first-run / needs_setup on empty data dir. host running the prior release, invoke the Captain host-update action, observe checking/downloading/installing/restarting, verify the new version and `/var/lib/1helm` identity, and exercise health-failure rollback. -- **Windows:** on Windows 11 x64, verify Authenticode on Setup, the packaged app, - and its executable code; confirm `.nupkg` and `RELEASES` consistency; clean - install Setup; exercise the real WSL 2 channel lifecycle; then expose staged +- **Windows:** on Windows 11 x64, record Authenticode status for Setup, the + packaged app, and its executable code; confirm `.nupkg` and `RELEASES` + consistency; clean install Setup; exercise the real WSL 2 channel lifecycle; + then expose staged Squirrel metadata to the prior public version and prove download, verification, restart installation, new version, loopback health, WSL state, and app-data preservation. @@ -193,7 +196,7 @@ Local setup: needs_setup verified on clean CTRL_DATA_DIR Clean deploy: Mac host update: Linux update: -Windows update: +Windows update: Desktop matrix: Android: iOS: diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index 8b4df6a..1b7bea2 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -118,8 +118,9 @@ Draft PRs are allowed for long slices; mark ready only when the quality bar is m evidence in their own sections. 6. Before creating the tag or GitHub Release, finish the complete desktop matrix from the exact merged commit: verified macOS DMG + updater ZIP, - Linux host archive, and Authenticode-signed Windows Setup + full `.nupkg` + - literal `RELEASES` manifest. + Linux host archive, and Windows Setup + full `.nupkg` + literal `RELEASES` + manifest. Record whether Windows artifacts are trusted-signed or unsigned; + unsigned is accepted until 1Helm adopts a trusted Windows signing identity. 7. Publish those desktop artifacts and complete release notes together through one GitHub Release. Never publish a subset or attach a platform later to a version already described as complete. Include a directly distributed @@ -154,7 +155,7 @@ workspace state. | Named desktop release | One version/commit, changelog, full numbered notes, exact tag, complete Mac + Linux + Windows asset matrix, and clean installation evidence for all three | | Mac host update | Published notarized/stapled updater ZIP feed, installed-old-to-new acceptance, and preserved Application Support | | Linux host update | Digest-qualified artifact, real systemd old-to-new update, health check/rollback, and preserved `/var/lib/1helm` | -| Windows host update | Authenticode-signed Setup + `.nupkg` + `RELEASES`, real Squirrel old-to-new update, WSL lifecycle smoke, and preserved app data | +| Windows host update | Setup + `.nupkg` + `RELEASES` with disclosed signature status, real Squirrel old-to-new update, WSL lifecycle smoke, and preserved app data | If any platform artifact or acceptance run is skipped, the release is paused, not partially shipped. Say exactly what is missing and do not call it “done.” diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index f425da8..7889cce 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -43,7 +43,7 @@ Source commit: `` launch/smoke behavior, and Application Support preservation. - For Linux, state archive/source/digest verification and the real prior-version systemd update, health-check rollback, and `/var/lib/1helm` preservation. -- For Windows, state Authenticode verification, Setup clean install, Squirrel +- For Windows, state Authenticode status, Setup clean install, Squirrel prior-version update, WSL lifecycle smoke, loopback health, and app-data preservation. - Name anything skipped or incomplete; do not call an incomplete release fully diff --git a/test/release-governance.mjs b/test/release-governance.mjs index d4d4eed..f3f7c30 100644 --- a/test/release-governance.mjs +++ b/test/release-governance.mjs @@ -54,6 +54,8 @@ test("desktop releases fail closed unless Mac, Linux, and Windows ship together" assert.match(checklist + lifecycle + governance, /pause (?:the )?(?:whole|entire) release/i); assert.match(checklist, /package:windows:release/); assert.match(checklist, /Authenticode/i); + assert.match(checklist + lifecycle + governance, /NotSigned[\s\S]+must not block|unsigned is accepted/i); + assert.match(checklist + governance, /Never[\s\S]+self-signed|Never[\s\S]+self-sign/i); assert.match(checklist, /Squirrel/i); assert.doesNotMatch(notes, /Other published artifact, or `Not applicable`/i); }); From 71683518312f78484b89b9f06c79703983935dcc Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 23:28:29 +0000 Subject: [PATCH 7/7] Record unsigned Windows release policy --- CHANGELOG.md | 7 ++++--- README.md | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e00810..ac11693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Desktop release policy now fail-closes on platform parity: one version and exact source commit must produce verified macOS DMG/updater, Linux host, and - signed Windows Setup/Squirrel artifacts before any tag or GitHub Release can - be published. A missing platform pauses the whole release instead of leaving - users on a stale update line. + Windows Setup/Squirrel artifacts before any tag or GitHub Release can be + published. Windows signature status is disclosed and unsigned output remains + accepted until a trusted signing identity is adopted. A missing platform + pauses the whole release instead of leaving users on a stale update line. - Cowork new-file prompts now start blank. Names with an explicit extension are preserved, while extensionless names receive the active section's `.md`, diff --git a/README.md b/README.md index 29df2f4..e1af4ad 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ update preserves it — credentials, databases, resident state, files, and workspaces. Profile → Check for updates asks the Mac running 1Helm—not the device displaying the web UI—to download and verify the signed update. -Windows 11 x64 gets a [signed Setup executable](https://1helm.com/download/windows) +Windows 11 x64 gets a [Setup executable](https://1helm.com/download/windows) that provisions one private WSL 2 world per channel. Linux hosts use a verified installer that provisions a durable systemd service with an atomic, digest-verified, health-checked updater — see the @@ -273,7 +273,7 @@ and an audit trail. A prompt saying “use this service” is not a connector. |---|---| | **Apple Silicon macOS 26** | Native desktop product and real isolated Linux computer per resident (Apple `container machine`, `home-mount=none`). | | **Linux / CI** | Supported headless systemd host with one unprivileged LXC per resident (subordinate UID/GID mapping, exact ownership checks); CI may select an explicit test backend. | -| **Windows 11 x64** | Native desktop product with one private WSL 2 world per resident (Windows-drive mounts and interop disabled); the signed Setup executable ships with every release. | +| **Windows 11 x64** | Native desktop product with one private WSL 2 world per resident (Windows-drive mounts and interop disabled); the Setup executable ships with every release and its signature status is disclosed. | | **iPhone and iPad** | App Store gateway to an already configured HTTPS 1Helm host; sessions live in the device-only iOS Keychain. | | **Android 7+** | Directly distributed signed universal APK gateway; sessions are encrypted by a key held in Android Keystore. |