diff --git a/CHANGELOG.md b/CHANGELOG.md index 548738b..974b906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.17] - 2026-07-27 + +### Added + +- Cowork replaces the former Notes tab with one direct editing plane for the + channel's existing `/workspace/notes`, `/whiteboards`, `/code`, `/docs`, and + `/presentations` trees. Its five stable work modes share nested-folder + navigation and add focused Markdown, document, code, single-file whiteboard, + and single-file presentation editors. +- Cowork's collapsible resident-agent panel keeps the active file visible, + starts an ordinary channel thread, adds the open `/workspace/...` path only + to the first message, and lets that same session continue in Chat. +- The top-bar Quick Note captures a titled or collision-safe untitled Markdown + note directly into `/workspace/notes` without leaving the active channel, + tab, thread, or scroll position. Collapsed drafts persist in the current + session; dictation, Escape-to-save, and Control/Command+Enter are supported. +- Dictation now has a subtle listening waveform and active resident work uses + a compact animated orb without obscuring content. + +### Changed + +- Files is now a familiar two-pane browser with a persistent folder rail, + breadcrumbs, bounded content grid, search, sorting, metadata, distinct + folder and file-type icons, non-color-only selection, and direct create, + upload, open, rename, move, duplicate, delete, and download actions. Cowork + assets open in the matching Cowork mode over the same underlying file. +- Markdown now renders consistently in global and channel Threads, Board, + session summaries, agent and status messages, Cowork chat, Memory, Texts, + and Activity, including spaced bold labels such as `** Goal **`. +- Photon conversations no longer repeat robotic phone-number source labels; + legacy entries use the concise human-facing title “Text with Skipper.” +- Mobile Settings uses a compact horizontal section rail, all layouts track + the real visual viewport and safe areas, and scrolling away from the message + composer releases focus so the on-screen keyboard can dismiss naturally. +- The redundant **Call Skipper** button is removed; `@Skipper` remains the + direct escalation path. + +### Fixed + +- Background messages, polling, synchronization, route repaints, and shell + updates no longer remount live Files or Cowork surfaces or reset the user's + focused input, selection/cursor, draft value, scroll position, expanded + details, active file, panel, or workspace mode. +- Folder and file rows no longer share an ambiguous document glyph, and mobile + panels and Terminal size against the visible screen instead of extending + beneath browser chrome, the keyboard, or operating-system safe areas. + ### Documentation - The public privacy policy now covers the iOS gateway, just-in-time media and @@ -444,7 +491,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.16...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.17...HEAD +[0.0.17]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.17 [0.0.16]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.16 [0.0.15]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.15 [0.0.14]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.14 diff --git a/README.md b/README.md index f6222a8..a8dd9a2 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,16 @@ and an audit trail. A prompt saying “use this service” is not a connector. supported platform (exact contracts in the table below). - Shared channel `/workspace` for the agent command surface and human Terminal, with automatic terminal heartbeat and silent same-session reconnection. +- A traditional two-pane Files browser over that same `/workspace`, with + breadcrumbs, nested folders, search, sorting, distinct file-type icons, and + familiar create, rename, move, duplicate, delete, upload, and download actions. +- Cowork as the direct visual editing plane for `/workspace/notes`, + `/workspace/whiteboards`, `/workspace/code`, `/workspace/docs`, and + `/workspace/presentations`, with one consistent file rail, purpose-built + editors, and an optional channel-agent panel that starts an ordinary thread + with the open file path. +- Quick Note in the top bar for capturing a Markdown note into + `/workspace/notes` without leaving or repositioning the current channel view. - Durable files, threads, curated memory, Mnemosyne long-term recall, corrections, follow-ups, and recurring workflows. - Direct resident → Skipper escalation and automatic Skipper → resident return. @@ -286,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.16` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.17` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/SPEC.md b/SPEC.md index e7a0787..8c1b9f0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -345,14 +345,17 @@ Once ready, the channel visibly presents: - its current model/provider, as a replaceable implementation detail; - its files/artifacts; - its active/recent threads; and -- an obvious `@skipper` escalation path. +- an obvious `@skipper` mention path. ### 8.3 Channel layout Chat remains the shared work surface, but the channel is not reduced to a message feed. It provides views or panels for: - **Threads** — active and completed sessions; -- **Files** — the channel workspace and shared artifacts; +- **Files** — a familiar two-pane browser for the channel workspace and shared artifacts; +- **Cowork** — the direct visual editing plane for the same files under + `/workspace/notes`, `/workspace/whiteboards`, `/workspace/code`, + `/workspace/docs`, and `/workspace/presentations`; - **Terminal** — interactive shell sessions inside the channel environment; - **Memory/Knowledge** — channel-owned decisions, facts, summaries, and references; - **Activity** — tool actions, long-running work, Skipper interventions, and system lifecycle; and @@ -360,6 +363,14 @@ Chat remains the shared work surface, but the channel is not reduced to a messag These views are representations of the same channel world, not separate apps that force users to copy context between them. +The top-bar note action is a compact Quick Note overlay, not a separate Notes +dock. Saving writes a collision-safe Markdown file under `/workspace/notes` +without changing the active channel, view, thread, focus, or scroll position. +Cowork provides Notes, Whiteboard, Code, Docs, and Presentations work modes with +one shared folder rail and an optional channel-agent panel. The panel starts an +ordinary channel thread, appending only the currently open `/workspace/...` +path to its first human message; the same thread remains available in Chat. + ### 8.4 Agent interactions - Mention the resident agent to start or continue work in a thread. @@ -565,7 +576,7 @@ Use language that reinforces the native model: - **Workspace** — the agent's durable environment. - **Thread** or **session** — focused unit of work. - **Memory/Knowledge** — channel-owned continuity. -- **Call Skipper** — escalation to the workspace-level operator. +- **Mention Skipper** — direct escalation to the workspace-level operator. - **Captain** — final human owner. Suggested concise positioning: diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index bf4d3d7..1b60dad 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -55,6 +55,13 @@ messages remain conversational without repeated mentions. Mention Skipper for a boundary outside the resident computer; the resident can also call Skipper itself, and the runtime returns the unblocked thread automatically. +The top-bar **Quick Note** opens a compact editor above the current view. It +keeps an unsaved draft when collapsed, supports dictation, saves with +Control/Command+Enter, and writes directly to `/workspace/notes`. Give the note +an optional title or let 1Helm allocate the next +`untitled-quick-note-[#].md`; saving never changes the current tab, thread, or +scroll position. + Agent replies stream into one stable message. Draft text, focus, selection, scroll position, and open work logs remain stable while updates arrive. Use the Stop control to interrupt only the active turn; the next reply receives hidden @@ -90,14 +97,48 @@ the workspace-wide Threads entry provides a cross-channel view. ![Board lanes for real channel sessions](assets/guide/board.png) -## Files and Terminal +## Files, Cowork, and Terminal + +Files, Cowork, and Terminal are three views of the same channel computer. Agent +commands and the human Terminal both start in the channel's persistent +`/workspace`; there is no separate Cowork document store. -Files and Terminal are two views of the same channel computer. Agent commands -and the human Terminal both start in the channel's persistent `/workspace`. -Files created through either path appear in Files after reconciliation. +**Files** is a traditional two-pane browser. The left rail navigates the folder +tree while the main pane shows the selected folder in a bounded grid. Use the +breadcrumb, search, and sort controls for larger collections; folders and +file types have distinct icons and selected items have a visible outline as +well as color. From the details pane you can open, rename, move, duplicate, +delete, or download an item. The top bar creates files and folders or uploads +into the current directory. Protected top-level product folders cannot be +renamed or deleted. ![The channel Files surface with explicit Open and Download](assets/guide/files.png) +**Cowork** is the visual editor for files already in `/workspace`. Its Notes, +Whiteboard, Code, Docs, and Presentations modes map directly to: + +```text +/workspace/notes +/workspace/whiteboards +/workspace/code +/workspace/docs +/workspace/presentations +``` + +Each mode keeps the same folder rail and nested-folder navigation while the +center changes to the appropriate editor. Notes and Docs edit Markdown, Code +provides a focused text editor and declines unsupported binary types, +Whiteboard uses one readable `.whiteboard.json` file, and Presentations uses +one readable `.slides.json` file. Drafts and each mode's last open asset remain +available while switching modes. Opening one of these assets from Files routes +to the matching Cowork mode because both surfaces edit the same bytes. + +The resident avatar opens Cowork's collapsible agent panel without covering the +asset on desktop. Its first message starts a normal channel thread and adds the +current `/workspace/...` path; later messages continue that thread, and **Open +in Chat** moves the same session to the channel's regular conversation view. +The agent harness otherwise remains unchanged. + The terminal prompt displays the live current path and changes after `cd`. 1Helm sends a terminal heartbeat while the pane is open and automatically reconnects a dropped browser transport to the same live shell. Briefly @@ -136,7 +177,8 @@ log. ## Settings The gear opens a full-screen settings application. On desktop, use its vertical -sidebar; smaller layouts use a compact non-scrolling grid. +sidebar; smaller layouts use a compact horizontal section rail so settings do +not consume half of the visible screen. - **Admin** — workspace name, theme, image, and release/update information. - **Agents** — Skipper and resident identities, status, models, capabilities, diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9155699..677235f 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -309,7 +309,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.16; + MARKETING_VERSION = 0.0.17; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; PRODUCT_NAME = 1Helm; @@ -333,7 +333,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.16; + MARKETING_VERSION = 0.0.17; PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; PRODUCT_NAME = 1Helm; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package-lock.json b/package-lock.json index b58d8e1..149e85b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.16", + "version": "0.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.16", + "version": "0.0.17", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 44e4e70..bc2d549 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.16", + "version": "0.0.17", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/public/index.html b/public/index.html index 4373f30..8424680 100644 --- a/public/index.html +++ b/public/index.html @@ -29,11 +29,11 @@ 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 7648a33..5669c3a 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/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/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/api.ts b/src/client/api.ts index ac4f339..0c64ef2 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -3,7 +3,7 @@ export type User = { description: string; job_title: string; avatar: string; tour_complete: boolean; }; export type Author = { kind: "user" | "bot" | "system"; id: number; name: string }; -export type Attachment = { id: number; name: string; mime: string; size: number }; +export type Attachment = { id: number; name: string; mime: string; size: number; workspace_path?: string }; export type AgentProgress = { id: number; kind: "thinking" | "tool" | "status"; body: string; status: "running" | "complete" | "failed"; created: number; updated: number }; export type ThreadUsage = { input_tokens: number; output_tokens: number }; export type AgentQuestionOption = { label: string; description?: string }; diff --git a/src/client/app.ts b/src/client/app.ts index ae035b0..ce51f75 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -6,6 +6,7 @@ import { openRoutingPopover, pushRoutingActivity } from "./routing.ts"; import { openOnboarding } from "./onboarding.ts"; import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts"; import { openCreateChannel, renderActivity, renderBoard, renderChannelSettings, renderFiles, renderGlobalThreads, renderMemory, renderNotes, renderTexts, renderThreads, type ChannelView } from "./channel.ts"; +import { renderCowork, stageCoworkPath } from "./cowork.ts"; import { apiUrl, finishNativeLaunch, forgetMobileServer, getServerOrigin, isNativeMobile, serverAssetUrl, setMobileServer } from "./mobile.ts"; /** Per-channel layout bound to the user profile (server user_ui_state). */ @@ -90,7 +91,9 @@ function getChannelView(channelId: number): ChannelUiView { function applyChannelViewToState(channelId: number): void { const view = getChannelView(channelId); S.terminalOpen = !!view.terminalOpen; - S.notesOpen = !!view.notesOpen; + // 0.0.17 replaced the full Notes dock with the non-disruptive Quick Note. + // Ignore legacy persisted dock-open state so an old preference cannot reopen it. + S.notesOpen = false; S.serversListOpen = !!view.serversListOpen; S.preferredTerminalComputerId = view.preferredComputerId; } @@ -145,6 +148,67 @@ async function loadUiState(): Promise { const root = document.getElementById("app")!; const pending: { token: string; name: string; mime: string; size: number }[] = []; +type UiContinuity = { + active: { key: string; start: number | null; end: number | null; value: string | null; checked: boolean | null } | null; + scroll: Array<{ key: string; top: number; left: number }>; + details: string[]; +}; + +function continuityKey(element: Element): string | null { + const stable = element.closest("[data-continuity-key]")?.dataset.continuityKey; + if (stable) return `[data-continuity-key="${CSS.escape(stable)}"]`; + if (element.id) return `#${CSS.escape(element.id)}`; + const composer = (element as HTMLElement).dataset?.composerParent; + if (composer != null) return `[data-composer-parent="${CSS.escape(composer)}"]`; + const quick = (element as HTMLElement).dataset?.quickNote; + if (quick != null) return `[data-quick-note="${CSS.escape(quick)}"]`; + const labelled = element.getAttribute("aria-label"); + if (labelled) return `${element.tagName.toLowerCase()}[aria-label="${CSS.escape(labelled)}"]`; + const placeholder = element.getAttribute("placeholder"); + if (placeholder) return `${element.tagName.toLowerCase()}[placeholder="${CSS.escape(placeholder)}"]`; + return null; +} + +/** Preserve user-owned focus, selection, scroll, and expansion across unavoidable shell paints. */ +function captureUiContinuity(scope: ParentNode): UiContinuity { + const activeElement = document.activeElement instanceof HTMLElement && scope.contains(document.activeElement) ? document.activeElement : null; + const activeKey = activeElement ? continuityKey(activeElement) : null; + const selection = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement + ? { start: activeElement.selectionStart, end: activeElement.selectionEnd, value: activeElement.value, checked: activeElement instanceof HTMLInputElement && ["checkbox", "radio"].includes(activeElement.type) ? activeElement.checked : null } + : activeElement instanceof HTMLSelectElement ? { start: null, end: null, value: activeElement.value, checked: null } + : { start: null, end: null, value: null, checked: null }; + const scroll = Array.from(scope.querySelectorAll("[data-continuity-key],#msgs,#threadmsgs,#channelview")) + .filter((element) => element.scrollTop !== 0 || element.scrollLeft !== 0) + .flatMap((element) => { const key = continuityKey(element); return key ? [{ key, top: element.scrollTop, left: element.scrollLeft }] : []; }); + const details = Array.from(scope.querySelectorAll("details[open][data-continuity-key]")) + .flatMap((element) => { const key = continuityKey(element); return key ? [key] : []; }); + return { active: activeKey ? { key: activeKey, ...selection } : null, scroll, details }; +} + +function restoreUiContinuity(snapshot: UiContinuity): void { + // Restore focus synchronously once the retained surface is reattached. A + // caller may observe the completed paint before the next animation frame; + // focus and cursor ownership must already be back with the user by then. + for (const key of snapshot.details) { const element = document.querySelector(key); if (element) element.open = true; } + if (snapshot.active) { + const element = document.querySelector(snapshot.active.key); + if (element) { + if ((element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && snapshot.active.value != null) element.value = snapshot.active.value; + if (element instanceof HTMLInputElement && snapshot.active.checked != null) element.checked = snapshot.active.checked; + element.focus({ preventScroll: true }); + if ((element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && snapshot.active.start != null && snapshot.active.end != null) element.setSelectionRange(snapshot.active.start, snapshot.active.end); + } + } + requestAnimationFrame(() => { + for (const saved of snapshot.scroll) { const element = document.querySelector(saved.key); if (element) { element.scrollTop = saved.top; element.scrollLeft = saved.left; } } + }); +} + +function showToast(message: string): void { + const toast = h("div", { class: "app-toast card fixed bottom-6 left-1/2 z-[75] -translate-x-1/2 px-4 py-3 text-sm text-fg shadow-2xl", role: "status" }, message); + document.body.append(toast); window.setTimeout(() => toast.remove(), 3200); +} + // ---------------- theme ---------------- export function currentTheme(): "light" | "dark" { return document.documentElement.classList.contains("light") ? "light" : "dark"; } export function toggleTheme(): void { @@ -157,12 +221,13 @@ export function toggleTheme(): void { } type AppRoute = { slug: string | null; view: ChannelView; threadRootId: number | null }; -const VIEWS = new Set(["chat", "texts", "board", "threads", "notes", "files", "terminal", "memory", "activity", "settings"]); +const VIEWS = new Set(["chat", "texts", "board", "threads", "cowork", "notes", "files", "terminal", "memory", "activity", "settings"]); function readRoute(): AppRoute { const parts = location.pathname.split("/").filter(Boolean); if (parts[0] !== "c" || !parts[1]) return { slug: null, view: "chat", threadRootId: null }; if (parts[2] === "thread" && /^\d+$/.test(parts[3] || "")) return { slug: decodeURIComponent(parts[1]), view: "chat", threadRootId: Number(parts[3]) }; - const view = VIEWS.has(parts[2] as ChannelView) ? parts[2] as ChannelView : "chat"; + const requested = VIEWS.has(parts[2] as ChannelView) ? parts[2] as ChannelView : "chat"; + const view = requested === "notes" ? "cowork" : requested; return { slug: decodeURIComponent(parts[1]), view, threadRootId: null }; } function writeRoute(channel: Channel | undefined, view: ChannelView, threadRootId: number | null, replace = false): void { @@ -759,9 +824,10 @@ async function renderAccessRequest(container: HTMLElement): Promise { // ---------------- layout ---------------- export function renderApp(): void { - const focusedNote = document.activeElement instanceof HTMLTextAreaElement && document.activeElement.classList.contains("note-editor") - ? { node: document.activeElement, start: document.activeElement.selectionStart, end: document.activeElement.selectionEnd } - : null; + const continuity = captureUiContinuity(root); + const preserveChannelSurface = Boolean(root.querySelector( + `[data-file-browser="${S.channelId}"], [data-cowork-surface="${S.channelId}"]`, + )); document.documentElement.dataset.workspaceTheme = S.workspace?.theme || localStorage.getItem("ctrl.workspaceTheme") || "graphite"; clear(root); const shell = h("div", { id: "app-shell", class: "workspace-shell app-shell relative flex h-full min-h-0 min-w-0 overflow-hidden" }, @@ -769,11 +835,8 @@ export function renderApp(): void { h("main", { id: "main", class: "relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-bg" })); root.append(shell); if (S.mobileMenuOpen) shell.append(mobileNavigation()); - renderMain(); - if (focusedNote?.node.isConnected) { - focusedNote.node.focus({ preventScroll: true }); - focusedNote.node.setSelectionRange(focusedNote.start, focusedNote.end); - } + renderMain(preserveChannelSurface); + restoreUiContinuity(continuity); finishNativeLaunch(); } @@ -1246,7 +1309,7 @@ function openGlobalThreads(): void { void loadWorkspace().then(() => renderApp()).catch(() => renderApp()); } -function renderMain(): void { +function renderMain(preserveChannelSurface = false): void { const main = document.getElementById("main")!; // #msgs is destroyed on every shell rebuild. Capture scroll *before* clear so // open/close thread doesn't dump the user at the oldest message. @@ -1285,7 +1348,7 @@ function renderMain(): void { if (channel?.kind !== "channel") { S.view = "chat"; S.terminalOpen = false; S.notesOpen = false; } if (S.view !== "chat") { main.append(h("section", { class: "flex min-w-0 flex-1 flex-col" }, h("div", { id: "hdr" }), channelTabs(), h("div", { id: "channelview", class: "min-h-0 flex-1 overflow-y-auto" }))); - renderHeader(); renderChannelView(); + renderHeader(); renderChannelView(preserveChannelSurface); return; } const showRhs = !!(S.threadRoot @@ -1417,7 +1480,7 @@ function textsAvailable(channel?: Channel): boolean { function channelTabs(): HTMLElement { const currentChannel = S.channels.find((channel) => channel.id === S.channelId); const tabs: [ChannelView, string][] = [ - ["chat", "Chat"], ["texts", "Texts"], ["board", "Board"], ["threads", "Threads"], ["notes", "Notes"], ["files", "Files"], + ["chat", "Chat"], ["texts", "Texts"], ["board", "Board"], ["threads", "Threads"], ["cowork", "Cowork"], ["files", "Files"], ["terminal", "Terminal"], ["memory", "Memory"], ["activity", "Activity"], ["settings", "Settings"], ]; return h("nav", { class: "flex shrink-0 gap-2 overflow-x-auto border-b border-line bg-surface px-3" }, ...tabs @@ -1435,7 +1498,7 @@ export function navigateChannelView(view: ChannelView): void { S.terminalOpen = false; S.preferredTerminalComputerId = S.preferredTerminalComputerId ?? getChannelView(S.channelId).preferredComputerId ?? defaultTerminalComputer(S.channelId); } - if (view === "notes") S.notesOpen = false; + if (view === "cowork" || view === "notes") { view = "cowork"; S.notesOpen = false; } persistCurrentChannelView(); renderApp(); writeRoute(S.channels.find((channel) => channel.id === S.channelId), view, null); @@ -1471,22 +1534,47 @@ export function openTerminalsFromHeader(): void { writeRoute(channel, "chat", S.threadRoot?.id ?? null); } -/** Header Notes — dock RHS like a thread or terminal while keeping chat visible. */ -export function openNotesFromHeader(): void { +type QuickNoteDraft = { title: string; content: string }; +const quickNoteDrafts = new Map(); + +/** Header Quick Note floats above the current view and never rebuilds its DOM. */ +export function openQuickNoteFromHeader(): void { const channel = S.channels.find((item) => item.id === S.channelId); if (!channel || channel.kind !== "channel") { - void appAlert("Open an agent channel to use notes."); + void appAlert("Open an agent channel to take a Quick Note."); return; } if (channel.status === "archived") { - void appAlert("Restore the channel before editing its notes."); + void appAlert("Restore the channel before saving a Quick Note."); return; } - if (S.view !== "chat") S.view = "chat"; - S.notesOpen = !S.notesOpen; - persistCurrentChannelView(); - renderApp(); - writeRoute(channel, "chat", S.threadRoot?.id ?? null); + const existing = document.querySelector(`[data-quick-note="${channel.id}"]`); + if (existing) { existing.querySelector('[aria-label="Collapse Quick Note"]')?.click(); return; } + const draft = quickNoteDrafts.get(channel.id) || { title: "", content: "" }; + const title = h("input", { class: "field h-9 text-sm", value: draft.title, placeholder: "Optional title", maxlength: 150, "aria-label": "Quick Note title" }) as HTMLInputElement; + const content = h("textarea", { class: "quick-note-content field resize-none text-sm leading-6", value: draft.content, rows: 9, placeholder: "Take a quick note and save it to /workspace/notes…", "aria-label": "Quick Note content" }) as HTMLTextAreaElement; + const status = h("span", { class: "min-h-5 flex-1 text-xs text-muted", role: "status" }); + const panel = h("section", { class: "quick-note-panel card fixed right-3 top-[max(4.25rem,env(safe-area-inset-top))] z-[65] flex w-[min(25rem,calc(100vw-1.5rem))] flex-col overflow-hidden shadow-2xl", dataset: { quickNote: String(channel.id) }, role: "dialog", "aria-label": "Quick Note" }); + const remember = (): void => { quickNoteDrafts.set(channel.id, { title: title.value, content: content.value }); }; + title.oninput = remember; content.oninput = remember; + const collapse = (): void => { remember(); panel.remove(); document.removeEventListener("click", outside); }; + const discard = async (): Promise => { + if ((title.value.trim() || content.value.trim()) && !(await appConfirm("Discard this Quick Note?"))) return; + collapse(); quickNoteDrafts.delete(channel.id); + }; + const save = async (): Promise => { + if (!content.value.trim()) { status.textContent = "Write a note before saving."; content.focus(); return; } + status.textContent = "Saving…"; + try { + const result = await api<{ note: { name: string } }>(`/api/channels/${channel.id}/notes/quick`, { body: { title: title.value, content: content.value } }); + quickNoteDrafts.delete(channel.id); panel.remove(); document.removeEventListener("click", outside); showToast(`Saved ${result.note.name} in /workspace/notes`); + } catch (error) { status.textContent = (error as Error).message; } + }; + const outside = (event: MouseEvent): void => { if (!panel.contains(event.target as Node)) collapse(); }; + const mic = h("button", { class: "grid h-8 w-8 place-items-center rounded-md text-muted hover:bg-hover hover:text-fg", type: "button", title: "Dictate", "aria-label": "Dictate Quick Note", dataset: { speechToggle: "" }, onclick: () => { void toggleSpeechToText(content); } }, microphoneIcon()); + panel.append(h("header", { class: "flex items-center gap-2 border-b border-line px-3 py-2.5" }, h("span", { class: "text-accent" }, icon("fileText", 17)), h("h2", { class: "flex-1 font-semibold text-fg" }, "Quick Note"), mic, h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Collapse Quick Note", onclick: collapse }, icon("x", 15))), h("div", { class: "space-y-2 p-3" }, title, content), h("footer", { class: "flex items-center gap-2 border-t border-line px-3 py-2.5" }, status, h("button", { class: "btn-ghost text-xs", onclick: () => { void discard(); } }, "Discard"), h("button", { class: "btn-primary text-xs", onclick: () => { void save(); } }, "Save"))); + panel.addEventListener("keydown", (event) => { if (event.key === "Escape") { event.preventDefault(); if (content.value.trim()) void save(); else collapse(); } else if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); void save(); } }); + document.body.append(panel); setTimeout(() => document.addEventListener("click", outside), 0); requestAnimationFrame(() => content.focus({ preventScroll: true })); } function openTerminalOnComputer(computerId: number): void { @@ -1500,7 +1588,7 @@ function openTerminalOnComputer(computerId: number): void { writeRoute(S.channels.find((channel) => channel.id === S.channelId), "terminal", null); } -export function renderChannelView(): void { +export function renderChannelView(preserveSurface = false): void { const container = document.getElementById("channelview"); const channel = S.channels.find((item) => item.id === S.channelId); if (!container || !channel) return; @@ -1513,8 +1601,8 @@ export function renderChannelView(): void { void openThread(root); }); else if (S.view === "threads") renderThreads(container, channel.id, (thread) => { S.view = "chat"; renderApp(); void openThread(thread.root); }); - else if (S.view === "notes") renderNotes(container, channel.id); - else if (S.view === "files") renderFiles(container, channel.id); + else if (S.view === "cowork" || S.view === "notes") renderCowork(container, channel.id, channel, (root) => { S.view = "chat"; renderApp(); void openThread(root); }, preserveSurface); + else if (S.view === "files") renderFiles(container, channel.id, "", (path) => { stageCoworkPath(channel.id, path); navigateChannelView("cowork"); }, preserveSurface); else if (S.view === "memory") renderMemory(container, channel.id); else if (S.view === "activity") renderActivity(container, channel.id); else if (S.view === "terminal") openTerminals(container, channel.id, S.preferredTerminalComputerId || undefined); @@ -1543,15 +1631,6 @@ function renderHeader(): void { const agent = channel?.agent; clear(el); el.className = "app-topbar flex min-h-12 flex-col items-stretch justify-between gap-0 border-b border-line bg-surface px-2 py-1.5 sm:flex-row sm:items-center sm:gap-3 sm:px-4 sm:py-2.5"; - const statusTone = agent?.status === "working" ? "bg-amber-400 animate-pulse" : agent?.status === "waiting" ? "bg-blue-400" : agent?.status === "archived" || agent?.status === "paused" ? "bg-faint" : "bg-ok"; - const callSkipper = (): void => { - S.view = "chat"; renderApp(); - queueMicrotask(() => { - const target = S.threadRoot ? String(S.threadRoot.id) : "root"; - const input = document.querySelector(`textarea[data-composer-parent="${target}"]`); - if (input) { input.value = "@skipper "; input.focus(); input.setSelectionRange(input.value.length, input.value.length); } - }); - }; const terminalsEnabled = S.workspace?.terminals_enabled !== false && (agent?.kind === "channel" || S.me.is_admin); const toggleFavorite = async (): Promise => { if (!channel) return; @@ -1585,7 +1664,7 @@ function renderHeader(): void { title: "Open live 1Helm Router activity", "aria-label": "Open 1Helm Router", dataset: { routingHeader: "" }, onclick: (event: MouseEvent) => { void openRoutingPopover(event).catch((error) => appAlert((error as Error).message)); }, }, routerSymbolIcon()), agent ? h("button", { class: "flex h-11 w-11 shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-1 font-mono text-[10px] text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-auto sm:min-h-0 sm:w-auto sm:max-w-[14rem] sm:gap-2 sm:px-2 sm:text-[11px]", title: `${agent.display_name || agent.name} · ${agent.status} · ${agent.provider_kind === "routing" ? "Model fabric" : agent.provider_name || "no provider"} · ${agent.model || "no model"}`, "aria-label": `Open ${agent.display_name || agent.name} settings · ${agent.status}`, onclick: channel?.can_manage ? () => navigateChannelView("settings") : undefined }, - h("span", { class: `h-1.5 w-1.5 shrink-0 rounded-full ${statusTone}` }), h("span", { class: "hidden min-w-0 truncate sm:inline" }, "@" + agent.name), + h("span", { class: `agent-state-orb ${agent.status === "working" ? "is-working" : agent.status === "waiting" ? "is-waiting" : ""}`, "aria-hidden": "true" }, h("span", {}), h("span", {})), h("span", { class: "hidden min-w-0 truncate sm:inline" }, "@" + agent.name), h("span", { class: "hidden max-w-28 truncate text-faint 2xl:inline" }, agent.model || "no model")) : null, terminalsEnabled ? h("button", { class: `grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9 ${S.terminalOpen || S.view === "terminal" ? "border-line bg-hover text-fg" : ""}`, @@ -1593,9 +1672,8 @@ function renderHeader(): void { }, icon("terminal", 18)) : null, channel?.kind === "channel" ? h("button", { class: `grid h-11 w-11 place-items-center rounded-md border border-transparent text-muted transition hover:border-line hover:bg-hover hover:text-fg sm:h-9 sm:w-9 ${S.notesOpen || S.view === "notes" ? "border-line bg-hover text-fg" : ""}`, - title: "Notes", "aria-label": "Open channel notes", dataset: { notesHeader: "" }, onclick: openNotesFromHeader, - }, icon("file", 18)) : null, - channel?.kind === "channel" ? h("button", { class: "btn-subtle min-h-11 w-11 px-0 text-xs sm:min-h-0 sm:w-auto sm:px-3", title: "Call Skipper", "aria-label": "Call Skipper", onclick: callSkipper }, helmMark(14), h("span", { class: "hidden xl:inline" }, "Call Skipper")) : null)); + title: "Quick Note", "aria-label": "Open Quick Note", dataset: { quickNoteHeader: "" }, onclick: (event: MouseEvent) => { event.stopPropagation(); openQuickNoteFromHeader(); }, + }, icon("fileText", 18)) : null)); } function starIcon(filled: boolean): SVGElement { @@ -2404,18 +2482,25 @@ function attachments(m: Message): HTMLElement | null { return h("div", { class: "mt-1.5 flex flex-wrap gap-2" }, ...m.attachments.map((a) => { const viewUrl = `/api/files/${a.id}`; const mediaUrl = `${serverAssetUrl(viewUrl)}?token=${encodeURIComponent(getToken())}`; + const cowork = a.workspace_path ? coworkAttachmentPath(a.workspace_path) : null; + const open = (event: MouseEvent): void => { event.stopPropagation(); if (cowork) { stageCoworkPath(m.channel_id, cowork); navigateChannelView("cowork"); } else void openAuthenticatedFile(viewUrl).catch((error) => appAlert((error as Error).message)); }; const actions = h("div", { class: "flex items-center gap-1 border-t border-line/70 px-2 py-1.5" }, - h("button", { class: "btn-subtle text-xs", type: "button", onclick: (event: MouseEvent) => { event.stopPropagation(); void openAuthenticatedFile(viewUrl).catch((error) => appAlert((error as Error).message)); } }, "Open"), + h("button", { class: "btn-subtle text-xs", type: "button", onclick: open }, cowork ? "Open in Cowork" : "Open"), h("button", { class: "btn-subtle text-xs", type: "button", onclick: (event: MouseEvent) => { event.stopPropagation(); void downloadAuthenticatedFile(`${viewUrl}?download=1`, a.name).catch((error) => appAlert((error as Error).message)); } }, "Download")); if (a.mime.startsWith("image/")) return h("article", { class: "overflow-hidden rounded-lg border border-line bg-raised" }, - h("button", { type: "button", class: "block", onclick: (event: MouseEvent) => { event.stopPropagation(); void openAuthenticatedFile(viewUrl).catch((error) => appAlert((error as Error).message)); } }, h("img", { src: mediaUrl, class: "max-h-64 object-contain", alt: a.name })), actions); + h("button", { type: "button", class: "block", onclick: open }, h("img", { src: mediaUrl, class: "max-h-64 object-contain", alt: a.name })), actions); return h("article", { class: "overflow-hidden rounded-lg border border-line bg-raised text-sm" }, - h("button", { type: "button", class: "flex w-full items-center gap-2.5 px-3 py-2 text-left hover:bg-hover", onclick: (event: MouseEvent) => { event.stopPropagation(); void openAuthenticatedFile(viewUrl).catch((error) => appAlert((error as Error).message)); } }, + h("button", { type: "button", class: "flex w-full items-center gap-2.5 px-3 py-2 text-left hover:bg-hover", onclick: open }, h("span", { class: "grid h-9 w-9 place-items-center rounded-lg bg-accent-soft text-accent" }, icon("file")), h("div", { class: "min-w-0" }, h("div", { class: "truncate font-medium text-fg" }, a.name), h("div", { class: "text-xs text-muted" }, fmtSize(a.size)))), actions); })); } +function coworkAttachmentPath(path: string): string | null { + const normalized = String(path || "").replace(/^\/?workspace\/?/, "").replace(/^\/+/, ""); + return /^(notes|whiteboards|code|docs|presentations)\//.test(normalized) ? normalized : null; +} + // ---------------- thread panel ---------------- async function openThread(root: Message, replaceRoute = false): Promise { const data = await api<{ root: Message; replies: Message[]; followup?: ThreadFollowup | null; usage?: ThreadUsage }>(`/api/messages/${root.id}/thread`); @@ -2617,6 +2702,15 @@ type BrowserSpeechRecognition = { type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition; let activeSpeech: { recognition: BrowserSpeechRecognition; input: HTMLTextAreaElement; button: HTMLButtonElement } | null = null; +function setListeningIndicator(listening: boolean): void { + let indicator = document.querySelector("[data-listening-indicator]"); + if (listening && !indicator) { + indicator = h("div", { class: "listening-indicator", dataset: { listeningIndicator: "" }, role: "status", "aria-label": "Listening" }, h("span", { class: "listening-orb" }, h("i", {}), h("i", {}), h("i", {})), h("span", { class: "hidden text-[11px] font-semibold text-fg sm:inline" }, "Listening")); + document.body.append(indicator); + } + if (!listening) indicator?.remove(); +} + function speechRecognitionConstructor(): BrowserSpeechRecognitionConstructor | null { const browser = window as Window & { SpeechRecognition?: BrowserSpeechRecognitionConstructor; webkitSpeechRecognition?: BrowserSpeechRecognitionConstructor }; return browser.SpeechRecognition || browser.webkitSpeechRecognition || null; @@ -2631,6 +2725,8 @@ function microphoneIcon(): SVGElement { return svg; } function activeComposerInput(): HTMLTextAreaElement | null { + const quickNote = document.querySelector("[data-quick-note] .quick-note-content"); + if (quickNote && quickNote.offsetParent !== null) return quickNote; const parent = S.threadRoot ? String(S.threadRoot.id) : "root"; return document.querySelector(`textarea[data-composer-parent="${parent}"]`); } @@ -2646,7 +2742,7 @@ 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")?.querySelector("[data-speech-toggle]"); + const button = input.closest(".composer-wrap, [data-quick-note]")?.querySelector("[data-speech-toggle]"); if (!button) return; const recognition = new Recognition(); const original = input.value; @@ -2675,15 +2771,18 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise button.classList.remove("bg-danger/15", "text-danger"); button.title = "Dictate · tap Option/Alt to toggle"; if (activeSpeech?.recognition === recognition) activeSpeech = null; + setListeningIndicator(false); }; activeSpeech = { recognition, input, button }; button.setAttribute("aria-pressed", "true"); button.classList.add("bg-danger/15", "text-danger"); button.title = "Listening… tap again or tap Option/Alt to stop"; + setListeningIndicator(true); input.focus(); try { recognition.start(); } catch (error) { activeSpeech = null; + setListeningIndicator(false); recognition.onend?.(); await appAlert(`Speech-to-text could not start: ${(error as Error).message}`); } diff --git a/src/client/channel.ts b/src/client/channel.ts index e6b01fc..2b4c5af 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -3,7 +3,7 @@ import { h, clear, icon, md, timeLabel } from "./dom.ts"; import { S, avatar, appAlert, appConfirm, appPrompt } from "./app.ts"; import { NOTIFICATION_SOUNDS, channelNotificationPreference, previewNotification, setChannelNotificationPreference } from "./notifications.ts"; -export type ChannelView = "chat" | "texts" | "board" | "threads" | "notes" | "files" | "terminal" | "memory" | "activity" | "settings"; +export type ChannelView = "chat" | "texts" | "board" | "threads" | "cowork" | "notes" | "files" | "terminal" | "memory" | "activity" | "settings"; export function openCreateChannel(onCreated: (channel: Channel) => void): void { const name = h("input", { class: "field", placeholder: "launch", autocomplete: "off" }) as HTMLInputElement; @@ -357,7 +357,7 @@ export function renderGlobalThreads( h("span", { class: "shrink-0 font-mono text-[11px] text-accent" }, `#${thread.channel_name}`), h("span", { class: "ml-auto shrink-0 text-[11px] text-faint" }, timeLabel(thread.updated_at))), h("span", { class: "mt-0.5 flex min-w-0 items-center gap-2" }, - h("span", { class: "min-w-0 flex-1 truncate text-xs text-muted" }, thread.summary || "No summary yet."), + h("div", { class: "md min-w-0 flex-1 truncate text-xs text-muted", html: md(thread.summary || "No summary yet.") }), h("span", { class: "shrink-0 rounded-full border border-line px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-faint" }, thread.status)))))); } clear(container); @@ -419,66 +419,140 @@ export function renderTexts(container: HTMLElement, selectedId?: number, onSelec }).catch((error) => panelError(container, error)); } -export function renderFiles(container: HTMLElement, channelId: number, initialPath = ""): void { +function workspaceIcon(file: ChannelFile, size = 18): SVGElement { + if (file.kind === "directory") return icon("folder", size); + const ext = file.name.split(".").pop()?.toLowerCase() || ""; + if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return icon("image", size); + if (["js", "jsx", "ts", "tsx", "html", "css", "json", "yaml", "yml", "py", "rb", "go", "rs", "sh", "sql"].includes(ext)) return icon("code", size); + if (["excalidraw", "whiteboard"].includes(ext)) return icon("board", size); + if (["deck", "slides", "ppt", "pptx"].includes(ext)) return icon("presentation", size); + return icon("fileText", size); +} + +function coworkPath(path: string): { section: "notes" | "whiteboards" | "code" | "docs" | "presentations"; path: string } | null { + const section = path.split("/")[0] as "notes" | "whiteboards" | "code" | "docs" | "presentations"; + return ["notes", "whiteboards", "code", "docs", "presentations"].includes(section) ? { section, path } : null; +} + +type FileBrowserSurface = { node: HTMLElement; reload: () => Promise }; +const fileBrowserSurfaces = new Map(); + +/** 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 { + const cached = fileBrowserSurfaces.get(channelId); + if (cached) { + clear(container); container.append(cached.node); + if (!preserveExisting) void cached.reload(); + return; + } let currentPath = initialPath.replace(/^\/?workspace\/?/, "").replace(/^\/+|\/+$/g, ""); - panelLoading(container, "Files", "Browse the same folders the channel computer sees under /workspace."); - const load = (): void => { + let selected: ChannelFile | null = null; + let filter = ""; + let sort: "name" | "modified" | "size" = "name"; + const root = h("section", { class: "file-browser flex h-full min-h-[32rem] flex-col bg-surface", dataset: { fileBrowser: String(channelId) } }); + const heading = h("span", { class: "truncate font-semibold text-fg" }, "/workspace"); + const status = h("span", { class: "min-h-5 min-w-0 flex-1 truncate text-xs text-muted", role: "status" }); + const search = h("input", { class: "field h-9 min-w-0 text-xs", type: "search", placeholder: "Search this folder", "aria-label": "Search current folder" }) as HTMLInputElement; + const tree = h("div", { class: "min-h-0 flex-1 overflow-y-auto p-2", dataset: { fileFolderTree: "" } }); + const main = h("div", { class: "min-h-0 flex-1 overflow-y-auto", dataset: { fileDirectory: "" } }); + 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; + const open = (entry: ChannelFile): void => { + if (entry.kind === "directory") { currentPath = entry.path; selected = null; void load(); return; } + const target = coworkPath(entry.path); + if (target && onOpenCowork) { onOpenCowork(entry.path); return; } + void openAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(entry.path)}`).catch((error) => appAlert((error as Error).message)); + }; + const mutate = async (action: "rename" | "move" | "duplicate" | "delete"): Promise => { + if (!selected) return; + try { + if (action === "rename") { + const name = await appPrompt(`Rename ${selected.kind === "directory" ? "folder" : "file"}`, selected.name); + if (!name || name === selected.name) return; + await api(`/api/channels/${channelId}/files/entries`, { method: "PATCH", body: { path: selected.path, name } }); + } else if (action === "move") { + const parent = await appPrompt("Move to folder inside /workspace (leave empty for the top level)", selected.path.split("/").slice(0, -1).join("/")); + if (parent == null) return; + await api(`/api/channels/${channelId}/files/entries`, { method: "PATCH", body: { path: selected.path, parent } }); + } else if (action === "duplicate") { + await api(`/api/channels/${channelId}/files/duplicate`, { body: { path: selected.path } }); + } else { + if (!(await appConfirm(`Delete “${selected.name}”${selected.kind === "directory" ? " and everything inside it" : ""}? This cannot be undone.`))) return; + await api(`/api/channels/${channelId}/files/entries`, { method: "DELETE", body: { path: selected.path } }); + } + selected = null; status.textContent = ""; await load(); + } catch (error) { status.textContent = (error as Error).message; } + }; + 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; + 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(); }, + }, h("span", { class: currentPath === directory.path ? "text-accent" : "text-faint" }, icon(currentPath === directory.path ? "folderOpen" : "folder", 15)), h("span", { class: "truncate" }, directory.name))); + } + }; + const drawInfo = (): void => { + clear(info); + if (!selected) { info.append(h("div", { class: "grid h-full place-items-center text-center text-xs leading-5 text-faint" }, "Select an item to see details and actions.")); return; } + info.append(h("div", { class: "mx-auto grid h-14 w-14 place-items-center rounded-xl bg-accent-soft text-accent" }, workspaceIcon(selected, 28)), + h("h3", { class: "mt-3 break-words text-sm font-semibold text-fg" }, selected.name), + h("dl", { class: "mt-4 space-y-3 text-xs" }, + h("div", {}, h("dt", { class: "text-faint" }, "Kind"), h("dd", { class: "mt-0.5 text-muted" }, selected.kind === "directory" ? "Folder" : "File")), + h("div", {}, h("dt", { class: "text-faint" }, "Location"), h("dd", { class: "mt-0.5 break-all font-mono text-muted" }, `/workspace/${selected.path}`)), + h("div", {}, h("dt", { class: "text-faint" }, "Size"), h("dd", { class: "mt-0.5 text-muted" }, selected.kind === "directory" ? "—" : formatBytes(selected.size))), + h("div", {}, h("dt", { class: "text-faint" }, "Modified"), h("dd", { class: "mt-0.5 text-muted" }, timeLabel(selected.modified)))), + h("div", { class: "mt-5 grid gap-2" }, + h("button", { class: "btn-primary text-xs", type: "button", onclick: () => open(selected!) }, selected.kind === "directory" ? "Open folder" : coworkPath(selected.path) && onOpenCowork ? "Open in Cowork" : "Open"), + 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, + h("button", { class: "btn-ghost text-xs text-danger", type: "button", onclick: () => { void mutate("delete"); } }, "Delete"))); + }; + const load = async (): Promise => { const requestedPath = currentPath; - void api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`).then((result) => { - currentPath = result.path ?? requestedPath; - const crumbs = h("nav", { class: "flex min-w-0 items-center gap-1 overflow-x-auto font-mono text-xs", "aria-label": "File breadcrumbs", dataset: { fileBreadcrumbs: "" } }); + 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`), + ]); + if (requestedPath !== currentPath) return; + currentPath = result.path ?? requestedPath; heading.textContent = `/workspace${currentPath ? `/${currentPath}` : ""}`; main.dataset.fileDirectory = currentPath || "/"; + drawTree(folders.directories); + clear(crumbs); const segments = currentPath ? currentPath.split("/") : []; const addCrumb = (label: string, path: string): void => { if (crumbs.childNodes.length) crumbs.append(h("span", { class: "text-faint" }, "/")); - crumbs.append(h("button", { class: `shrink-0 rounded px-1.5 py-1 ${path === currentPath ? "text-fg" : "text-accent hover:bg-hover"}`, type: "button", onclick: () => { currentPath = path; load(); } }, label)); + crumbs.append(h("button", { class: `shrink-0 rounded px-1.5 py-1 ${path === currentPath ? "text-fg" : "text-accent hover:bg-hover"}`, type: "button", onclick: () => { currentPath = path; selected = null; void load(); } }, label)); }; - addCrumb("workspace", ""); - segments.forEach((_segment, index) => addCrumb(segments[index], segments.slice(0, index + 1).join("/"))); - - const list = h("div", { class: "overflow-hidden rounded-lg border border-line bg-surface", dataset: { fileDirectory: currentPath || "/" } }); - if (!result.files.length) list.append(empty("This folder is empty", "Upload a file, create a folder, or let the resident agent add something here.")); - for (const file of result.files) { - const isFolder = file.kind === "directory"; - const openFile = (): void => { void openAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(file.path)}`).catch((error) => appAlert((error as Error).message)); }; - list.append(h("div", { class: "flex items-center gap-3 border-b border-line px-3 py-2.5 last:border-0", dataset: { filePath: file.path, fileKind: file.kind } }, - h("button", { class: `flex min-w-0 flex-1 items-center gap-3 text-left ${isFolder ? "hover:text-accent" : ""}`, type: "button", onclick: () => { if (isFolder) { currentPath = file.path; load(); } else openFile(); } }, - h("span", { class: `grid h-8 w-8 shrink-0 place-items-center rounded ${isFolder ? "bg-hover text-muted" : "bg-accent-soft text-accent"}` }, icon("file", 16)), - h("span", { class: "min-w-0 flex-1" }, - h("span", { class: "block truncate font-mono text-sm text-fg" }, file.name), - h("span", { class: "block truncate text-xs text-muted" }, isFolder ? "Folder" : `${formatBytes(file.size)} · changed ${timeLabel(file.modified)}`))), - !isFolder ? h("div", { class: "flex shrink-0 items-center gap-1.5" }, - h("button", { class: "btn-subtle text-xs", type: "button", onclick: openFile }, "Open"), - h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(file.path)}&download=1`, file.name).catch((error) => appAlert((error as Error).message)); } }, "Download")) : null)); - } - - const fileInput = h("input", { type: "file", multiple: true, class: "hidden" }) as HTMLInputElement; - const status = h("span", { class: "min-h-5 flex-1 text-xs text-muted", role: "status" }); - const uploadButton = h("button", { class: "btn-primary text-sm", type: "button", onclick: () => fileInput.click() }, icon("plus"), "Upload here") as HTMLButtonElement; - const folderButton = h("button", { class: "btn-subtle text-sm", type: "button", onclick: async () => { - const name = await appPrompt("Folder name"); - if (!name) return; - folderButton.setAttribute("disabled", "true"); - try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: currentPath, name } }); load(); } - catch (error) { status.textContent = (error as Error).message; folderButton.removeAttribute("disabled"); } - } }, icon("plus"), "New folder") as HTMLButtonElement; - fileInput.onchange = async () => { - const chosen = Array.from(fileInput.files || []); - if (!chosen.length) return; - uploadButton.disabled = true; status.textContent = `Uploading ${chosen.length} file${chosen.length === 1 ? "" : "s"} to /workspace${currentPath ? `/${currentPath}` : ""}…`; - try { - for (const file of chosen) { - const upload = await uploadFile(file); - await api(`/api/channels/${channelId}/files/upload`, { body: { ...upload, path: currentPath } }); - } - load(); - } catch (error) { status.textContent = (error as Error).message; uploadButton.disabled = false; } - }; - panelContent(container, "Files", "Browse the same folders the channel computer sees under /workspace.", h("div", {}, - h("div", { class: "mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-raised px-3 py-2" }, crumbs, h("div", { class: "flex-1" }), status, folderButton, uploadButton, fileInput), - list)); - }).catch((error) => panelError(container, error)); + addCrumb("workspace", ""); segments.forEach((segment, index) => addCrumb(segment, segments.slice(0, index + 1).join("/"))); + let files = result.files.filter((entry) => !filter || entry.name.toLowerCase().includes(filter)); + files = files.slice().sort((a, b) => Number(a.kind !== "directory") - Number(b.kind !== "directory") || (sort === "modified" ? b.modified - a.modified : sort === "size" ? b.size - a.size : a.name.localeCompare(b.name))); + clear(main); + 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), + }, 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); } }; - load(); + 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 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( + h("header", { class: "flex min-h-14 flex-wrap items-center gap-2 border-b border-line px-3 py-2 sm:px-4" }, h("span", { class: "text-accent" }, icon("folderOpen", 20)), heading, h("div", { class: "flex-1" }), status, h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void newFile(); } }, icon("plus", 14), "New file"), h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void newFolder(); } }, icon("folder", 14), "New folder"), h("button", { class: "btn-primary text-xs", type: "button", onclick: () => fileInput.click() }, "Upload"), fileInput), + 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 }); + clear(container); container.append(root); void load(); } type ChannelNoteView = { name: string; size: number; modified: number; content?: string }; @@ -748,7 +822,7 @@ function activityCard(item: ActivityItem): HTMLElement { const headline = h("div", { class: `flex gap-3 ${item.action_id ? "px-4 py-3" : "rounded-lg px-4 py-3"}` }, h("span", { class: `mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${activityStatusTone(item.status)}`, title: item.status || "complete" }), h("div", { class: "min-w-0 flex-1" }, - h("div", { class: "text-sm leading-5 text-fg" }, item.summary), + h("div", { class: "md text-sm leading-5 text-fg", html: md(item.summary) }), h("div", { class: "mt-1 text-xs text-muted" }, meta))); if (!item.action_id) return h("div", { class: `rounded-lg border ${border}` }, headline); diff --git a/src/client/cowork.ts b/src/client/cowork.ts new file mode 100644 index 0000000..f612e8b --- /dev/null +++ b/src/client/cowork.ts @@ -0,0 +1,257 @@ +import { api, type Channel, type ChannelFile, type Message } from "./api.ts"; +import { clear, color, h, icon, initials, md, timeLabel } from "./dom.ts"; +import { appAlert, appConfirm, appPrompt } from "./app.ts"; + +export type CoworkSection = "notes" | "whiteboards" | "code" | "docs" | "presentations"; + +type EditableFile = ChannelFile & { content: string }; +type SectionSession = { folder: string; path: string; content: string; saved: string; loaded: boolean; preview: boolean }; +type CoworkSurface = { node: HTMLElement; openPath: (path: string) => Promise; reload: () => Promise; setOpenThread: (callback: (root: Message) => void) => 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 surfaces = new Map(); +const pendingPaths = new Map(); + +export function stageCoworkPath(channelId: number, path: string): void { + pendingPaths.set(channelId, path.replace(/^\/?workspace\/?/, "").replace(/^\/+/, "")); +} + +function sectionForPath(path: string): CoworkSection { + const root = path.split("/")[0] as CoworkSection; + return SECTIONS.some((section) => section.id === root) ? root : "notes"; +} + +function fileIcon(file: ChannelFile, size = 17): SVGElement { + if (file.kind === "directory") return icon("folder", size); + const ext = file.name.split(".").pop()?.toLowerCase(); + if (["js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "sh", "html", "css", "json", "yaml", "yml", "sql"].includes(ext || "")) return icon("code", size); + if (file.name.includes(".whiteboard.")) return icon("board", size); + if (file.name.includes(".slides.")) return icon("presentation", size); + return icon("fileText", size); +} + +function starterContent(section: CoworkSection): string { + if (section === "whiteboards") return JSON.stringify({ version: 1, elements: [] }, null, 2); + if (section === "presentations") return JSON.stringify({ version: 1, slides: [{ title: "Untitled presentation", body: "" }] }, null, 2); + return ""; +} + +function visibleName(path: string): string { return path.split("/").pop() || path; } + +export function renderCowork(container: HTMLElement, channelId: number, channel: Channel, onOpenThread: (root: Message) => void, preserveExisting = false): void { + const cached = surfaces.get(channelId); + if (cached) { + cached.setOpenThread(onOpenThread); + clear(container); container.append(cached.node); + const staged = pendingPaths.get(channelId); pendingPaths.delete(channelId); + if (staged) void cached.openPath(staged); else if (!preserveExisting) void cached.reload(); + return; + } + + const sessions = new Map(); + for (const section of SECTIONS) sessions.set(section.id, { folder: section.folder, path: "", content: "", saved: "", loaded: false, preview: false }); + let section: CoworkSection = "notes"; + let filter = ""; + let agentOpen = false; + let chatTimer: number | null = null; + let chatRootId = 0; + let openThreadCallback = onOpenThread; + const shell = h("section", { class: "cowork-shell flex h-full min-h-[34rem] flex-col bg-surface", dataset: { coworkSurface: String(channelId) } }); + const sectionNav = h("nav", { class: "cowork-sections flex shrink-0 gap-1 overflow-x-auto border-b border-line bg-raised/25 px-3", "aria-label": "Cowork sections" }); + const breadcrumb = h("nav", { class: "flex min-w-0 flex-1 items-center gap-1 overflow-x-auto font-mono text-[11px]", "aria-label": "Cowork folder path" }); + const fileList = h("div", { class: "min-h-0 flex-1 overflow-y-auto p-2", dataset: { coworkFiles: "" } }); + const workspace = h("main", { class: "cowork-workspace relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-bg", dataset: { coworkViewport: "" } }); + 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 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 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 = ""; search.value = ""; void draw(); }, + }, icon(item.icon, 15), item.label)); + }; + + const drawBreadcrumb = (): void => { + clear(breadcrumb); + const session = activeSession(); + const segments = session.folder.split("/").filter(Boolean); + const add = (label: string, path: string): void => { + if (breadcrumb.childNodes.length) breadcrumb.append(h("span", { class: "text-faint" }, "/")); + breadcrumb.append(h("button", { class: path === session.folder ? "shrink-0 text-fg" : "shrink-0 text-accent hover:underline", type: "button", onclick: () => { session.folder = path; session.path = ""; void draw(); } }, label)); + }; + add("workspace", activeSection().folder); + segments.slice(1).forEach((segment, index) => add(segment, segments.slice(0, index + 2).join("/"))); + }; + + const saveFile = async (): Promise => { + const session = activeSession(); + if (!session.path || session.content === session.saved) return; + 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.content = result.file.content; session.saved = result.file.content; localStorage.removeItem(draftKey(session.path)); status.textContent = "Saved"; + } catch (error) { status.textContent = (error as Error).message; } + }; + + const textEditor = (session: SectionSession, mode: "notes" | "code" | "docs"): HTMLElement => { + const textarea = h("textarea", { + class: mode === "docs" ? "cowork-doc-page" : mode === "code" ? "cowork-code-editor" : "cowork-note-editor", + value: session.content, spellcheck: mode !== "code", "aria-label": `${activeSection().label} editor`, + }) as HTMLTextAreaElement; + const preview = h("div", { class: `md cowork-markdown-preview ${session.preview ? "" : "hidden"}`, html: md(session.content || "_This file is empty._") }); + textarea.classList.toggle("hidden", session.preview); + textarea.oninput = () => { session.content = textarea.value; localStorage.setItem(draftKey(session.path), session.content); status.textContent = "Unsaved changes"; if (session.preview) preview.innerHTML = md(session.content || "_This file is empty._"); }; + textarea.onkeydown = (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void saveFile(); } }; + const toolbar = h("div", { class: "cowork-editor-toolbar" }, + h("span", { class: "min-w-0 flex-1 truncate font-mono text-[11px] text-muted", title: `/workspace/${session.path}` }, `/workspace/${session.path}`), + mode !== "code" ? h("button", { class: `btn-subtle text-xs ${session.preview ? "bg-accent-soft" : ""}`, type: "button", onclick: () => { session.preview = !session.preview; void drawWorkspace(); } }, session.preview ? "Write" : "Preview") : null, + h("button", { class: "btn-primary text-xs", type: "button", disabled: session.content === session.saved, onclick: () => { void saveFile(); } }, "Save")); + 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" }, textarea, preview)); + }; + + const whiteboardEditor = (session: SectionSession): HTMLElement => { + type Element = { id: string; x: number; y: number; text: string; color: string }; + let data: { version: number; elements: Element[] }; + try { data = JSON.parse(session.content || "{}"); if (!Array.isArray(data.elements)) throw new Error(); } + catch { return h("div", { class: "grid flex-1 place-items-center p-8 text-center text-sm text-muted" }, "This file is not a supported 1Helm whiteboard."); } + const canvas = h("div", { class: "cowork-whiteboard-canvas", tabindex: 0, "aria-label": "Whiteboard canvas" }); + const commit = (): void => { session.content = JSON.stringify(data, null, 2); localStorage.setItem(draftKey(session.path), session.content); status.textContent = "Unsaved changes"; }; + const paint = (): void => { + clear(canvas); + for (const element of data.elements) { + const card = h("article", { class: "cowork-whiteboard-card", style: `left:${element.x}px;top:${element.y}px;border-color:${element.color}`, tabindex: 0 }, + h("textarea", { class: "h-full w-full resize-none bg-transparent text-sm leading-5 text-fg outline-none", value: element.text, "aria-label": "Whiteboard card text", oninput: (event: Event) => { element.text = (event.target as HTMLTextAreaElement).value; commit(); } }), + h("button", { class: "absolute right-1 top-1 grid h-6 w-6 place-items-center rounded text-faint hover:bg-hover hover:text-danger", type: "button", "aria-label": "Delete card", onclick: () => { data.elements = data.elements.filter((item) => item.id !== element.id); commit(); paint(); } }, icon("x", 12))); + let drag: { x: number; y: number } | null = null; + card.addEventListener("pointerdown", (event) => { if ((event.target as HTMLElement | null)?.closest("textarea,button")) return; drag = { x: event.clientX - element.x, y: event.clientY - element.y }; card.setPointerCapture(event.pointerId); }); + card.addEventListener("pointermove", (event) => { if (!drag) return; element.x = Math.max(8, event.clientX - drag.x); element.y = Math.max(8, event.clientY - drag.y); card.style.left = `${element.x}px`; card.style.top = `${element.y}px`; }); + card.addEventListener("pointerup", () => { if (drag) commit(); drag = null; }); + canvas.append(card); + } + }; + const addCard = (): void => { data.elements.push({ id: crypto.randomUUID(), x: 40 + data.elements.length * 18, y: 40 + data.elements.length * 18, text: "New idea", color: color(String(data.elements.length)) }); commit(); paint(); }; + paint(); + return h("div", { class: "flex min-h-0 flex-1 flex-col" }, h("div", { class: "cowork-editor-toolbar" }, h("span", { class: "flex-1 text-xs text-muted" }, "Drag cards anywhere on the canvas."), h("button", { class: "btn-subtle text-xs", onclick: addCard }, icon("plus", 13), "Card"), h("button", { class: "btn-primary text-xs", onclick: () => { void saveFile(); } }, "Save")), canvas); + }; + + const presentationEditor = (session: SectionSession): HTMLElement => { + type Slide = { title: string; body: string }; + let data: { version: number; slides: Slide[] }; + try { data = JSON.parse(session.content || "{}"); if (!Array.isArray(data.slides)) throw new Error(); } + catch { return h("div", { class: "grid flex-1 place-items-center p-8 text-center text-sm text-muted" }, "This file is not a supported 1Helm presentation."); } + let active = 0; + const root = h("div", { class: "flex min-h-0 flex-1 flex-col" }); + const commit = (): void => { session.content = JSON.stringify(data, null, 2); localStorage.setItem(draftKey(session.path), session.content); status.textContent = "Unsaved changes"; }; + const paint = (): void => { + clear(root); active = Math.min(active, Math.max(0, data.slides.length - 1)); + const thumbnails = h("aside", { class: "cowork-slide-strip" }); + data.slides.forEach((slide, index) => thumbnails.append(h("button", { class: `cowork-slide-thumb ${active === index ? "is-active" : ""}`, type: "button", onclick: () => { active = index; paint(); } }, h("span", { class: "text-[10px] text-faint" }, String(index + 1)), h("span", { class: "truncate text-xs font-semibold text-fg" }, slide.title || "Untitled slide")))); + const slide = data.slides[active] || { title: "", body: "" }; + const title = h("input", { class: "cowork-slide-title", value: slide.title, placeholder: "Slide title", oninput: (event: Event) => { slide.title = (event.target as HTMLInputElement).value; commit(); } }); + const body = h("textarea", { class: "cowork-slide-body", value: slide.body, placeholder: "Add text…", oninput: (event: Event) => { slide.body = (event.target as HTMLTextAreaElement).value; commit(); } }); + root.append(h("div", { class: "cowork-editor-toolbar" }, h("span", { class: "flex-1 text-xs text-muted" }, `${data.slides.length} slide${data.slides.length === 1 ? "" : "s"}`), h("button", { class: "btn-subtle text-xs", onclick: () => { data.slides.push({ title: "Untitled slide", body: "" }); active = data.slides.length - 1; commit(); paint(); } }, icon("plus", 13), "Slide"), h("button", { class: "btn-ghost text-xs text-danger", disabled: data.slides.length <= 1, onclick: () => { data.slides.splice(active, 1); active = Math.max(0, active - 1); commit(); paint(); } }, "Delete"), h("button", { class: "btn-primary text-xs", onclick: () => { void saveFile(); } }, "Save")), h("div", { class: "flex min-h-0 flex-1" }, thumbnails, h("div", { class: "cowork-slide-stage" }, h("article", { class: "cowork-slide" }, title, body)))); + }; + paint(); return root; + }; + + const drawWorkspace = async (): Promise => { + const session = activeSession(); clear(workspace); + if (!session.path) { + workspace.append(h("div", { class: "grid h-full place-items-center p-8 text-center" }, h("div", {}, h("span", { class: "mx-auto grid h-14 w-14 place-items-center rounded-xl bg-accent-soft text-accent" }, icon(activeSection().icon, 27)), h("h2", { class: "mt-4 font-display text-2xl text-fg" }, activeSection().label), h("p", { class: "mt-2 max-w-sm text-sm leading-6 text-muted" }, "Choose a file on the left or create one. Cowork edits the same files your channel agent sees in /workspace.")))); + return; + } + if (!session.loaded) { + 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(session.path)}`); + const local = localStorage.getItem(draftKey(session.path)); session.content = local ?? result.file.content; session.saved = result.file.content; session.loaded = true; + } catch (error) { 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); + } + if (section === "whiteboards") workspace.append(whiteboardEditor(session)); + else if (section === "presentations") workspace.append(presentationEditor(session)); + else workspace.append(textEditor(session, section === "code" ? "code" : section === "docs" ? "docs" : "notes")); + workspace.append(agentToggle); + }; + + const openPath = async (path: string): Promise => { + const normalized = path.replace(/^\/?workspace\/?/, "").replace(/^\/+/, ""); + section = sectionForPath(normalized); const session = activeSession(); session.path = normalized; session.folder = normalized.split("/").slice(0, -1).join("/") || activeSection().folder; session.loaded = false; + chatRootId = Number(localStorage.getItem(threadKey(normalized)) || 0); await draw(); + }; + + const loadFiles = async (): Promise => { + const session = activeSession(); + try { + const result = await api<{ files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(session.folder)}`); + clear(fileList); const visible = result.files.filter((file) => !filter || file.name.toLowerCase().includes(filter)); + if (!visible.length) fileList.append(h("p", { class: "px-2 py-8 text-center text-xs leading-5 text-faint" }, result.files.length ? "No matching files." : "This folder is empty.")); + for (const file of visible) fileList.append(h("button", { class: `group mb-0.5 flex min-h-10 w-full items-center gap-2 rounded-md px-2 text-left ${session.path === file.path ? "bg-accent-soft" : "hover:bg-hover"}`, type: "button", ondblclick: () => { if (file.kind === "directory") { session.folder = file.path; session.path = ""; void draw(); } else void openPath(file.path); }, onclick: () => { if (file.kind === "file") void openPath(file.path); } }, h("span", { class: file.kind === "directory" ? "text-muted" : "text-accent" }, fileIcon(file)), h("span", { class: "min-w-0 flex-1 truncate text-xs text-fg" }, file.name), file.kind === "file" ? h("span", { class: "font-mono text-[9px] text-faint" }, file.name.split(".").pop()?.toUpperCase() || "FILE") : null)); + } catch (error) { fileList.replaceChildren(h("p", { class: "p-3 text-xs text-danger" }, (error as Error).message)); } + }; + + const renderChatMessages = async (): Promise => { + const stream = agentPanel.querySelector("[data-cowork-chat-stream]"); if (!stream || !chatRootId) return; + const stick = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 60; const top = stream.scrollTop; + try { + const result = await api<{ root: Message; replies: Message[] }>(`/api/messages/${chatRootId}/thread`); + const signature = [result.root, ...result.replies].map((message) => `${message.id}:${message.body}`).join("|"); + if (stream.dataset.signature === signature) return; + stream.dataset.signature = signature; clear(stream); + for (const message of [result.root, ...result.replies]) stream.append(h("article", { class: `cowork-chat-message ${message.author.kind === "user" ? "is-user" : ""}` }, h("div", { class: "mb-1 text-[10px] font-semibold text-muted" }, message.author.kind === "user" ? "You" : message.author.name), h("div", { class: "md text-sm text-fg", html: md(message.body) }))); + requestAnimationFrame(() => { stream.scrollTop = stick ? stream.scrollHeight : top; }); + } catch { /* a deleted/archived thread stays quiet until the user starts another */ } + }; + + const drawAgent = (): void => { + agentPanel.classList.toggle("hidden", !agentOpen); agentPanel.classList.toggle("flex", agentOpen); agentToggle.setAttribute("aria-expanded", String(agentOpen)); + if (!agentOpen) { if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = null; return; } + 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 }) as HTMLTextAreaElement; + const send = async (): Promise => { + const message = input.value.trim(); if (!message || !session.path) return; + input.disabled = true; + try { + const body = chatRootId ? message : `@${channel.agent?.name || "agent"} ${message}\n\nWorking file: /workspace/${session.path}`; + const result = await api<{ message: Message }>(`/api/channels/${channelId}/messages`, { body: { body, parentId: chatRootId || null } }); + if (!chatRootId) { chatRootId = result.message.id; localStorage.setItem(threadKey(session.path), String(chatRootId)); } + input.value = ""; await renderChatMessages(); + } catch (error) { void appAlert((error as Error).message); } + finally { input.disabled = false; input.focus(); } + }; + 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; 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 and includes this file path."), 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"))))); + 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); + requestAnimationFrame(() => input.focus({ preventScroll: true })); + }; + + agentToggle.onclick = () => { agentOpen = !agentOpen; 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 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, h("div", { class: "min-h-9 border-t border-line px-3 py-2" }, status)); + shell.append(h("header", { class: "flex min-h-14 items-center gap-3 border-b border-line px-3 sm:px-4" }, h("div", { class: "min-w-0 flex-1" }, h("h2", { class: "font-display text-xl text-fg" }, "Cowork"), h("p", { class: "truncate text-xs text-muted" }, "Work directly in this channel's /workspace files"))), sectionNav, h("div", { class: "flex min-h-0 flex-1" }, rail, workspace, agentPanel)); + const surface: CoworkSurface = { node: shell, openPath, reload: async () => { await draw(); }, setOpenThread: (callback) => { openThreadCallback = callback; } }; surfaces.set(channelId, surface); clear(container); container.append(shell); + const staged = pendingPaths.get(channelId); pendingPaths.delete(channelId); if (staged) void openPath(staged); else void draw(); +} diff --git a/src/client/dom.ts b/src/client/dom.ts index 6117fa9..5d31381 100644 --- a/src/client/dom.ts +++ b/src/client/dom.ts @@ -174,6 +174,15 @@ const ICONS: Record = { sliders: '', thread: '', file: '', + folder: '', + folderOpen: '', + fileText: '', + image: '', + code: '', + presentation: '', + board: '', + search: '', + copy: '', paperclip: '', send: '', plus: '', diff --git a/src/client/mobile.ts b/src/client/mobile.ts index 1aa6610..8b12c4d 100644 --- a/src/client/mobile.ts +++ b/src/client/mobile.ts @@ -16,6 +16,7 @@ type StoredSession = { server: string; token: string }; const native = Capacitor.isNativePlatform(); let serverOrigin = ""; let launchFinishQueued = false; +let viewportInstalled = false; /** Keep the reserved native status surface visually joined to the app header. */ export async function syncNativeStatusSurface(): Promise { @@ -32,6 +33,39 @@ export const isNativeMobile = (): boolean => native; export const mobilePlatform = (): string => native ? Capacitor.getPlatform() : "web"; export const getServerOrigin = (): string => serverOrigin; +/** Track the truly visible viewport (browser chrome + keyboard), and let an + * upward conversation scroll dismiss composition like a native messenger. */ +export function installMobileViewportBehavior(): void { + if (viewportInstalled) return; + viewportInstalled = true; + const update = (): void => { + const viewport = window.visualViewport; + const height = Math.max(320, viewport?.height || window.innerHeight); + const keyboard = Math.max(0, window.innerHeight - height - (viewport?.offsetTop || 0)); + document.documentElement.style.setProperty("--app-viewport-height", `${height}px`); + document.documentElement.style.setProperty("--app-keyboard-height", `${keyboard}px`); + document.documentElement.classList.toggle("keyboard-visible", keyboard > 120); + window.dispatchEvent(new CustomEvent("1helm:viewport", { detail: { height, keyboard } })); + }; + window.visualViewport?.addEventListener("resize", update, { passive: true }); + window.visualViewport?.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update, { passive: true }); + update(); + const lastTop = new WeakMap(); + document.addEventListener("scroll", (event) => { + if (!matchMedia("(max-width: 767px)").matches) return; + const target = event.target as HTMLElement | null; + if (!target?.matches?.("#msgs,#threadmsgs,#channelview,[data-cowork-chat-stream]")) return; + const previous = lastTop.get(target) ?? target.scrollHeight; + const awayFromComposer = target.scrollTop < previous - 2 || target.scrollHeight - target.scrollTop - target.clientHeight > 48; + lastTop.set(target, target.scrollTop); + const active = document.activeElement as HTMLElement | null; + if (!awayFromComposer || !active?.closest?.(".composer-wrap")) return; + active.blur(); + if (native) void Keyboard.hide().catch(() => undefined); + }, true); +} + /** Reveal the first real native screen only after the WebView has painted it. */ export function finishNativeLaunch(): void { if (!native || launchFinishQueued) return; @@ -93,6 +127,7 @@ export function serverWebSocketUrl(path: string): string { } export async function initializeMobileRuntime(): Promise { + installMobileViewportBehavior(); if (!native) return localStorage.getItem("ctrl.token") || ""; const platform = mobilePlatform(); document.documentElement.dataset.nativeMobile = platform; diff --git a/src/client/settings.ts b/src/client/settings.ts index b8b6041..5e0a314 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -82,11 +82,11 @@ export function openSettings(tab: Tab = "agents"): void { ? [["admin", "Admin"], ["agents", "Agents"], ["skills", "Skills"], ["workflows", "Workflows"], ["connections", "Connections"], ["notifications", "Notifications"], ["feedback", "Feedback"], ["audit", "Audit"], ["domains", "Domains"], ["providers", "Providers"], ["computers", "Skipper computers"], ["members", "Members"]] : [["providers", "Providers"], ["notifications", "Notifications"]]; if (!tabs.length) return; - const tabBar = h("nav", { class: "grid w-full shrink-0 grid-cols-2 gap-1 border-b border-line bg-raised/30 p-3 sm:grid-cols-3 lg:w-64 lg:grid-cols-1 lg:border-b-0 lg:border-r lg:p-4", "aria-label": "Settings sections" }); + const tabBar = h("nav", { class: "settings-nav flex w-full shrink-0 gap-1 overflow-x-auto border-b border-line bg-raised/30 p-2 lg:w-64 lg:flex-col lg:overflow-y-auto lg:border-b-0 lg:border-r lg:p-4", "aria-label": "Settings sections" }); const draw = (t: Tab): void => { overlay.dataset.settingsTab = t; clear(tabBar); - tabs.forEach(([id, label]) => tabBar.append(h("button", { class: `rounded-lg px-3 py-2.5 text-left text-sm font-semibold transition ${t === id ? "bg-accent text-white shadow-sm" : "text-muted hover:bg-hover hover:text-fg"}`, type: "button", "aria-current": t === id ? "page" : undefined, onclick: () => draw(id) }, label))); + tabs.forEach(([id, label]) => tabBar.append(h("button", { class: `shrink-0 rounded-lg px-3 py-2 text-left text-xs font-semibold transition lg:w-full lg:py-2.5 lg:text-sm ${t === id ? "bg-accent text-white shadow-sm" : "text-muted hover:bg-hover hover:text-fg"}`, type: "button", "aria-current": t === id ? "page" : undefined, onclick: () => draw(id) }, label))); clear(bodyEl); const content = t === "admin" ? adminPanel() : t === "agents" ? agentsPanel() : t === "skills" ? skillsPanel() : t === "workflows" ? workflowsPanel() : t === "connections" ? connectionsPanel() : t === "notifications" ? notificationsPanel() : t === "feedback" ? feedbackPanel() : t === "audit" ? auditPanel() : t === "domains" ? domainsPanel() : t === "providers" ? providersPanel() : t === "computers" ? computersPanel() : membersPanel(); bodyEl.append(h("div", { class: `mx-auto w-full ${t === "providers" ? "max-w-7xl" : "max-w-5xl"}` }, h("div", { class: "mb-5" }, h("div", { class: "eyebrow text-accent" }, "Settings"), h("h1", { class: "font-display mt-1 text-3xl text-fg" }, tabs.find(([id]) => id === t)?.[1] || "Settings")), content)); @@ -178,6 +178,17 @@ function adminPanel(): HTMLElement { const file = h("input", { type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", class: "hidden" }) as HTMLInputElement; const presetColors = ["#c8552f", "#4f6d7a", "#8a6b7c", "#a67c52", "#7a6a4f", "#2e7d4f", "#2166b8", "#64748b"]; const presetRow = h("div", { class: "mt-2 flex flex-wrap gap-2" }); + const applyWorkspacePhoto = async (workspace: typeof S.workspace): Promise => { + // Fetch the fixed same-origin asset as authenticated bytes and give the DOM + // a browser-created blob URL. Neither an API-returned string nor session + // text is ever reinterpreted as a DOM destination. + S.workspace = workspace; + const response = await fetch(apiUrl("/api/workspace/photo"), { headers: { authorization: `Bearer ${getToken()}` }, cache: "no-store" }); + if (!response.ok) throw new Error("The updated workspace photo could not be loaded."); + const source = URL.createObjectURL(await response.blob()); + photo.src = source; + document.querySelectorAll(".logo-asset").forEach((image) => { image.src = source; }); + }; const applyPreset = async (hex: string): Promise => { status.textContent = "Applying workspace color…"; const canvas = document.createElement("canvas"); @@ -190,7 +201,7 @@ function adminPanel(): HTMLElement { const response = await fetch(apiUrl("/api/workspace/photo"), { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": "image/png" }, body: blob }); const result = await response.json().catch(() => ({})); if (!response.ok) { status.textContent = result.error || `HTTP ${response.status}`; return; } - S.workspace = result.workspace; photo.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); document.querySelectorAll(".logo-asset").forEach((image) => { image.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); }); status.textContent = "Workspace photo updated."; + await applyWorkspacePhoto(result.workspace); status.textContent = "Workspace photo updated."; }; for (const hex of presetColors) presetRow.append(h("button", { class: "h-8 w-8 rounded-lg border border-line shadow-sm transition hover:scale-105", style: `background:${hex}`, title: hex, onclick: () => { void applyPreset(hex); } })); file.onchange = async () => { @@ -199,7 +210,7 @@ function adminPanel(): HTMLElement { const response = await fetch(apiUrl("/api/workspace/photo"), { method: "POST", headers: { authorization: `Bearer ${getToken()}`, "content-type": image.type }, body: image }); const result = await response.json().catch(() => ({})); if (!response.ok) { status.textContent = result.error || `HTTP ${response.status}`; return; } - S.workspace = result.workspace; photo.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); document.querySelectorAll(".logo-asset").forEach((image) => { image.src = workspacePhotoSrc(S.workspace.photo_url, Date.now()); }); status.textContent = "Workspace photo updated."; + await applyWorkspacePhoto(result.workspace); status.textContent = "Workspace photo updated."; }; const save = async (): Promise => { try { S.workspace = (await api<{ workspace: typeof S.workspace }>("/api/workspace", { method: "PATCH", body: { name: name.value.trim().slice(0, 100), theme: theme.value } })).workspace; applyWorkspaceTheme(); document.querySelectorAll("[data-workspace-name]").forEach((node) => { node.textContent = S.workspace.name; }); status.textContent = "Workspace settings saved."; } diff --git a/src/client/styles.css b/src/client/styles.css index 2cebf8f..9521ca0 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -845,7 +845,80 @@ html.light .channel-unread-badge { .thread-messages > * { min-width: 0; max-width: 100%; } html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: none; } -#app, .app-shell, .workspace-shell { height: 100%; height: 100dvh; } +#app, .app-shell, .workspace-shell { height: 100%; height: var(--app-viewport-height, 100dvh); max-height: var(--app-viewport-height, 100dvh); } + +/* Listening and working are ambient state, not modal chrome. The motion is + deliberately small and falls back to a static halo for reduced motion. */ +.listening-indicator { + position: fixed; + z-index: 80; + top: max(.65rem, env(safe-area-inset-top)); + right: max(.75rem, env(safe-area-inset-right)); + display: flex; + align-items: center; + gap: .45rem; + padding: .36rem .55rem .36rem .38rem; + border: 1px solid color-mix(in srgb, var(--c-accent) 34%, var(--c-line)); + border-radius: 999px; + background: color-mix(in srgb, var(--c-surface) 88%, transparent); + box-shadow: 0 10px 32px -20px #000, 0 0 24px var(--c-glow); + backdrop-filter: blur(12px); + pointer-events: none; +} +.listening-orb { position: relative; display: flex; width: 1.55rem; height: 1.55rem; align-items: center; justify-content: center; gap: 2px; overflow: hidden; border-radius: 999px; background: var(--c-accent-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c-accent) 28%, transparent), 0 0 0 4px color-mix(in srgb, var(--c-accent) 6%, transparent); } +.listening-orb i { width: 2px; height: 6px; border-radius: 2px; background: var(--c-accent); animation: listening-wave .85s ease-in-out infinite alternate; } +.listening-orb i:nth-child(2) { height: 11px; animation-delay: -.35s; } +.listening-orb i:nth-child(3) { height: 8px; animation-delay: -.6s; } +@keyframes listening-wave { to { height: 3px; opacity: .55; } } +.agent-state-orb { position: relative; width: 9px; height: 9px; flex-shrink: 0; border-radius: 50%; background: var(--c-ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-ok) 15%, transparent); } +.agent-state-orb > span { display: none; position: absolute; inset: -2px; border: 1px solid currentColor; border-radius: inherit; } +.agent-state-orb.is-working { color: #fbbf24; background: #fbbf24; box-shadow: 0 0 0 3px color-mix(in srgb, #fbbf24 16%, transparent); } +.agent-state-orb.is-working > span { display: block; animation: agent-orb-ring 1.35s ease-out infinite; } +.agent-state-orb.is-working > span:nth-child(2) { animation-delay: -.65s; } +.agent-state-orb.is-waiting { background: #60a5fa; box-shadow: 0 0 0 3px color-mix(in srgb, #60a5fa 17%, transparent); } +@keyframes agent-orb-ring { from { transform: scale(.65); opacity: .8; } to { transform: scale(2); opacity: 0; } } + +/* Files: familiar navigation rail + dense, bounded content browser. Shape, + label, and stroke distinguish folders/files without relying on color. */ +#channelview:has(.file-browser), #channelview:has(.cowork-shell) { display: flex; flex-direction: column; overflow: hidden; } +.file-browser, .cowork-shell { flex: 1 1 auto; min-height: 0; } +.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: .5rem; padding: .75rem; } +.file-grid-item { display: flex; min-width: 0; min-height: 4rem; align-items: center; gap: .7rem; padding: .65rem; border: 1px solid var(--c-line); border-radius: .65rem; background: var(--c-surface); text-align: left; transition: border-color .12s ease, background .12s ease, transform .12s ease; } +.file-grid-item:hover { border-color: color-mix(in srgb, var(--c-accent) 40%, var(--c-line)); background: var(--c-hover); } +.file-grid-item:active { transform: translateY(1px); } +.file-grid-item.is-selected { border-color: color-mix(in srgb, var(--c-accent) 65%, var(--c-line)); background: var(--c-accent-soft); box-shadow: inset 3px 0 0 var(--c-accent); } +.file-grid-icon { display: grid; width: 2.5rem; height: 2.5rem; flex-shrink: 0; place-items: center; border: 1px solid var(--c-line); border-radius: .55rem; background: var(--c-raised); color: var(--c-muted); } +.file-grid-icon.is-folder { border-bottom-width: 3px; border-bottom-color: color-mix(in srgb, var(--c-accent) 55%, var(--c-line)); color: var(--c-accent); } +.file-grid-icon.is-file { border-radius: .35rem .65rem .35rem .35rem; } +.file-grid-kind { flex-shrink: 0; border: 1px solid var(--c-line); border-radius: 999px; padding: .15rem .35rem; font-family: var(--font-mono); font-size: 8px; letter-spacing: .08em; color: var(--c-faint); } + +/* Cowork uses one persistent three-pane shell for every file mode. */ +.cowork-section { display: inline-flex; min-height: 2.65rem; flex-shrink: 0; align-items: center; gap: .4rem; border-bottom: 2px solid transparent; padding: 0 .7rem; font-family: var(--font-mono); font-size: 10px; letter-spacing: .09em; text-transform: uppercase; color: var(--c-faint); } +.cowork-section:hover { color: var(--c-fg); } +.cowork-section.is-active { border-bottom-color: var(--c-accent); color: var(--c-fg); } +.cowork-workspace { background-image: linear-gradient(var(--c-grid) 1px, transparent 1px), linear-gradient(90deg, var(--c-grid) 1px, transparent 1px); background-size: 24px 24px; } +.cowork-editor-toolbar { display: flex; min-height: 3rem; flex-shrink: 0; align-items: center; gap: .5rem; border-bottom: 1px solid var(--c-line); background: color-mix(in srgb, var(--c-surface) 92%, transparent); padding: .45rem .7rem; } +.cowork-note-editor, .cowork-code-editor { min-height: 0; flex: 1 1 auto; resize: none; border: 0; background: color-mix(in srgb, var(--c-surface) 95%, transparent); padding: 1.25rem 1.5rem; color: var(--c-fg); outline: none; } +.cowork-note-editor { font-family: var(--font-mono); font-size: 14px; line-height: 1.7; } +.cowork-code-editor { font-family: var(--font-mono); font-size: 13px; line-height: 1.65; tab-size: 2; 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; resize: none; border: 1px solid var(--c-line); border-radius: .2rem; background: var(--c-surface); padding: clamp(1.5rem, 6vw, 4rem); color: var(--c-fg); box-shadow: 0 20px 70px -45px #000; outline: none; font-family: var(--font-sans); font-size: 15px; line-height: 1.75; } +.cowork-whiteboard-canvas { position: relative; min-width: 45rem; min-height: 32rem; flex: 1 1 auto; overflow: auto; background: radial-gradient(circle, color-mix(in srgb, var(--c-faint) 35%, transparent) 1px, transparent 1px); background-size: 20px 20px; } +.cowork-whiteboard-card { position: absolute; width: 13rem; height: 8rem; cursor: move; border: 2px solid var(--c-accent); border-radius: .65rem; background: color-mix(in srgb, var(--c-surface) 95%, transparent); padding: .65rem; box-shadow: 0 14px 35px -25px #000; } +.cowork-slide-strip { width: 11rem; flex-shrink: 0; overflow-y: auto; border-right: 1px solid var(--c-line); background: var(--c-raised); padding: .6rem; } +.cowork-slide-thumb { display: flex; width: 100%; min-height: 4.5rem; align-items: flex-start; gap: .4rem; margin-bottom: .5rem; border: 1px solid var(--c-line); border-radius: .45rem; background: var(--c-surface); padding: .45rem; text-align: left; } +.cowork-slide-thumb.is-active { border-color: var(--c-accent); box-shadow: 0 0 0 2px var(--c-accent-soft); } +.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 { display: flex; aspect-ratio: 16 / 9; width: min(55rem, 90%); flex-direction: column; justify-content: center; border: 1px solid var(--c-line); background: var(--c-surface); padding: 8%; box-shadow: 0 22px 70px -40px #000; } +.cowork-slide-title { width: 100%; border: 0; background: transparent; color: var(--c-fg); outline: none; font-family: var(--font-display); font-size: clamp(1.6rem, 4vw, 3rem); } +.cowork-slide-body { width: 100%; min-height: 0; flex: 1 1 auto; margin-top: 1rem; resize: none; border: 0; background: transparent; color: var(--c-muted); outline: none; font-size: clamp(.9rem, 1.6vw, 1.25rem); line-height: 1.6; } +.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); } +.cowork-chat-message { margin-right: 1.5rem; border: 1px solid var(--c-line); border-radius: .7rem; background: var(--c-raised); padding: .65rem .75rem; } +.cowork-chat-message.is-user { margin-right: 0; margin-left: 1.5rem; border-color: color-mix(in srgb, var(--c-accent) 30%, var(--c-line)); background: var(--c-accent-soft); } +.quick-note-panel { max-height: min(34rem, calc(var(--app-viewport-height, 100dvh) - 5rem)); } +.quick-note-content { height: 14rem; max-height: 14rem; overflow-y: auto; } .mobile-drawer { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); } @keyframes drawer-in { @@ -912,6 +985,21 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n padding-bottom: max(.75rem, env(safe-area-inset-bottom)); padding-left: max(.75rem, env(safe-area-inset-left)); } + .keyboard-visible .composer-wrap { padding-bottom: .35rem; } + .file-browser > header { align-items: flex-start; } + .file-browser > header [role="status"] { order: 10; width: 100%; } + .file-grid { grid-template-columns: minmax(0, 1fr); padding: .5rem; } + .file-grid-item { min-height: 3.6rem; } + .cowork-shell > header { min-height: 3.25rem; } + .cowork-files { width: min(42vw, 10rem); } + .cowork-agent { position: absolute; inset: 0; z-index: 15; width: 100%; max-width: none; } + .cowork-section { padding: 0 .55rem; font-size: 9px; } + .cowork-section svg { display: none !important; } + .cowork-doc-page { width: calc(100% - 1rem); margin: .5rem auto; padding: 1.25rem; } + .cowork-slide-strip { width: 6.5rem; } + .cowork-slide-stage { padding: .65rem; } + .cowork-slide { width: 100%; } + .quick-note-panel { right: .5rem; left: .5rem; width: auto; } #msgs, #threadmsgs, #channelview { padding-right: env(safe-area-inset-right); padding-left: env(safe-area-inset-left); @@ -1043,7 +1131,7 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n } @media (prefers-reduced-motion: reduce) { - .mobile-drawer { animation: none; } + .mobile-drawer, .listening-orb i, .agent-state-orb > span, .channel-working-dots > span { animation: none !important; } } /* xterm */ diff --git a/src/server/agents.ts b/src/server/agents.ts index 8785a5c..e819187 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, type Stats } from "node:fs"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { DATA_DIR, UPLOAD_DIR, now, q, q1, run, tx, type Row } from "./db.ts"; import { botView, resolveModel } from "./store.ts"; @@ -9,6 +9,9 @@ import { archiveChannelComputer, deleteChannelComputer, ensureChannelComputerRec const CHANNELS_DIR = join(DATA_DIR, "channels"); const WORLD_DIRS = ["workspace", "files", "state", "memory", "profile"]; +const COWORK_DIRS = ["notes", "whiteboards", "code", "docs", "presentations"]; +const PROTECTED_WORKSPACE_ROOTS = new Set([...COWORK_DIRS, "files"]); +const MAX_EDITABLE_FILE_BYTES = 5 * 1024 * 1024; const MEMORY_KINDS = new Set(["summary", "decision", "fact", "preference", "artifact_ref"]); const AGENT_COLORS = ["#C8552F", "#2166B8", "#2E7D4F", "#8A6B7C", "#A67C52", "#4F6D7A", "#7A6A4F", "#64748B"]; const randomAgentAvatar = (): string => { @@ -46,6 +49,13 @@ export function ensureChannelWorkspace(channelId: number): string { if (!channel) throw new Error("Channel workspace not found."); const root = channelRoot(channelId); for (const dir of WORLD_DIRS) mkdirSync(join(root, dir), { recursive: true }); + for (const dir of COWORK_DIRS) { + const path = join(root, "workspace", dir); + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + markWorkspaceDirty(channelId, `workspace/${dir}`, "upsert"); + } + } run("INSERT OR IGNORE INTO channel_workspaces (channel_id, root_ref, created) VALUES (?,?,?)", channelId, `channels/${channelId}`, now()); const profile = q1(`SELECT a.id, a.kind, a.name, a.display_name, a.status, p.purpose, p.instructions, p.memory_namespace, p.capability_policy FROM agents a LEFT JOIN agent_channels ac ON ac.agent_id=a.id @@ -588,6 +598,154 @@ export function createWorkspaceDirectory(channelId: number, parentInput: string, return { path, name, size: 0, modified: info.mtimeMs, kind: "directory" }; } +function workspaceEntry(channelId: number, input: string): { path: string; host: string; base: string; info: Stats } { + const path = normalizeWorkspaceDirectoryPath(input); + if (!path) throw new Error("Choose a file or folder inside /workspace."); + const resolved = workspaceApiPathToHost(channelId, path); + if (!existsSync(resolved.host) || lstatSync(resolved.host).isSymbolicLink()) throw new Error("File or folder not found."); + const relativeParts = relative(resolved.base, resolved.host).split(sep).filter(Boolean); + let cursor = resolved.base; + for (const part of relativeParts) { + cursor = join(cursor, part); + if (lstatSync(cursor).isSymbolicLink()) throw new Error("File or folder not found."); + } + const realBase = realpathSync(resolved.base); + const realHost = realpathSync(resolved.host); + if (realHost !== realBase && !realHost.startsWith(realBase + sep)) throw new Error("File or folder not found."); + return { ...resolved, info: lstatSync(resolved.host) }; +} + +function assertMutableWorkspaceEntry(path: string): void { + if (!path.includes("/") && PROTECTED_WORKSPACE_ROOTS.has(path)) { + throw new Error("This top-level workspace folder is part of 1Helm and cannot be renamed, moved, duplicated, or deleted."); + } +} + +function workspaceFileView(path: string, host: string): WorkspaceFile { + const info = statSync(host); + return { path, name: basename(host), size: info.isFile() ? info.size : 0, modified: info.mtimeMs, kind: info.isDirectory() ? "directory" : "file" }; +} + +function checkedWorkspaceText(input: string): string { + const content = String(input ?? ""); + if (Buffer.byteLength(content, "utf8") > MAX_EDITABLE_FILE_BYTES) throw new Error("Editable files are limited to 5 MB."); + return content; +} + +/** Create a text asset in the same /workspace tree used by agents and Terminal. */ +export function createWorkspaceFile(channelId: number, parentInput: string, nameInput: string, initialContent = ""): WorkspaceFile { + const parent = existingWorkspaceDirectory(channelId, parentInput); + const name = validateWorkspaceEntryName(nameInput); + const path = parent.path ? `${parent.path}/${name}` : name; + const target = resolve(parent.host, name); + if (dirname(target) !== parent.host || existsSync(target)) throw new Error("A file or folder with that name already exists."); + writeFileSync(target, checkedWorkspaceText(initialContent), { encoding: "utf8", flag: "wx" }); + markWorkspaceDirty(channelId, workspaceWorldPath(path), "upsert"); + return workspaceFileView(path, target); +} + +export function readWorkspaceTextFile(channelId: number, input: string): WorkspaceFile & { content: string } { + const entry = workspaceEntry(channelId, input); + if (!entry.info.isFile()) throw new Error("Choose a file to edit."); + if (entry.info.size > MAX_EDITABLE_FILE_BYTES) throw new Error("This file is too large to edit in 1Helm."); + const content = readFileSync(entry.host, "utf8"); + if (content.includes("\0")) throw new Error("This file type is not supported to view."); + return { ...workspaceFileView(entry.path, entry.host), content }; +} + +export function saveWorkspaceTextFile(channelId: number, input: string, nextContent: string): WorkspaceFile & { content: string } { + const entry = workspaceEntry(channelId, input); + if (!entry.info.isFile()) throw new Error("Choose a file to edit."); + const content = checkedWorkspaceText(nextContent); + writeFileSync(entry.host, content, "utf8"); + markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "upsert"); + return { ...workspaceFileView(entry.path, entry.host), content }; +} + +/** Rename and/or move one contained entry without ever replacing a destination. */ +export function moveWorkspaceEntry(channelId: number, input: string, parentInput?: string, nameInput?: string): WorkspaceFile { + const entry = workspaceEntry(channelId, input); + assertMutableWorkspaceEntry(entry.path); + const parentPath = parentInput == null ? entry.path.split("/").slice(0, -1).join("/") : normalizeWorkspaceDirectoryPath(parentInput); + const parent = existingWorkspaceDirectory(channelId, parentPath); + const name = nameInput == null ? basename(entry.path) : validateWorkspaceEntryName(nameInput); + const nextPath = parent.path ? `${parent.path}/${name}` : name; + if (nextPath === entry.path) return workspaceFileView(entry.path, entry.host); + if (entry.info.isDirectory() && (nextPath === entry.path || nextPath.startsWith(entry.path + "/"))) throw new Error("A folder cannot be moved inside itself."); + const target = resolve(parent.host, name); + if (dirname(target) !== parent.host || existsSync(target)) throw new Error("A file or folder with that name already exists."); + renameSync(entry.host, target); + markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "delete"); + markWorkspaceDirty(channelId, workspaceWorldPath(nextPath), "upsert"); + return workspaceFileView(nextPath, target); +} + +export function duplicateWorkspaceEntry(channelId: number, input: string): WorkspaceFile { + const entry = workspaceEntry(channelId, input); + assertMutableWorkspaceEntry(entry.path); + const parentPath = entry.path.split("/").slice(0, -1).join("/"); + const parent = existingWorkspaceDirectory(channelId, parentPath); + const original = basename(entry.path); + const dot = entry.info.isFile() ? original.lastIndexOf(".") : -1; + const stem = dot > 0 ? original.slice(0, dot) : original; + const ext = dot > 0 ? original.slice(dot) : ""; + let name = `${stem} copy${ext}`; + for (let suffix = 2; existsSync(resolve(parent.host, name)); suffix += 1) name = `${stem} copy ${suffix}${ext}`; + const path = parent.path ? `${parent.path}/${name}` : name; + const target = resolve(parent.host, name); + cpSync(entry.host, target, { recursive: entry.info.isDirectory(), errorOnExist: true, force: false, verbatimSymlinks: false, filter: (source) => !lstatSync(source).isSymbolicLink() }); + markWorkspaceDirty(channelId, workspaceWorldPath(path), "upsert"); + return workspaceFileView(path, target); +} + +export function deleteWorkspaceEntry(channelId: number, input: string): void { + const entry = workspaceEntry(channelId, input); + assertMutableWorkspaceEntry(entry.path); + rmSync(entry.host, { recursive: entry.info.isDirectory(), force: false }); + markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "delete"); +} + +/** A safe folder tree for the Files and Cowork navigation rails. */ +export function listWorkspaceDirectories(channelId: number): WorkspaceFile[] { + const result: WorkspaceFile[] = []; + const walk = (path: string): void => { + const directory = existingWorkspaceDirectory(channelId, path); + for (const entry of readdirSync(directory.host, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const child = directory.path ? `${directory.path}/${entry.name}` : entry.name; + const host = join(directory.host, entry.name); + result.push(workspaceFileView(child, host)); + walk(child); + } + }; + walk(""); + const uploads = join(ensureChannelWorkspace(channelId), "files"); + result.push(workspaceFileView("files", uploads)); + const walkUploads = (path: string): void => { + const directory = existingWorkspaceDirectory(channelId, path); + for (const entry of readdirSync(directory.host, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const child = `${directory.path}/${entry.name}`; + result.push(workspaceFileView(child, join(directory.host, entry.name))); + walkUploads(child); + } + }; + walkUploads("files"); + return result.sort((a, b) => a.path.localeCompare(b.path)); +} + +export function createQuickNote(channelId: number, titleInput: string, contentInput: string): ChannelNote { + const content = checkedNoteContent(contentInput); + const rawTitle = String(titleInput || "").trim(); + const title = rawTitle && !rawTitle.toLowerCase().endsWith(".md") ? `${rawTitle}.md` : rawTitle; + if (title) return createChannelNote(channelId, title, content); + for (let index = 1; index < 100_000; index += 1) { + try { return createChannelNote(channelId, `untitled-quick-note-${index}.md`, content); } + catch (error) { if (!/already exists/i.test((error as Error).message)) throw error; } + } + throw new Error("Could not allocate a Quick Note filename."); +} + export function importWorkspaceUpload(channelId: number, threadId: number | null, token: string, nameInput: string, createdBy: string, directoryInput = "files"): string | null { if (!/^[a-f0-9]{32,}$/.test(String(token || ""))) return null; const source = join(UPLOAD_DIR, token); @@ -792,8 +950,8 @@ export function attachWorkspaceFileToMessage( const token = randomBytes(20).toString("hex"); copyFileSync(absolute, join(UPLOAD_DIR, token)); const id = run( - "INSERT INTO attachments (message_id, name, mime, size, path) VALUES (?,?,?,?,?)", - messageId, name, mime, stat.size, token, + "INSERT INTO attachments (message_id, name, mime, size, path, workspace_path) VALUES (?,?,?,?,?,?)", + messageId, name, mime, stat.size, token, worldRelSafe(channelId, absolute), ).lastInsertRowid; const worldRel = worldRelSafe(channelId, absolute); diff --git a/src/server/bots.ts b/src/server/bots.ts index befbd46..dbdb749 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -216,6 +216,7 @@ function systemPromptTiers(bot: Row, agent: RuntimeAgent | undefined, channelId: visiting ? "You are a temporary thread guest. No shell, workspace, or durable-memory capability is attached to this invitation." : "You own an isolated persistent Linux computer for this channel. Its durable workspace is /workspace. The run_command tool executes there; files, memory, and other listed tools belong to this channel.", + visiting ? "" : "Where practical, use Markdown for notes and documents, plain text source files for code, .whiteboard.json for whiteboards, and .slides.json for presentations.", "The callable tools below are your current capabilities. Their implementations enforce authority and isolation boundaries.", ].filter(Boolean).join("\n\n"); const context = [ diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 605f9fa..8b91e37 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.16"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.17"; 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 0b60dde..cd02ed5 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -125,6 +125,7 @@ export function migrate(): void { addColumn("workspace", "default_model", "default_model TEXT NOT NULL DEFAULT ''"); addColumn("workspace", "photo_mime", "photo_mime TEXT NOT NULL DEFAULT ''"); addColumn("workspace", "theme", "theme TEXT NOT NULL DEFAULT 'graphite'"); + addColumn("attachments", "workspace_path", "workspace_path TEXT NOT NULL DEFAULT ''"); db.exec(` CREATE TABLE IF NOT EXISTS agent_turns ( id INTEGER PRIMARY KEY, @@ -1004,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.16"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.17"); // 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 ce10d13..aa9694c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,17 +20,24 @@ import { archiveChannel, channelWorkspace, createChannelNote, + createQuickNote, + createWorkspaceFile, createWorkspaceDirectory, deleteChannelWorld, ensureChannelWorkspace, ensureThread, importAttachment, importWorkspaceUpload, + deleteWorkspaceEntry, + duplicateWorkspaceEntry, listChannelNotes, listWorkspaceDirectory, + listWorkspaceDirectories, + moveWorkspaceEntry, normalizeChannelName, provisionChannelWithComputer, readChannelNote, + readWorkspaceTextFile, recordMemory, refreshThreadSummary, renameChannel, @@ -38,6 +45,7 @@ import { resolveWorldFile, restoreChannel, saveChannelNote, + saveWorkspaceTextFile, syncWorkspaceArtifacts, threadIdForRoot, updateChannelPurpose, @@ -510,8 +518,8 @@ function postMessage(channelId: number, user: Row, text: string, parentId: numbe for (const upload of uniqueUploads) { if (!/^[a-f0-9]{32,}$/.test(upload.token) || !existsSync(join(UPLOAD_DIR, upload.token))) continue; const size = statSync(join(UPLOAD_DIR, upload.token)).size; - run("INSERT INTO attachments (message_id, name, mime, size, path) VALUES (?,?,?,?,?)", id, upload.name.slice(0, 255), upload.mime.slice(0, 255), size, upload.token); - if (!['collab', 'human'].includes(String(channel.kind))) importAttachment(channelId, threadId, upload.token, upload.name, "human"); + const workspacePath = !['collab', 'human'].includes(String(channel.kind)) ? importAttachment(channelId, threadId, upload.token, upload.name, "human") : null; + run("INSERT INTO attachments (message_id, name, mime, size, path, workspace_path) VALUES (?,?,?,?,?,?)", id, upload.name.slice(0, 255), upload.mime.slice(0, 255), size, upload.token, workspacePath || ""); } refreshThreadSummary(actualRootId); const msg = serializeMessage(id)!; @@ -1440,6 +1448,15 @@ const server = createServer(async (req, res) => { sendToUsers([Number(user.id)], { type: "channel_update", channel: channelMetaView(row, user) }); return json(res, 200, { ok: true, favorite, channel }); } + const quickNote = p.match(/^\/api\/channels\/(\d+)\/notes\/quick$/); + if (quickNote && m === "POST") { + const channelId = Number(quickNote[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + try { + const b = await jbody(req); + return json(res, 201, { note: createQuickNote(channelId, String(b.title || ""), String(b.content || "")) }); + } catch (error) { return json(res, 400, { error: (error as Error).message }); } + } const channelNotes = p.match(/^\/api\/channels\/(\d+)\/notes(?:\/([^/]+))?$/); if (channelNotes) { const channelId = Number(channelNotes[1]); @@ -1466,16 +1483,48 @@ const server = createServer(async (req, res) => { return json(res, 405, { error: "Method not allowed." }); } const channelDirectories = p.match(/^\/api\/channels\/(\d+)\/files\/directories$/); - if (channelDirectories && m === "POST") { + if (channelDirectories && (m === "GET" || m === "POST")) { const channelId = Number(channelDirectories[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." }); - const b = await jbody(req); try { 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 worldEntry = p.match(/^\/api\/channels\/(\d+)\/files\/entries$/); + if (worldEntry && ["POST", "PATCH", "DELETE"].includes(m)) { + const channelId = Number(worldEntry[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + try { + const b = await jbody(req); + if (m === "POST") return json(res, 201, { file: createWorkspaceFile(channelId, String(b.parent || ""), String(b.name || ""), String(b.content || "")) }); + if (m === "PATCH") return json(res, 200, { entry: moveWorkspaceEntry(channelId, String(b.path || ""), "parent" in b ? String(b.parent || "") : undefined, "name" in b ? String(b.name || "") : undefined) }); + deleteWorkspaceEntry(channelId, String(b.path || "")); + return json(res, 200, { ok: true }); + } catch (error) { return json(res, /not found/i.test((error as Error).message) ? 404 : 400, { error: (error as Error).message }); } + } + const duplicateEntry = p.match(/^\/api\/channels\/(\d+)\/files\/duplicate$/); + if (duplicateEntry && m === "POST") { + const channelId = Number(duplicateEntry[1]); + if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); + try { const b = await jbody(req); return json(res, 201, { entry: duplicateWorkspaceEntry(channelId, String(b.path || "")) }); } + catch (error) { return json(res, 400, { error: (error as Error).message }); } + } + const worldText = p.match(/^\/api\/channels\/(\d+)\/files\/text$/); + if (worldText && (m === "GET" || m === "PATCH" || m === "PUT")) { + 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) }); + 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 worldFile = p.match(/^\/api\/channels\/(\d+)\/files\/content$/); if (worldFile && m === "GET") { const channelId = Number(worldFile[1]); diff --git a/src/server/photon.ts b/src/server/photon.ts index 3c25796..fa512a0 100644 --- a/src/server/photon.ts +++ b/src/server/photon.ts @@ -110,7 +110,10 @@ export function photonConversations(ownerUserId: number): Record'' AND m.body<>'_Working…_' ORDER BY m.id DESC LIMIT 1) last_body, (SELECT COUNT(*) FROM messages WHERE photon_conversation_id=pc.id AND trim(body)<>'' AND body<>'_Working…_') message_count FROM photon_conversations pc JOIN threads t ON t.id=pc.thread_id - WHERE pc.channel_id=? ORDER BY pc.updated DESC,pc.id DESC`, main.id); + WHERE pc.channel_id=? ORDER BY pc.updated DESC,pc.id DESC`, main.id).map((conversation) => ({ + ...conversation, + title: /^\[?Photon\b|^Photon message from\b/i.test(String(conversation.title || "")) ? "Text with Skipper" : conversation.title, + })); } export function photonConversation(ownerUserId: number, conversationId: number): Record | null { @@ -246,7 +249,9 @@ export async function deliverPhotonEvent(event: PhotonEvent): Promise { } let conversation = activePhotonConversation(event.sender); let rootMessageId = Number(conversation?.root_message_id || 0); - const body = `[Photon iMessage from ${event.sender}]\n${event.text}`; + // Source/direction/sender already live in photon_messages. Keep the visible + // transcript human and avoid repeating a robotic transport label + phone number. + const body = event.text; const messageId = createMessage({ channelId, parentId: rootMessageId || null, botId: null, body }); run("UPDATE messages SET system_message=1 WHERE id=?", messageId); if (!rootMessageId) { diff --git a/src/server/store.ts b/src/server/store.ts index 9444da6..5d3486f 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -87,7 +87,7 @@ export function serializeMessage(id: number): Row | undefined { name: (q1("SELECT name FROM bots WHERE id=?", m.bot_id)?.name as string) || "agent", } : { kind: "user", id: m.user_id, name: (q1("SELECT display FROM users WHERE id=?", m.user_id)?.display as string) || "user" }; - const attachments = q("SELECT id, name, mime, size FROM attachments WHERE message_id=?", id); + const attachments = q("SELECT id, name, mime, size, workspace_path FROM attachments WHERE message_id=?", id); // A transient Working placeholder is visible activity, not a completed // reply. Compute the user-facing total from durable reply bodies so it can // never be inflated by reconnects or placeholder creation. diff --git a/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index ba88d70..273278a 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -120,11 +120,11 @@ try { ok(brand.title.includes("1Helm") && brand.appName === "1Helm" && !brand.body.includes("1Herd"), "the browser presents the product as 1Helm throughout"); ok(brand.favicon === "/brand/1helm-sailboat.png" && brand.workspaceLogo === "/brand/1helm-sailboat.png", "the sailboat is the web favicon and default customizable workspace image"); - await page.goto(`${base}/c/${channel.slug}/notes`, { waitUntil: "networkidle0" }); - await page.click('[data-note-name="Quiet refresh proof.md"]'); - await page.waitForFunction(() => document.querySelector('[aria-label="Note content"]')?.value.includes("Start here")); + await page.goto(`${base}/c/${channel.slug}/cowork`, { waitUntil: "networkidle0" }); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent?.includes("Quiet refresh proof.md"))?.click()); + await page.waitForFunction(() => document.querySelector('[aria-label="Notes editor"]')?.value.includes("Start here")); await page.evaluate(() => { - const editor = document.querySelector('[aria-label="Note content"]'); + const editor = document.querySelector('[aria-label="Notes editor"]'); editor.value += "\n\nUnsaved words survive."; editor.focus(); editor.setSelectionRange(4, 11); editor.dispatchEvent(new Event("input")); window.__briefNoteEditor = editor; @@ -133,15 +133,14 @@ try { await page.evaluate(() => [...document.querySelectorAll("button")].find((button) => /Switch to/.test(button.title))?.click()); await page.waitForFunction((prior) => document.documentElement.className !== prior, {}, themeBeforeNotesRefresh); const durableNote = await page.evaluate(() => { - const editor = document.querySelector('[aria-label="Note content"]'); - return { sameNode: editor === window.__briefNoteEditor, value: editor?.value, focused: document.activeElement === editor, start: editor?.selectionStart, end: editor?.selectionEnd, toolbar: document.querySelector('[aria-label="Note formatting"]')?.textContent }; + const editor = document.querySelector('[aria-label="Notes editor"]'); + return { sameNode: editor === window.__briefNoteEditor, value: editor?.value, focused: document.activeElement === editor, start: editor?.selectionStart, end: editor?.selectionEnd }; }); ok(durableNote.sameNode && durableNote.value.endsWith("Unsaved words survive.") && durableNote.focused && durableNote.start === 4 && durableNote.end === 11, "shell refreshes preserve the exact note editor node, unsaved draft, focus, and selection"); - ok(/H.*B.*I.*List.*Code.*Link/.test(durableNote.toolbar || ""), "notes expose practical Markdown formatting controls"); - await page.click('[data-note-preview-toggle]'); - await page.waitForFunction(() => Boolean(document.querySelector('[data-note-preview]') && !document.querySelector('[data-note-preview]').classList.contains("hidden"))); - ok(await page.$eval('[data-note-preview]', (element) => /Unsaved words survive/.test(element.textContent)), "note preview renders the current unsaved Markdown draft"); + await page.evaluate(() => [...document.querySelectorAll('.cowork-editor-toolbar button')].find((button) => button.textContent?.trim() === "Preview")?.click()); + await page.waitForSelector('.cowork-markdown-preview:not(.hidden)'); + ok(await page.$eval('.cowork-markdown-preview', (element) => /Unsaved words survive/.test(element.textContent)), "Cowork note preview renders the current unsaved Markdown draft"); await api(`/api/channels/${channel.id}`, { method: "PATCH", body: { purpose: "A deliberately long channel description that must remain one calm truncated line even when a large-monitor user narrows the remote application window. ".repeat(5) } }, token); await page.setViewport({ width: 1280, height: 760 }); @@ -173,20 +172,21 @@ try { }); ok(phoneHeader.twoRows && phoneHeader.rightAligned && phoneHeader.controlsFit && phoneHeader.pageFits, `phone channel chrome uses a safe title row plus right-aligned 44px actions without horizontal overflow (${JSON.stringify(phoneHeader)})`); - await page.evaluate(() => document.querySelector('[data-notes-header]')?.click()); - await page.waitForFunction(() => [...document.querySelectorAll('[data-notes-surface]')].some((element) => element.getBoundingClientRect().width > 0)); + await page.evaluate(() => document.querySelector('[data-quick-note-header]')?.click()); + await page.waitForSelector('[role="dialog"][aria-label="Quick Note"]'); const phoneNotes = await page.evaluate(() => { - const surface = [...document.querySelectorAll("[data-notes-surface]")].find((element) => element.getBoundingClientRect().width > 0); - const buttons = [...(surface?.querySelectorAll(":scope > div:first-child button") || [])]; + const surface = document.querySelector('[role="dialog"][aria-label="Quick Note"]'); + const buttons = [...(surface?.querySelectorAll("button") || [])]; return { - visible: buttons.length >= 2 && buttons.every((button) => { + visible: buttons.length >= 4 && buttons.every((button) => { const rect = button.getBoundingClientRect(); return rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight; }), pageFits: document.documentElement.scrollWidth <= document.documentElement.clientWidth, }; }); - ok(phoneNotes.visible && phoneNotes.pageFits, "phone Notes keeps New note and Close visible inside the matching surface top bar"); + ok(phoneNotes.visible && phoneNotes.pageFits, "phone Quick Note keeps dictation, collapse, Discard, and Save visible inside the viewport"); + await page.evaluate(() => document.querySelector('[aria-label="Collapse Quick Note"]')?.click()); await page.setViewport({ width: 1280, height: 760, deviceScaleFactor: 1, isMobile: false, hasTouch: false }); await page.goto(`${base}/c/main/texts`, { waitUntil: "networkidle0" }); @@ -339,12 +339,11 @@ try { await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Files"))?.click()); await page.waitForSelector('#channelview [data-file-path="README.md"]'); - const fileActions = await page.$$eval("#channelview button", (buttons) => buttons.map((button) => button.textContent?.trim()).filter((text) => text === "Open" || text === "Download")); + await page.click('#channelview [data-file-path="README.md"]'); + await page.waitForFunction(() => document.querySelector('[data-file-metadata]')?.textContent?.includes("README.md")); + const fileActions = await page.$$eval("[data-file-metadata] button", (buttons) => buttons.map((button) => button.textContent?.trim()).filter((text) => text === "Open" || text === "Download")); ok(fileActions.includes("Open") && fileActions.includes("Download"), "Files exposes explicit authenticated Open and Download actions"); - await page.evaluate(() => { - const readmeRow = document.querySelector('#channelview [data-file-path="README.md"]'); - [...(readmeRow?.querySelectorAll("button") || [])].find((button) => button.textContent?.trim() === "Open")?.click(); - }); + await page.evaluate(() => [...document.querySelectorAll('[data-file-metadata] button')].find((button) => button.textContent?.trim() === "Open")?.click()); await page.waitForSelector('[role="dialog"][aria-label^="Preview "]'); ok(await page.$eval('[role="dialog"]', (dialog) => /README\.md/.test(dialog.textContent || "") && /Download/.test(dialog.textContent || "")), "Files opens an authenticated in-app preview with its own download action"); await page.evaluate(() => [...document.querySelectorAll('[role="dialog"] button')].find((button) => button.textContent?.trim() === "Close")?.click()); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 9b2398d..7b4fd95 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.16"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.17"); 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 93a55e3..ebdcab5 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -70,6 +70,7 @@ test("notes reject a symlinked notes directory", () => { addChannel(902, "symlink-note-test"); const outside = join(dataDir, "outside-notes"); mkdirSync(outside); + rmSync(join(dataDir, "channels", "902", "workspace", "notes"), { recursive: true }); symlinkSync(outside, join(dataDir, "channels", "902", "workspace", "notes")); assert.throws(() => agents.listChannelNotes(902), /not a safe directory/); }); @@ -96,7 +97,7 @@ test("directory listing is navigable, direct-child only, folder-first, and symli const rootListing = agents.listWorkspaceDirectory(901, ""); assert.equal(rootListing.path, ""); - assert.deepEqual(rootListing.files.map(({ path }) => path), ["files", "notes", "projects"]); + assert.deepEqual(rootListing.files.map(({ path }) => path), ["code", "docs", "files", "notes", "presentations", "projects", "whiteboards"]); const projectListing = agents.listWorkspaceDirectory(901, "/workspace/projects"); assert.deepEqual(projectListing.files.map(({ path }) => path), ["projects/site", "projects/brief.txt"]); assert.ok(!projectListing.files.some(({ path }) => path.includes("site/")), "nested descendants are not flattened into the current folder"); @@ -133,16 +134,41 @@ test("uploads target the selected folder, retain containment, and keep legacy fi assert.ok(q("SELECT relative_path FROM channel_workspace_changes WHERE channel_id=?", 901).some((row) => row.relative_path === "workspace/projects/site/report.md")); }); +test("workspace files support contained CRUD and protected Cowork roots", () => { + const created = agents.createWorkspaceFile(901, "docs", "proposal.md", "# Proposal\n"); + assert.equal(created.path, "docs/proposal.md"); + assert.equal(agents.readWorkspaceTextFile(901, created.path).content, "# Proposal\n"); + assert.equal(agents.saveWorkspaceTextFile(901, created.path, "# Proposal\n\nReady.\n").content, "# Proposal\n\nReady.\n"); + const renamed = agents.moveWorkspaceEntry(901, created.path, undefined, "launch-proposal.md"); + assert.equal(renamed.path, "docs/launch-proposal.md"); + const duplicated = agents.duplicateWorkspaceEntry(901, renamed.path); + assert.equal(duplicated.path, "docs/launch-proposal copy.md"); + agents.deleteWorkspaceEntry(901, duplicated.path); + assert.throws(() => agents.readWorkspaceTextFile(901, duplicated.path), /not found/); + const nested = agents.createWorkspaceDirectory(901, "docs", "plans"); + assert.equal(agents.moveWorkspaceEntry(901, renamed.path, nested.path).path, "docs/plans/launch-proposal.md"); + assert.throws(() => agents.moveWorkspaceEntry(901, "docs", "", "renamed-docs"), /top-level workspace folder/); + assert.throws(() => agents.deleteWorkspaceEntry(901, "notes"), /top-level workspace folder/); +}); + +test("quick notes choose collision-safe untitled filenames", () => { + assert.equal(agents.createQuickNote(901, "", "first").name, "untitled-quick-note-1.md"); + assert.equal(agents.createQuickNote(901, "", "second").name, "untitled-quick-note-2.md"); + assert.equal(agents.createQuickNote(901, "Captain thought", "third").name, "Captain thought.md"); + assert.equal(agents.createQuickNote(901, "release.v2", "fourth").name, "release.v2.md"); +}); + test("agent attachment MIME recognizes MP3 and M4A audio", () => { assert.equal(agents.attachmentMimeForName("voice.MP3"), "audio/mpeg"); assert.equal(agents.attachmentMimeForName("meeting.m4a"), "audio/mp4"); assert.equal(agents.attachmentMimeForName("archive.bin"), "application/octet-stream"); }); -test("channel UI source exposes route-shaped Notes, folder navigation, audio preview, and compact global threads contracts", () => { +test("channel UI source exposes file-backed Cowork, traditional Files, audio preview, and compact global threads contracts", () => { const channelSource = readFileSync(join(root, "src", "client", "channel.ts"), "utf8"); const apiSource = readFileSync(join(root, "src", "client", "api.ts"), "utf8"); const appSource = readFileSync(join(root, "src", "client", "app.ts"), "utf8"); + const coworkSource = readFileSync(join(root, "src", "client", "cowork.ts"), "utf8"); assert.match(channelSource, /export function renderNotes\(/); assert.match(channelSource, /const noteSurfaces = new Map/, "note editor nodes survive shell refreshes"); assert.match(channelSource, /data(?:set)?: \{ notePreviewToggle: "" \}/, "Notes includes a Markdown preview mode"); @@ -152,12 +178,16 @@ test("channel UI source exposes route-shaped Notes, folder navigation, audio pre assert.match(channelSource, /\/files\/directories/); assert.match(channelSource, /body: \{ \.\.\.upload, path: currentPath \}/); assert.match(channelSource, /data(?:set)?: \{ globalThreadsList: "compact" \}/); + assert.match(channelSource, /class: "md text-sm leading-5 text-fg", html: md\(item\.summary\)/, "Markdown is rendered in status and Activity summaries too"); assert.match(apiSource, /blob\.type\.startsWith\("audio\/"\)/); assert.match(apiSource, /document\.createElement\("audio"\)/); - assert.match(appSource, /\["notes", "Notes"\]/, "Notes is a visible channel surface"); - assert.match(appSource, /data(?:set)?: \{ notesHeader: "" \}/, "Notes has a channel-header action"); - assert.match(appSource, /onclick: openNotesFromHeader/, "the header action opens Notes beside chat rather than navigating away"); - assert.match(appSource, /renderNotes\(notesBox, S\.channelId, closeDockedNotes\)/, "the Notes surface mounts in the shared right-hand dock with a close action"); + assert.match(appSource, /\["cowork", "Cowork"\]/, "Cowork replaces the visible Notes tab"); + assert.match(appSource, /data(?:set)?: \{ quickNoteHeader: "" \}/, "the header exposes Quick Note"); + assert.doesNotMatch(appSource, /title: "Call Skipper"|aria-label": "Call Skipper"/, "the redundant Call Skipper button is removed"); + 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, /Working file: \/workspace\//, "the first Cowork agent message includes the open file path"); assert.doesNotMatch(appSource, /controllerchange[\s\S]{0,300}location\.reload\(/, "service-worker updates never reload an active note draft"); }); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs new file mode 100644 index 0000000..976453a --- /dev/null +++ b/test/cowork-browser.mjs @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { join } from "node:path"; +import test from "node:test"; +import puppeteer from "puppeteer"; + +const root = process.cwd(); +const dataDir = join(root, ".native-test-data", `cowork-browser-${process.pid}`); + +async function browserExecutable() { + const candidates = [process.env.PUPPETEER_EXECUTABLE_PATH || ""]; + try { candidates.push(await puppeteer.executablePath()); } catch { /* no bundled browser */ } + candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"); + for (const candidate of candidates.filter(Boolean)) { + try { accessSync(candidate, fsConstants.X_OK); return candidate; } catch { /* try next */ } + } + return null; +} + +const executablePath = await browserExecutable(); +const freePort = () => new Promise((resolve, reject) => { + const server = createServer(); server.once("error", reject); + server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); +}); +const waitFor = async (fn, label, timeout = 20_000) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + try { const value = await fn(); if (value) return value; } catch { /* retry */ } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${label}`); +}; + +test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one file-backed product", { + timeout: 120_000, + skip: executablePath ? false : "No local Chrome executable; server and source contracts cover the nonvisual fallback.", +}, async (t) => { + rmSync(dataDir, { recursive: true, force: true }); + const appPort = await freePort(); + const providerPort = await freePort(); + const base = `http://127.0.0.1:${appPort}`; + const provider = spawn(process.execPath, ["test/mock-openai.mjs", String(providerPort)], { cwd: root, stdio: "ignore" }); + const app = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { + cwd: root, + env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(appPort), NODE_ENV: "test", HELM_CHANNEL_COMPUTER_BACKEND: "native", IMPROVEMENT_INTERVAL_MS: "600000" }, + stdio: "ignore", + }); + let browser; + t.after(async () => { + await browser?.close().catch(() => undefined); + app.kill("SIGTERM"); provider.kill("SIGTERM"); + await Promise.race([new Promise((resolve) => app.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 3_000))]); + rmSync(dataDir, { recursive: true, force: true }); + }); + + await waitFor(async () => (await fetch(`http://127.0.0.1:${providerPort}/v1/models`).catch(() => null))?.ok, "mock provider"); + await waitFor(async () => (await fetch(`${base}/api/setup/status`).catch(() => null))?.ok, "1Helm server"); + const api = async (path, options = {}, token = "") => { + const response = await fetch(base + path, { + method: options.method || (options.body !== undefined ? "POST" : "GET"), + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(options.body !== undefined ? { "content-type": "application/json" } : {}) }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const payload = await response.json().catch(() => ({})); + assert.equal(response.ok, true, `${path}: ${payload.error || response.status}`); + return payload; + }; + const registration = await api("/api/auth/register", { body: { username: "captain", password: "secret-pass", display: "Captain" } }); + const token = registration.token; + const providerRecord = await api("/api/providers", { body: { name: "Mock", base_url: `http://127.0.0.1:${providerPort}/v1`, api_key: "test" } }, token); + await api("/api/setup/complete", { body: { name: "Cowork Browser", terminals_enabled: true, provider_id: providerRecord.provider.id, model: "mock-large" } }, token); + const channel = (await api("/api/channels", { body: { name: "Studio", purpose: "Exercise Cowork and Files." } }, token)).channel; + const createFile = (parent, name, content) => api(`/api/channels/${channel.id}/files/entries`, { body: { parent, name, content } }, token); + await createFile("notes", "field-notes.md", "# Field notes\n\n**Goal**\n"); + await createFile("whiteboards", "map.whiteboard.json", JSON.stringify({ version: 1, elements: [] }, null, 2)); + 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)); + 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"] }); + const page = await browser.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(base, { waitUntil: "networkidle0" }); + await page.evaluate((value) => localStorage.setItem("ctrl.token", value), token); + await page.goto(`${base}/c/${channel.slug}/chat`, { waitUntil: "networkidle0" }); + + // Quick Note stays above the current context, collapses without discarding, + // saves with both shortcuts, and allocates collision-safe untitled names. + await page.click("[data-quick-note-header]"); + await page.waitForSelector('[role="dialog"][aria-label="Quick Note"]'); + await page.waitForFunction(() => document.activeElement?.getAttribute("aria-label") === "Quick Note content"); + assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("aria-label")), "Quick Note content"); + await page.type('[aria-label="Quick Note content"]', "First captured thought"); + await page.click('nav button:nth-child(1)'); + await page.waitForFunction(() => !document.querySelector('[role="dialog"][aria-label="Quick Note"]')); + await page.click("[data-quick-note-header]"); + assert.equal(await page.$eval('[aria-label="Quick Note content"]', (input) => input.value), "First captured thought"); + await page.$eval('[role="dialog"][aria-label="Quick Note"]', (panel) => panel.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }))); + await page.waitForFunction(() => !document.querySelector('[role="dialog"][aria-label="Quick Note"]')); + await waitFor(async () => (await api(`/api/channels/${channel.id}/notes`, {}, token)).notes.some((note) => note.name === "untitled-quick-note-1.md"), "first Quick Note"); + await page.click("[data-quick-note-header]"); + await page.type('[aria-label="Quick Note content"]', "Second captured thought"); + await page.$eval('[role="dialog"][aria-label="Quick Note"]', (panel) => panel.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }))); + await waitFor(async () => (await api(`/api/channels/${channel.id}/notes`, {}, token)).notes.some((note) => note.name === "untitled-quick-note-2.md"), "second Quick Note"); + + // Cowork keeps one live editor node and exact selection across an otherwise + // full shell paint, while editing the authoritative /workspace file. + await page.goto(`${base}/c/${channel.slug}/cowork`, { waitUntil: "networkidle0" }); + await page.waitForSelector('[data-cowork-surface]'); + assert.deepEqual(await page.$$eval('[aria-label="Cowork sections"] button', (buttons) => buttons.map((button) => button.textContent.trim())), ["Notes", "Whiteboard", "Code", "Docs", "Presentations"]); + 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"]')?.value.includes("Field notes")); + await page.evaluate(() => { + const editor = document.querySelector('[aria-label="Notes editor"]'); + editor.value += "\nUnsaved continuity proof."; editor.dispatchEvent(new Event("input")); editor.focus(); editor.setSelectionRange(3, 12); window.__coworkEditor = editor; + }); + await page.evaluate(() => [...document.querySelectorAll('button[title^="Switch to"]')][0]?.click()); + await page.waitForFunction(() => document.activeElement === document.querySelector('[aria-label="Notes editor"]')); + const continuity = await page.evaluate(() => { + const editor = document.querySelector('[aria-label="Notes editor"]'); + return { same: editor === window.__coworkEditor, focused: document.activeElement === editor, value: editor?.value, start: editor?.selectionStart, end: editor?.selectionEnd }; + }); + assert.deepEqual(continuity, { same: true, focused: true, value: "# Field notes\n\n**Goal**\n\nUnsaved continuity proof.", start: 3, end: 12 }); + await page.keyboard.down("Control"); await page.keyboard.press("s"); await page.keyboard.up("Control"); + 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"); + await page.evaluate(() => [...document.querySelectorAll('.cowork-editor-toolbar button')].find((button) => button.textContent.trim() === "Preview")?.click()); + await page.waitForSelector(".cowork-markdown-preview:not(.hidden) strong"); + + // Every mode is file-backed, and the agent drawer starts an ordinary thread + // whose first human message carries only the open file path as context. + await page.evaluate(() => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === "Whiteboard")?.click()); + await page.waitForFunction(() => document.querySelector('[data-cowork-files]')?.textContent.includes("map.whiteboard.json")); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes("map.whiteboard.json"))?.click()); + await page.waitForSelector('[aria-label="Whiteboard canvas"]'); + await page.evaluate(() => [...document.querySelectorAll('.cowork-editor-toolbar button')].find((button) => button.textContent.includes("Card"))?.click()); + assert.equal(await page.$$eval(".cowork-whiteboard-card", (cards) => cards.length), 1); + await page.evaluate(() => document.querySelector('.cowork-agent-toggle')?.click()); + await page.waitForSelector('[data-cowork-agent]:not(.hidden) textarea'); + await page.type('[data-cowork-agent] textarea', "Help arrange this board"); + await page.evaluate(() => [...document.querySelectorAll('[data-cowork-agent] button')].find((button) => button.textContent.includes("Send"))?.click()); + const coworkRootId = await waitFor(async () => page.evaluate((id) => Number(localStorage.getItem(`1helm.cowork.thread.${id}.whiteboards/map.whiteboard.json`) || 0), channel.id), "Cowork thread id"); + const coworkThread = await api(`/api/messages/${coworkRootId}/thread`, {}, token); + assert.match(coworkThread.root.body, /^@\S+ Help arrange this board\n\nWorking file: \/workspace\/whiteboards\/map\.whiteboard\.json$/); + + for (const [label, file, selector] of [ + ["Code", "tool.ts", '[aria-label="Code editor"]'], + ["Docs", "proposal.md", '[aria-label="Docs editor"]'], + ["Presentations", "review.slides.json", ".cowork-slide"], + ]) { + await page.evaluate((next) => [...document.querySelectorAll('[aria-label="Cowork sections"] button')].find((button) => button.textContent.trim() === next)?.click(), label); + await page.waitForFunction((name) => document.querySelector('[data-cowork-files]')?.textContent.includes(name), {}, file); + await page.evaluate((name) => [...document.querySelectorAll('[data-cowork-files] button')].find((button) => button.textContent.includes(name))?.click(), file); + await page.waitForSelector(selector); + } + await page.screenshot({ path: "/tmp/1helm-cowork-desktop.png" }); + + // Files behaves like a bounded file browser and all familiar CRUD actions + // operate on the same tree Cowork has just edited. + await page.goto(`${base}/c/${channel.slug}/files`, { waitUntil: "networkidle0" }); + 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))); + 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"]'); + const clickNamedButton = (name) => page.evaluate((text) => { + const candidates = [...document.querySelectorAll('button')].filter((button) => button.textContent.trim() === text); + const button = candidates.find((candidate) => candidate.offsetParent !== null && !(candidate.closest('[data-file-path]'))) || candidates.find((candidate) => candidate.offsetParent !== null); + button?.click(); + }, name); + const submitPrompt = async (value) => { + await page.waitForSelector('.modal-overlay input'); + await page.$eval('.modal-overlay input', (input, next) => { input.value = next; input.dispatchEvent(new Event("input", { bubbles: true })); }, value); + await page.evaluate(() => [...document.querySelectorAll('.modal-overlay button')].find((button) => button.textContent.trim() === "OK")?.click()); + }; + await clickNamedButton("New folder"); await submitPrompt("plans"); + await page.waitForSelector('[data-file-path="docs/plans"]'); + await clickNamedButton("New file"); await submitPrompt("browser.md"); + 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")); + 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"]'); + await page.waitForFunction(() => document.querySelector('[data-file-metadata]')?.textContent.includes("renamed.md")); + await page.evaluate(() => [...document.querySelectorAll('[data-file-metadata] button')].find((button) => button.textContent.trim() === "Duplicate")?.click()); await page.waitForSelector('[data-file-path="docs/renamed copy.md"]'); + await page.click('[data-file-path="docs/renamed.md"]'); + await page.waitForFunction(() => document.querySelector('[data-file-metadata]')?.textContent.includes("renamed.md")); + await page.evaluate(() => [...document.querySelectorAll('[data-file-metadata] button')].find((button) => button.textContent.trim() === "Move")?.click()); await submitPrompt("docs/plans"); + await page.waitForFunction(() => !document.querySelector('[data-file-path="docs/renamed.md"]')); + await page.evaluate(() => document.querySelector('[aria-label="File breadcrumbs"] button')?.click()); + await page.waitForSelector('[data-file-directory="/"]'); + + // Global Threads uses the shared Markdown renderer, including spaced bold. + const globalThread = (await api("/api/threads", {}, token)).threads.find((thread) => thread.root_message_id === markdownThread.id); + assert.ok(globalThread, "Markdown fixture appears in the global Threads API"); + await page.evaluate(() => [...document.querySelectorAll('[data-sidebar="desktop"] button')].find((button) => button.textContent.trim() === "Threads")?.click()); + await page.waitForSelector(`[data-global-thread-open="${globalThread.id}"]`); + const markdown = await page.$eval(`[data-global-thread-open="${globalThread.id}"]`, (row) => ({ text: row.textContent, strong: [...row.querySelectorAll("strong")].map((node) => node.textContent) })); + 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, /\*\*/); + + // 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. + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); + await page.goto(`${base}/c/${channel.slug}/chat`, { waitUntil: "networkidle0" }); + await page.waitForSelector('#msgs'); + const mobile = await page.evaluate(() => { + const input = document.querySelector('textarea[data-composer-parent="root"]'); + const messages = document.getElementById("msgs"); + input.focus(); messages.scrollTop = Math.max(0, messages.scrollHeight - messages.clientHeight); messages.dispatchEvent(new Event("scroll")); + messages.scrollTop = Math.max(1, messages.scrollTop - 160); messages.dispatchEvent(new Event("scroll")); + const shell = document.querySelector(".workspace-shell")?.getBoundingClientRect(); + return { blurred: document.activeElement !== input, shellBottom: shell?.bottom, viewport: innerHeight, cssHeight: getComputedStyle(document.documentElement).getPropertyValue("--app-viewport-height") }; + }); + assert.equal(mobile.blurred, true); + 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"]'); + await page.click('[data-sidebar="mobile"] [aria-label="Open settings"]'); + 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 })); + assert.ok(settings.height < 90 && settings.width >= 380 && settings.scrolls, JSON.stringify(settings)); + await page.screenshot({ path: "/tmp/1helm-mobile-settings.png" }); + assert.deepEqual(errors, []); +}); diff --git a/test/markdown.mjs b/test/markdown.mjs index 032d868..fbb02a2 100644 --- a/test/markdown.mjs +++ b/test/markdown.mjs @@ -49,6 +49,7 @@ ok(/

Steps<\/h2>/.test(html), "H2 heading rendered"); ok(/

Notes<\/h3>/.test(html), "H3 heading rendered"); ok(!/(^|>)#{1,3}\s/.test(html.replace(/<[^>]+>/g, "")), "no raw hashtags leak into text"); ok(/plan<\/strong>/.test(html), "bold rendered"); +ok(/ Goal <\/strong>/.test(md("** Goal **")), "spaced bold labels render without literal asterisks"); ok(/caveats<\/em>/.test(html), "italic rendered"); ok(/old steps<\/del>/.test(html), "strikethrough rendered"); ok(/
    .*
  1. Pull the latest image<\/li>/s.test(html), "ordered list rendered"); diff --git a/test/native-world.mjs b/test/native-world.mjs index f2e4e3e..2cb5758 100644 --- a/test/native-world.mjs +++ b/test/native-world.mjs @@ -91,10 +91,20 @@ const stopApp = async () => { await Promise.race([new Promise((resolve) => app.once("exit", resolve)), sleep(2000)]); if (app.exitCode == null) app.kill("SIGKILL"); }; -const waitForAgentReply = async (rootId, token, authorName) => waitFor(async () => { - const response = await api(`/api/messages/${rootId}/thread`, {}, token); - return response.body.replies?.find((message) => message.author?.name === authorName && /Answer complete\.|Error contacting model/.test(message.body || "")); -}, `@${authorName} reply`, 15_000); +// This file intentionally exercises many concurrent agent lanes. Give a +// completed mock reply enough wall time on loaded release hosts without +// weakening the per-step assertions or accepting a placeholder response. +const waitForAgentReply = async (rootId, token, authorName) => { + try { + return await waitFor(async () => { + const response = await api(`/api/messages/${rootId}/thread`, {}, token); + return response.body.replies?.find((message) => message.author?.name === authorName && /Answer complete\.|Error contacting model/.test(message.body || "")); + }, `@${authorName} reply`, 60_000); + } catch (error) { + const response = await api(`/api/messages/${rootId}/thread`, {}, token); + throw new Error(`${(error).message}; thread=${JSON.stringify(response.body)}`); + } +}; try { rmSync(dataDir, { recursive: true, force: true }); diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs index ffa4143..f9038f8 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.match(server, /"permissions-policy": "camera=\(\), microphone=\(self\), geolocation=\(\)"/, "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, /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"); @@ -39,6 +40,8 @@ test("profile, naming, routing, and usage language match the visible product con assert.match(app, /toBlob\(resolve, "image\/jpeg", 0\.86\)/, "the square avatar is compressed before upload"); assert.match(app, /Photo ready\. Adjust the crop, then choose Save profile\./, "file choice does not instantly upload the raw photo"); assert.match(settings, /maxlength: 100/, "workspace naming allows and caps a generous 100 characters"); + assert.match(settings, /fetch\(apiUrl\("\/api\/workspace\/photo"\)[\s\S]{0,300}URL\.createObjectURL\(await response\.blob\(\)\)/, "workspace photo updates fetch the fixed authenticated asset and expose only a browser-created blob URL to the DOM"); + assert.equal([...settings.matchAll(/workspacePhotoSrc\(S\.workspace\.photo_url, Date\.now\(\)\)/g)].length, 1, "only the already-loaded workspace value may initialize the preview; update responses never become image destinations"); assert.match(app, /dataset: \{ workspaceName: "" \}/, "the complete workspace name has a stable wrapping hook"); assert.match(settings, /Connection availability/, "connections use direct availability wording"); assert.doesNotMatch(settings, /More connections/, "the ambiguous connections heading is gone");