From a2a6575bcc7ed0dce47d51b54858e4934b2d2d2f Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Mon, 27 Jul 2026 15:37:17 +0000 Subject: [PATCH] Fix Cowork editing and file responsiveness --- CHANGELOG.md | 30 ++++++++++++++++- README.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- public/index.html | 4 +-- src/client/app.ts | 43 +++++++++++++++++++----- src/client/channel.ts | 27 ++++++++++----- src/client/cowork-editors.ts | 5 +++ src/client/cowork.ts | 59 ++++++++++++++++++++++++++++----- src/client/styles.css | 3 ++ src/server/channel-computers.ts | 2 +- src/server/db.ts | 2 +- test/channel-computers.mjs | 2 +- test/channel-surfaces.mjs | 8 +++++ test/cowork-browser.mjs | 12 +++++++ test/workspace-interactions.mjs | 1 + 16 files changed, 170 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c65589c..5193279 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.20] - 2026-07-27 + +### Added + +- Cowork's agent request, Notes, and Docs editors now support the same + explicit speech-to-text mic control and bare Option/Alt shortcut as Chat + and Quick Note. + +### Fixed + +- Skipper now renders with the product avatar in Cowork rather than a plain + `S` initial. +- Returning to Cowork after navigating away starts a fresh collaboration + transport from the authoritative saved file. It cannot merge stale Yjs + history into a new room and duplicate an agent's or user's document edits. +- Long Cowork Notes stay scrollable while in Write mode. +- Files selection now paints immediately. Recursive folder-tree loading is + independent of the current directory request, removing repeated VM mirror + work from ordinary file and folder clicks. + +### Tests + +- Browser coverage proves leaving and reopening a saved Cowork note retains + exactly one copy of its content, and focused source/browser contracts cover + Cowork dictation, Skipper identity, Notes scrolling, and non-blocking Files + selection. + ## [0.0.19] - 2026-07-27 ### Fixed @@ -556,7 +583,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.19...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.20...HEAD +[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 [0.0.17]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.17 diff --git a/README.md b/README.md index 9852cda..9d3c2d1 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.19` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.20` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/package-lock.json b/package-lock.json index 62be752..7df63ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.19", + "version": "0.0.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.19", + "version": "0.0.20", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index a9f0fe7..581e27c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.19", + "version": "0.0.20", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/public/index.html b/public/index.html index 2b59c1a..221f7b8 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/src/client/app.ts b/src/client/app.ts index 1059a88..b29a7bd 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -2761,7 +2761,9 @@ type BrowserSpeechRecognition = { stop(): void; }; type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition; -let activeSpeech: { recognition: BrowserSpeechRecognition; input: HTMLTextAreaElement; button: HTMLButtonElement } | null = null; +type SpeechTextTarget = HTMLTextAreaElement | { value: () => string; replace: (value: string) => void; focus: () => void }; +let activeSpeech: { recognition: BrowserSpeechRecognition; input: SpeechTextTarget; button: HTMLButtonElement } | null = null; +let focusedSpeechTarget: SpeechTextTarget | null = null; function setListeningIndicator(listening: boolean): void { let indicator = document.querySelector("[data-listening-indicator]"); @@ -2791,7 +2793,7 @@ function activeComposerInput(): HTMLTextAreaElement | null { const parent = S.threadRoot ? String(S.threadRoot.id) : "root"; return document.querySelector(`textarea[data-composer-parent="${parent}"]`); } -async function toggleSpeechToText(input = activeComposerInput()): Promise { +async function toggleSpeechToText(input: SpeechTextTarget | null = activeComposerInput(), explicitButton?: HTMLButtonElement): Promise { if (!input) return; if (activeSpeech) { const wasThisInput = activeSpeech.input === input; @@ -2803,10 +2805,10 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise await appAlert("Speech-to-text is not available in this browser. Try the current 1Helm desktop app or a browser with SpeechRecognition support; typing and attachments still work normally."); return; } - const button = input.closest(".composer-wrap, [data-quick-note]")?.querySelector("[data-speech-toggle]"); + const button = explicitButton || (input instanceof HTMLTextAreaElement ? input.closest(".composer-wrap, [data-quick-note]")?.querySelector("[data-speech-toggle]") : null); if (!button) return; const recognition = new Recognition(); - const original = input.value; + const original = input instanceof HTMLTextAreaElement ? input.value : input.value(); const joiner = original && !/\s$/.test(original) ? " " : ""; let finalTranscript = ""; recognition.continuous = true; @@ -2819,9 +2821,12 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise if (event.results[index]?.isFinal) finalTranscript += transcript; else interim += transcript; } - input.value = original + joiner + finalTranscript + interim; - input.selectionStart = input.selectionEnd = input.value.length; - input.dispatchEvent(new Event("input")); + const next = original + joiner + finalTranscript + interim; + if (input instanceof HTMLTextAreaElement) { + input.value = next; + input.selectionStart = input.selectionEnd = input.value.length; + input.dispatchEvent(new Event("input")); + } else input.replace(next); }; recognition.onerror = (event: any) => { const reason = String(event.error || "speech recognition failed"); @@ -2849,11 +2854,29 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise } } +/** Shared explicit mic control for textareas and Cowork's CodeMirror inputs. + * The bare Option/Alt shortcut targets the currently focused control too. */ +export function mountSpeechToTextControl(input: SpeechTextTarget, label = "Toggle speech-to-text"): HTMLButtonElement { + const button = h("button", { + class: "grid h-8 w-8 shrink-0 place-items-center rounded text-muted hover:bg-hover hover:text-fg", + type: "button", + title: speechRecognitionAvailable() ? `${label} · tap Option/Alt to toggle` : "Speech-to-text is unavailable in this browser", + "aria-label": label, + "aria-pressed": "false", + dataset: { speechToggle: "" }, + }, microphoneIcon()) as HTMLButtonElement; + button.onclick = () => { void toggleSpeechToText(input, button); }; + return button; +} + +export function setFocusedSpeechTarget(input: SpeechTextTarget | null): void { focusedSpeechTarget = input; } + 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); @@ -3266,8 +3289,10 @@ window.addEventListener("keyup", (event) => { const isSingleTap = altTapOnly && performance.now() - altTapStarted < 800; altTapOnly = false; if (!isSingleTap || !document.hasFocus()) return; - const input = activeComposerInput(); - if (input && !input.disabled && input.offsetParent !== null) void toggleSpeechToText(input); + const input = focusedSpeechTarget || activeComposerInput(); + if (!input) return; + if (input instanceof HTMLTextAreaElement && (input.disabled || input.offsetParent === null)) return; + void toggleSpeechToText(input); }); window.addEventListener("blur", () => { altTapOnly = false; }); window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => { if (event.matches && S.mobileMenuOpen) closeMobileMenu(); }); diff --git a/src/client/channel.ts b/src/client/channel.ts index fbf28a5..d568506 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -480,6 +480,7 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa const info = h("aside", { class: "hidden min-h-0 w-60 shrink-0 overflow-y-auto border-l border-line bg-raised/35 p-4 xl:block", dataset: { fileMetadata: "" } }); 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; const open = (entry: ChannelFile): void => { if (entry.kind === "directory") { currentPath = entry.path; selected = null; void load(); return; } const target = coworkPath(entry.path); @@ -535,16 +536,24 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa 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, h("button", { class: "btn-ghost text-xs text-danger", type: "button", onclick: () => { void mutate("delete"); } }, "Delete"))); }; - const load = async (): Promise => { + const refreshDirectories = async (): Promise => { + try { + directoryCache = (await api<{ directories: ChannelFile[] }>(`/api/channels/${channelId}/files/directories`)).directories; + drawTree(directoryCache); + } catch { /* the current directory remains usable while the tree retries */ } + }; + const redrawSelection = (): void => { + main.querySelectorAll("[data-file-path]").forEach((node) => node.classList.toggle("is-selected", node.dataset.filePath === selected?.path)); + drawInfo(); + }; + const load = async (options: { refreshTree?: boolean } = {}): Promise => { const requestedPath = currentPath; try { - const [result, folders] = await Promise.all([ - api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`), - api<{ directories: ChannelFile[] }>(`/api/channels/${channelId}/files/directories`), - ]); + const result = await api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`); if (requestedPath !== currentPath) return; currentPath = result.path ?? requestedPath; heading.textContent = `/workspace${currentPath ? `/${currentPath}` : ""}`; main.dataset.fileDirectory = currentPath || "/"; - drawTree(folders.directories); + if (directoryCache) drawTree(directoryCache); + else void refreshDirectories(); clear(crumbs); const segments = currentPath ? currentPath.split("/") : []; const addCrumb = (label: string, path: string): void => { @@ -558,14 +567,14 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa if (!files.length) main.append(empty(result.files.length ? "No matches" : "This folder is empty", result.files.length ? "Try a different search." : "Create a folder or file, upload something, or let the resident agent add it.")); else main.append(h("div", { class: "file-grid", role: "list" }, ...files.map((entry) => h("button", { class: `file-grid-item ${selected?.path === entry.path ? "is-selected" : ""}`, type: "button", role: "listitem", dataset: { filePath: entry.path, fileKind: entry.kind }, - onclick: () => { selected = entry; void load(); }, ondblclick: () => open(entry), + onclick: () => { selected = entry; redrawSelection(); }, ondblclick: () => open(entry), }, h("span", { class: `file-grid-icon ${entry.kind === "directory" ? "is-folder" : "is-file"}` }, workspaceIcon(entry, 27)), h("span", { class: "min-w-0 flex-1 text-left" }, h("span", { class: "block truncate text-sm font-semibold text-fg" }, entry.name), h("span", { class: "mt-0.5 block truncate text-[11px] text-muted" }, entry.kind === "directory" ? "Folder" : `${formatBytes(entry.size)} · ${timeLabel(entry.modified)}`)), h("span", { class: "file-grid-kind" }, entry.kind === "directory" ? "Folder" : (entry.name.split(".").pop()?.toUpperCase() || "File")))))); drawInfo(); status.textContent = `${result.files.length} item${result.files.length === 1 ? "" : "s"}`; } catch (error) { panelError(main, error); } }; 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 } }); await load(); } catch (error) { status.textContent = (error as Error).message; } }; + 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; } }; const newFile = async (): Promise => { const name = await appPrompt("File name", "untitled.md"); if (!name) return; try { await api(`/api/channels/${channelId}/files/entries`, { body: { parent: currentPath, name, content: "" } }); await load(); } catch (error) { status.textContent = (error as Error).message; } }; fileInput.onchange = async () => { const chosen = Array.from(fileInput.files || []); if (!chosen.length) return; status.textContent = `Uploading ${chosen.length} item${chosen.length === 1 ? "" : "s"}…`; try { for (const file of chosen) { const upload = await uploadFile(file); await api(`/api/channels/${channelId}/files/upload`, { body: { ...upload, path: currentPath } }); } fileInput.value = ""; await load(); } catch (error) { status.textContent = (error as Error).message; } }; root.append( @@ -573,7 +582,7 @@ 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: load }); + fileBrowserSurfaces.set(channelId, { node: root, reload: async () => { directoryCache = null; await load(); } }); clear(container); container.append(root); void load(); } diff --git a/src/client/cowork-editors.ts b/src/client/cowork-editors.ts index c42e5c1..bd7bd4a 100644 --- a/src/client/cowork-editors.ts +++ b/src/client/cowork-editors.ts @@ -34,6 +34,7 @@ export type MountedEditor = { focus: () => void; destroy: () => void; getContent?: () => string; + replaceContent?: (content: string) => void; format?: (prefix: string, suffix?: string, placeholder?: string) => void; selection?: () => { from: number; to: number }; }; @@ -87,6 +88,10 @@ export function mountCodeMirror( focus: () => view.focus(), destroy: () => view.destroy(), getContent: () => view.state.doc.toString(), + replaceContent: (content) => { + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content }, selection: { anchor: content.length } }); + view.focus(); + }, format: (prefix, suffix = prefix, placeholder = "text") => { const range = view.state.selection.main; const selected = view.state.sliceDoc(range.from, range.to) || placeholder; diff --git a/src/client/cowork.ts b/src/client/cowork.ts index ef057e5..8aad3a1 100644 --- a/src/client/cowork.ts +++ b/src/client/cowork.ts @@ -1,6 +1,6 @@ import { api, type Channel, type ChannelFile, type Message, type User } from "./api.ts"; import { clear, color, h, icon, initials, md } from "./dom.ts"; -import { appAlert, appConfirm, appPrompt } from "./app.ts"; +import { appAlert, appConfirm, appPrompt, mountSpeechToTextControl, setFocusedSpeechTarget } from "./app.ts"; import { connectCoworkDocument, type CoworkDocument } from "./cowork-collaboration.ts"; import { mountCodeMirror, mountExcalidraw, type MountedEditor } from "./cowork-editors.ts"; @@ -125,8 +125,17 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const status = h("span", { class: "min-h-5 truncate text-xs text-muted", role: "status" }); const search = h("input", { class: "field h-9 text-xs", type: "search", placeholder: "Filter files", "aria-label": "Filter current Cowork folder" }) as HTMLInputElement; const agentPanel = h("aside", { class: "cowork-agent hidden min-h-0 w-[min(25rem,38vw)] shrink-0 flex-col border-l border-line bg-surface", dataset: { coworkAgent: "" } }); - const agentAvatar = h("span", { class: "grid h-9 w-9 place-items-center rounded-full text-xs font-bold text-white", style: `background:${color(channel.agent?.name || "agent")}` }, initials(channel.agent?.display_name || channel.agent?.name || "Agent")); - const agentToggle = h("button", { class: "cowork-agent-toggle", type: "button", title: "Work with the channel agent", "aria-label": "Open Cowork agent panel", "aria-expanded": "false" }, agentAvatar.cloneNode(true)); + const agentAvatar = () => { + const value = channel.agent?.runtime?.avatar || ""; + const name = channel.agent?.display_name || channel.agent?.name || "Agent"; + const swatch = /^color:(#[0-9a-f]{6})$/i.exec(value)?.[1] || color(channel.agent?.name || "agent"); + const image = /^agent:([1-9]):#[0-9a-f]{6}$/i.exec(value); + if (!value && channel.agent?.kind === "skipper") return h("img", { class: "h-9 w-9 rounded-full object-cover", src: "/brand/1helm-sailboat.png", alt: name, title: name }); + if (image) return h("span", { class: "grid h-9 w-9 place-items-center overflow-hidden rounded-full", style: `background:${swatch}`, title: name, "aria-label": name }, h("img", { class: "h-full w-full object-contain", src: `/agent-avatars/agent-${image[1]}.png`, alt: "" })); + if (value.startsWith("data:image/") || value.startsWith("/")) return h("img", { class: "h-9 w-9 rounded-full object-cover", src: value, alt: name, title: name }); + return h("span", { class: "grid h-9 w-9 place-items-center rounded-full text-xs font-bold text-white", style: `background:${swatch}` }, initials(name)); + }; + const agentToggle = h("button", { class: "cowork-agent-toggle", type: "button", title: "Work with the channel agent", "aria-label": "Open Cowork agent panel", "aria-expanded": "false" }, agentAvatar()); const activeSession = (): SectionSession => sessions.get(section)!; const activeSection = () => SECTIONS.find((candidate) => candidate.id === section)!; @@ -146,6 +155,27 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: 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; + }; + + /** 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 = ""; + } + }; + const updateSectionNav = (): void => { clear(sectionNav); for (const item of SECTIONS) sectionNav.append(h("button", { @@ -214,13 +244,23 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: 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({ + value: () => mounted.getContent?.() || "", + replace: (content) => 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 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, - previewButton); - return h("div", { class: `flex min-h-0 flex-1 flex-col ${mode === "docs" ? "cowork-doc-canvas" : ""}` }, toolbar, h("div", { class: "flex min-h-0 flex-1 flex-col overflow-auto" }, editStage, preview)); + 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 whiteboardEditor = (session: SectionSession): HTMLElement => { @@ -418,7 +458,9 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const session = activeSession(); chatRootId = Number(localStorage.getItem(threadKey(session.path)) || 0); const stream = h("div", { class: "min-h-0 flex-1 space-y-3 overflow-y-auto p-3", dataset: { coworkChatStream: "" } }); 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); const send = async (): Promise => { const message = input.value.trim(); if (!message || !session.path) return; input.disabled = true; @@ -432,8 +474,8 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: }; input.onkeydown = (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void send(); } }; clear(agentPanel); - agentPanel.append(h("header", { class: "flex min-h-14 items-center gap-2 border-b border-line px-3" }, agentAvatar.cloneNode(true), h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-fg" }, channel.agent?.display_name || channel.agent?.name || "Channel agent"), h("div", { class: "truncate text-[10px] text-muted" }, session.path ? `/workspace/${session.path}` : "Open a file")), chatRootId ? h("button", { class: "btn-ghost text-xs", onclick: async () => { try { const result = await api<{ root: Message }>(`/api/messages/${chatRootId}/thread`); openThreadCallback(result.root); } catch (error) { void appAlert((error as Error).message); } } }, "Open in Chat") : null, h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close agent panel", onclick: () => { agentOpen = false; drawAgent(); } }, icon("x", 15))), stream, - h("div", { class: "border-t border-line p-2" }, chatRootId ? h("button", { class: "btn-ghost mb-1 text-[11px]", onclick: () => { chatRootId = 0; coworkContextPending = true; localStorage.removeItem(threadKey(session.path)); drawAgent(); } }, "New session") : h("p", { class: "px-2 pb-1 text-[11px] leading-4 text-muted" }, "Your first message starts a normal channel session with this file and its current collaborators."), h("div", { class: "rounded-lg border border-line bg-raised/40 focus-within:border-accent" }, input, h("div", { class: "flex justify-end p-1.5" }, h("button", { class: "btn-primary text-xs", disabled: !session.path, onclick: () => { void send(); } }, icon("send", 13), "Send"))))); + agentPanel.append(h("header", { class: "flex min-h-14 items-center gap-2 border-b border-line px-3" }, agentAvatar(), h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-fg" }, channel.agent?.display_name || channel.agent?.name || "Channel agent"), h("div", { class: "truncate text-[10px] text-muted" }, session.path ? `/workspace/${session.path}` : "Open a file")), chatRootId ? h("button", { class: "btn-ghost text-xs", onclick: async () => { try { const result = await api<{ root: Message }>(`/api/messages/${chatRootId}/thread`); openThreadCallback(result.root); } catch (error) { void appAlert((error as Error).message); } } }, "Open in Chat") : null, h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close agent panel", onclick: () => { agentOpen = false; drawAgent(); } }, icon("x", 15))), stream, + h("div", { class: "border-t border-line p-2" }, chatRootId ? h("button", { class: "btn-ghost mb-1 text-[11px]", onclick: () => { chatRootId = 0; coworkContextPending = true; localStorage.removeItem(threadKey(session.path)); drawAgent(); } }, "New session") : h("p", { class: "px-2 pb-1 text-[11px] leading-4 text-muted" }, "Your first message starts a normal channel session with this file and its current collaborators."), h("div", { class: "rounded-lg border border-line bg-raised/40 focus-within:border-accent" }, input, h("div", { class: "flex justify-end gap-1 p-1.5" }, dictate, h("button", { class: "btn-primary text-xs", disabled: !session.path, onclick: () => { void send(); } }, icon("send", 13), "Send"))))); void renderChatMessages(); if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = window.setInterval(() => { if (!shell.isConnected || !agentOpen) { if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = null; return; } void renderChatMessages(); }, 1600); if (focusAgentOnDraw) requestAnimationFrame(() => input.focus({ preventScroll: true })); focusAgentOnDraw = false; @@ -456,7 +498,8 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: const returning = active && !surfaceActive; surfaceActive = active; if (returning) coworkContextPending = true; - syncCollaborationActivity(); + if (!active) disconnectEditors(); + else { syncCollaborationActivity(); if (activeSession().path) void drawWorkspace(); } if (returning && agentOpen) drawAgent(); }, }; diff --git a/src/client/styles.css b/src/client/styles.css index 618e368..d22b3b8 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -901,6 +901,9 @@ 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-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 a9426bf..4f2552e 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.19"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.20"; 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 = [ diff --git a/src/server/db.ts b/src/server/db.ts index 89f3c1b..9f7fb4d 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.19"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.20"); // 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/test/channel-computers.mjs b/test/channel-computers.mjs index 87a7ae0..207705f 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.19"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.20"); 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 020ac7b..c7386a4 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -6,6 +6,7 @@ import test, { after } from "node:test"; const root = resolve(import.meta.dirname, ".."); const agentsSource = readFileSync(join(root, "src", "server", "agents.ts"), "utf8"); +const stylesSource = readFileSync(join(root, "src", "client", "styles.css"), "utf8"); const dataDir = mkdtempSync(join(tmpdir(), "1helm-channel-surfaces-")); process.env.CTRL_DATA_DIR = dataDir; process.env.NODE_ENV = "test"; @@ -188,6 +189,13 @@ test("channel UI source exposes file-backed Cowork, traditional Files, audio pre assert.match(channelSource, /data(?:set)?: \{ fileFolderTree: "" \}/, "Files has a folder navigation rail"); 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, /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(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(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 232419c..21b534d 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -147,6 +147,18 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil assert.deepEqual(await page.$$eval('[aria-label="Notes editor"] .cm-line', (lines) => lines.map((line) => line.textContent)), ["# FPRESERVEDes", "", "**Goal**", "", "Unsaved continuity proof."]); 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"); + // 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. + await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Files"))?.click()); + await page.waitForSelector('[data-file-browser]'); + await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Cowork"))?.click()); + await page.waitForSelector('[data-cowork-surface]'); + 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")); + 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"); // A second authenticated member joins the same ordinary workspace file. // Both clients see presence, remote cursors, and edits without a reload. diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs index 6e47ba2..13db7e9 100644 --- a/test/workspace-interactions.mjs +++ b/test/workspace-interactions.mjs @@ -25,6 +25,7 @@ test("speech-to-text is explicit, graceful, and combination-safe", () => { assert.ok(server.includes('"permissions-policy": "camera=(), microphone=(self), geolocation=(), unload=(self)"'), "the web control plane permits first-party microphone access for explicit dictation"); assert.match(app, /SpeechRecognition\?[^\n]+webkitSpeechRecognition/, "standard and prefixed browser recognition are supported"); assert.match(app, /dataset: \{ speechToggle: "" \}/, "the composer exposes an explicit mic control"); + assert.match(app, /export function mountSpeechToTextControl/, "other text surfaces reuse the same dictation control and shortcut behavior"); 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");