diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b085b9..c65589c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.19] - 2026-07-27 + +### Fixed + +- Live Activity, Board, channel Threads, global Threads, Texts, Memory, and + channel-settings refreshes now keep the current surface visible until fresh + data arrives, ignore stale async paints, and restore focused controls, + cursor selection, unsaved values, scroll positions, and open or deliberately + closed disclosures by stable identity. +- Activity remembers the selected filter and expanded evidence across live + events, Board lane and Texts conversation scrollers retain their reading + position, and sidebar/header status updates no longer steal focus or reset + navigation state. +- Background skill-catalog changes no longer close and reopen Settings. The + open Skills page stays untouched and offers an explicit **Refresh when + ready** action so searches, drafts, focus, and scroll remain user-owned. + +### Tests + +- Browser regressions now trigger real WebSocket updates while a user owns a + Texts draft, Activity evidence, channel-settings cursor selection, Board + card, or global Threads position, and prove every state remains stable while + the incoming data and Markdown update in place. + ## [0.0.18] - 2026-07-27 ### Added @@ -532,7 +556,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.18...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.19...HEAD +[0.0.19]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.19 [0.0.18]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.18 [0.0.17]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.17 [0.0.16]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.16 diff --git a/README.md b/README.md index be8fd1b..9852cda 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.18` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.19` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 8b3c78b..5fc83d5 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -302,14 +302,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.18; + MARKETING_VERSION = 0.0.19; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile; PRODUCT_NAME = 1Helm; @@ -326,14 +326,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.18; + MARKETING_VERSION = 0.0.19; 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 317d9ee..62be752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.18", + "version": "0.0.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.18", + "version": "0.0.19", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 802095d..a9f0fe7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.18", + "version": "0.0.19", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/public/index.html b/public/index.html index 3741997..2b59c1a 100644 --- a/public/index.html +++ b/public/index.html @@ -36,6 +36,6 @@
- + diff --git a/src/client/app.ts b/src/client/app.ts index 81ff482..1059a88 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -152,12 +152,14 @@ 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; node: HTMLElement | null } | null; scroll: Array<{ key: string; top: number; left: number }>; - details: string[]; + details: Array<{ key: string; open: boolean }>; }; function continuityKey(element: Element): string | null { - const stable = element.closest("[data-continuity-key]")?.dataset.continuityKey; - if (stable) return `[data-continuity-key="${CSS.escape(stable)}"]`; + const stableElement = element.closest("[data-continuity-key]"); + const stable = stableElement?.dataset.continuityKey; + const stableSelector = stable ? `[data-continuity-key="${CSS.escape(stable)}"]` : ""; + if (stableElement === element) return stableSelector; 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)}"]`; @@ -167,11 +169,12 @@ function continuityKey(element: Element): string | null { 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)}"]`; + if (stableSelector && stableElement?.querySelectorAll(element.tagName).length === 1) return `${stableSelector} ${element.tagName.toLowerCase()}`; return null; } /** Preserve user-owned focus, selection, scroll, and expansion across unavoidable shell paints. */ -function captureUiContinuity(scope: ParentNode): UiContinuity { +export function captureUiContinuity(scope: ParentNode): UiContinuity { const activeElement = document.activeElement instanceof HTMLElement && scope.contains(document.activeElement) ? document.activeElement : null; const activeKey = activeElement ? continuityKey(activeElement) || (activeElement.isContentEditable ? "[contenteditable=true]" : null) : null; const selection = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement @@ -181,16 +184,16 @@ function captureUiContinuity(scope: ParentNode): UiContinuity { 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] : []; }); + const details = Array.from(scope.querySelectorAll("details[data-continuity-key]")) + .flatMap((element) => { const key = continuityKey(element); return key ? [{ key, open: element.open }] : []; }); return { active: activeKey ? { key: activeKey, node: activeElement, ...selection } : null, scroll, details }; } -function restoreUiContinuity(snapshot: UiContinuity): void { +export 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; } + for (const saved of snapshot.details) { const element = document.querySelector(saved.key); if (element) element.open = saved.open; } if (snapshot.active) { const element = snapshot.active.node?.isConnected ? snapshot.active.node : document.querySelector(snapshot.active.key); if (element) { @@ -200,6 +203,10 @@ function restoreUiContinuity(snapshot: UiContinuity): void { if ((element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) && snapshot.active.start != null && snapshot.active.end != null) element.setSelectionRange(snapshot.active.start, snapshot.active.end); } } + for (const saved of snapshot.scroll) { + const element = document.querySelector(saved.key); + if (element) { element.scrollTop = saved.top; element.scrollLeft = saved.left; } + } requestAnimationFrame(() => { for (const saved of snapshot.scroll) { const element = document.querySelector(saved.key); if (element) { element.scrollTop = saved.top; element.scrollLeft = saved.left; } } }); @@ -472,7 +479,7 @@ function onEvent(e: any): void { const msg = e.message as Message; if (!msg?.channel_id) return; if (msg.photon_conversation_id) { - if (S.view === "texts") renderChannelView(); + if (S.view === "texts") refreshChannelViewWithContinuity(); return; } const mine = msg.author?.kind === "user" && msg.author.id === S.me.id; @@ -506,7 +513,7 @@ function onEvent(e: any): void { } else if (e.type === "photon_update") { S.photonConfigured = true; if (e.conversationId) S.selectedTextConversationId = Number(e.conversationId); - if (S.view === "texts") renderChannelView(); else renderHeader(); + if (S.view === "texts") refreshChannelViewWithContinuity(); else renderHeader(); } else if (e.type === "message_deleted") { applyMessageDeleted(e); } else if (e.type === "mention_confirmation") { @@ -538,7 +545,7 @@ function onEvent(e: any): void { // Keep the URL slug in sync after rename without a hard navigation. writeRoute(S.channels.find((c) => c.id === S.channelId), S.view, S.threadRoot?.id ?? null, true); renderHeader(); - if (S.view === "settings") renderChannelView(); + if (S.view === "settings") refreshChannelViewWithContinuity(); else if (S.view === "chat") { renderMessages(); if (S.threadRoot) renderThread(); } } } else if (e.type === "channel_deleted") { @@ -554,17 +561,17 @@ function onEvent(e: any): void { renderSidebar(); if (e.channelId === S.channelId) renderHeader(); } else if (e.type === "activity" || e.type === "escalation") { - if (e.channelId === S.channelId && (S.view === "activity" || S.view === "memory")) renderChannelView(); + if (e.channelId === S.channelId && (S.view === "activity" || S.view === "memory")) refreshChannelViewWithContinuity(); } else if (e.type === "thread_update") { // Skipper (or API) changed durable session status — refresh board/threads/activity surfaces. - if (S.globalThreadsOpen) renderMain(); + if (S.globalThreadsOpen) refreshMainWithContinuity(); else if (Number(e.channelId) === Number(S.channelId) && (S.view === "board" || S.view === "threads" || S.view === "activity")) { - renderChannelView(); + refreshChannelViewWithContinuity(); } } else if (e.type === "followup") { // Durable agent wake scheduled/cleared — Board and the open thread share this source. if (Number(e.channelId) === Number(S.channelId) && (S.view === "board" || S.view === "threads")) { - renderChannelView(); + refreshChannelViewWithContinuity(); } if (Number(e.channelId) === Number(S.channelId) && S.threadRoot && Number(e.rootMessageId) === Number(S.threadRoot.id)) { S.threadFollowup = e.followup || null; @@ -591,17 +598,17 @@ function onEvent(e: any): void { } else if (e.type === "provider_update" && e.provider) { const i = S.providers.findIndex((p) => p.id === e.provider.id); if (i >= 0) S.providers[i] = e.provider; else S.providers = [...S.providers, e.provider]; - if (S.view === "settings") renderChannelView(); + if (S.view === "settings") refreshChannelViewWithContinuity(); } else if (e.type === "provider_deleted") { S.providers = S.providers.filter((p) => p.id !== e.providerId); - if (S.view === "settings") renderChannelView(); + if (S.view === "settings") refreshChannelViewWithContinuity(); } else if (e.type === "providers_changed") { - void reloadProviders().then(() => { if (S.view === "settings") renderChannelView(); }); + void reloadProviders().then(() => { if (S.view === "settings") refreshChannelViewWithContinuity(); }); } else if (e.type === "skills_changed") { void loadWorkspace().then(() => { renderSidebar(); renderHeader(); - if (S.view === "settings") renderChannelView(); + if (S.view === "settings") refreshChannelViewWithContinuity(); refreshOpenSkillsSettings(); }); } else if (e.type === "bot_update" && e.bot) { @@ -883,6 +890,7 @@ function sidebar(drawer = false): HTMLElement { class: `nav-item ${active ? "nav-item-active" : "nav-item-idle"} ${unread || working ? "font-semibold text-white" : ""}`, title: labels.join(" · "), "aria-label": labels.join(", "), + dataset: { continuityKey: `sidebar-${drawer ? "mobile" : "desktop"}-channel-${c.id}` }, onclick: () => { closeMobileMenu(); void openChannel(c.id); }, }, c.kind === "dm" @@ -923,13 +931,14 @@ function sidebar(drawer = false): HTMLElement { h("div", { class: "flex items-center gap-1" }, h("button", { class: "grid h-9 w-9 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: theme === "light" ? "Switch to dark" : "Switch to light", onclick: toggleTheme }, icon(theme === "light" ? "moon" : "sun")), drawer ? h("button", { class: "grid h-11 w-11 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-hover hover:text-white", title: "Close navigation", "aria-label": "Close navigation", dataset: { drawerClose: "" }, onclick: closeMobileMenu }, icon("x", 20)) : null)), - h("div", { class: "flex-1 space-y-5 overflow-y-auto px-2 py-3" }, + h("div", { class: "flex-1 space-y-5 overflow-y-auto px-2 py-3", dataset: { continuityKey: `sidebar-${drawer ? "mobile" : "desktop"}-scroll` } }, allChannels.length ? h("div", { class: "px-1 pb-1" }, h("button", { type: "button", class: `nav-item w-full ${S.globalThreadsOpen ? "nav-item-active" : "nav-item-idle"}`, title: "All threads across agent channels", "aria-pressed": String(Boolean(S.globalThreadsOpen)), + dataset: { continuityKey: `sidebar-${drawer ? "mobile" : "desktop"}-threads` }, onclick: () => { closeMobileMenu(); openGlobalThreads(); }, }, h("span", { class: "shrink-0 text-sidebar-muted" }, icon("thread", 14)), @@ -1310,7 +1319,29 @@ function openGlobalThreads(): void { void loadWorkspace().then(() => renderApp()).catch(() => renderApp()); } -function renderMain(preserveChannelSurface = false): void { +function refreshMainWithContinuity(): void { + const main = document.getElementById("main"); + const continuity = main ? captureUiContinuity(main) : null; + if (S.globalThreadsOpen) { + const container = document.getElementById("channelview"); + if (!container) { renderMain(true, continuity || undefined); return; } + renderGlobalThreads(container, { + unreadOnly: S.globalThreadsUnreadOnly, + onToggleUnread: (next) => { S.globalThreadsUnreadOnly = next; refreshMainWithContinuity(); }, + onOpen: (thread) => { + S.globalThreadsOpen = false; + void openChannel(thread.channel_id, "chat", thread.root_message_id); + }, + }, { + preserveExisting: true, + onPaint: continuity ? () => restoreUiContinuity(continuity) : undefined, + }); + return; + } + renderMain(true, continuity || undefined); +} + +function renderMain(preserveChannelSurface = false, continuity?: UiContinuity): void { setActiveCoworkChannel(!S.globalThreadsOpen && (S.view === "cowork" || S.view === "notes") ? S.channelId : null); const main = document.getElementById("main")!; // #msgs is destroyed on every shell rebuild. Capture scroll *before* clear so @@ -1338,12 +1369,16 @@ function renderMain(preserveChannelSurface = false): void { renderGlobalThreadsHeader(); renderGlobalThreads(document.getElementById("channelview")!, { unreadOnly: S.globalThreadsUnreadOnly, - onToggleUnread: (next) => { S.globalThreadsUnreadOnly = next; renderMain(); }, + onToggleUnread: (next) => { S.globalThreadsUnreadOnly = next; refreshMainWithContinuity(); }, onOpen: (thread) => { S.globalThreadsOpen = false; void openChannel(thread.channel_id, "chat", thread.root_message_id); }, + }, { + preserveExisting: preserveChannelSurface, + onPaint: continuity ? () => restoreUiContinuity(continuity) : undefined, }); + if (continuity) restoreUiContinuity(continuity); return; } const channel = S.channels.find((item) => item.id === S.channelId); @@ -1471,7 +1506,8 @@ function renderGlobalThreadsHeader(): void { type: "button", class: `btn-subtle min-h-11 text-xs sm:min-h-0 ${S.globalThreadsUnreadOnly ? "border-accent/40 bg-accent-soft text-accent" : ""}`, "aria-pressed": String(S.globalThreadsUnreadOnly), - onclick: () => { S.globalThreadsUnreadOnly = !S.globalThreadsUnreadOnly; renderMain(); }, + dataset: { continuityKey: "global-threads-unread" }, + onclick: () => { S.globalThreadsUnreadOnly = !S.globalThreadsUnreadOnly; refreshMainWithContinuity(); }, }, S.globalThreadsUnreadOnly ? "Unread · on" : "Unread · off"))); } @@ -1590,25 +1626,42 @@ function openTerminalOnComputer(computerId: number): void { writeRoute(S.channels.find((channel) => channel.id === S.channelId), "terminal", null); } -export function renderChannelView(preserveSurface = false): void { +let channelViewRenderGeneration = 0; + +function refreshChannelViewWithContinuity(): void { + const container = document.getElementById("channelview"); + if (!container) return; + const continuity = captureUiContinuity(container); + renderChannelView(true, () => restoreUiContinuity(continuity)); +} + +export function renderChannelView(preserveSurface = false, onPaint?: () => void): void { const container = document.getElementById("channelview"); const channel = S.channels.find((item) => item.id === S.channelId); if (!container || !channel) return; - if (S.view === "texts") renderTexts(container, S.selectedTextConversationId || undefined, (id) => { S.selectedTextConversationId = id; renderChannelView(); }); + const generation = ++channelViewRenderGeneration; + const options = { + preserveExisting: preserveSurface, + isCurrent: () => generation === channelViewRenderGeneration && container.isConnected, + onPaint: () => { if (generation === channelViewRenderGeneration && container.isConnected) onPaint?.(); }, + }; + let paintsAsynchronously = false; + if (S.view === "texts") renderTexts(container, S.selectedTextConversationId || undefined, (id) => { S.selectedTextConversationId = id; renderChannelView(); }, options); else if (S.view === "board") renderBoard(container, channel.id, (root) => { if (!S.messages.some((message) => message.id === root.id)) S.messages.push(root); S.messages.sort((a, b) => a.id - b.id); S.view = "chat"; renderApp(); void openThread(root); - }); - else if (S.view === "threads") renderThreads(container, channel.id, (thread) => { S.view = "chat"; renderApp(); void openThread(thread.root); }); + }, options); + else if (S.view === "threads") renderThreads(container, channel.id, (thread) => { S.view = "chat"; renderApp(); void openThread(thread.root); }, options); else if (S.view === "cowork" || S.view === "notes") renderCowork(container, channel.id, channel, S.me, (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 === "memory") renderMemory(container, channel.id, options); + else if (S.view === "activity") renderActivity(container, channel.id, options); else if (S.view === "terminal") openTerminals(container, channel.id, S.preferredTerminalComputerId || undefined); - else if (S.view === "settings") renderChannelSettings(container, channel, async (deleted) => { + else if (S.view === "settings") { + renderChannelSettings(container, channel, async (deleted) => { await loadWorkspace(); if (deleted || !S.channels.some((item) => item.id === S.channelId)) { S.channelId = S.channels.find((item) => item.name === "main" && item.kind === "channel")?.id || S.channels[0]?.id || 0; @@ -1624,11 +1677,16 @@ export function renderChannelView(preserveSurface = false): void { } renderApp(); } - }); + }, options.onPaint); + options.onPaint(); + paintsAsynchronously = true; + } + if (preserveSurface && !paintsAsynchronously) options.onPaint(); } function renderHeader(): void { const el = document.getElementById("hdr"); if (!el) return; + const continuity = captureUiContinuity(el); const channel = S.channels.find((item) => item.id === S.channelId); const agent = channel?.agent; clear(el); @@ -1676,6 +1734,7 @@ function renderHeader(): void { 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: "Quick Note", "aria-label": "Open Quick Note", dataset: { quickNoteHeader: "" }, onclick: (event: MouseEvent) => { event.stopPropagation(); openQuickNoteFromHeader(); }, }, icon("fileText", 18)) : null)); + restoreUiContinuity(continuity); } function starIcon(filled: boolean): SVGElement { @@ -3179,7 +3238,9 @@ export function pickList(title: string, items: { id: number; label: string }[], } export const renderSidebar = (): void => { if (!S.channels) return; + const continuity = captureUiContinuity(document); document.querySelectorAll("[data-sidebar]").forEach((s) => s.replaceWith(sidebar(s.dataset.sidebar === "mobile"))); + restoreUiContinuity(continuity); }; const fmtSize = (n: number): string => n < 1024 ? n + " B" : n < 1048576 ? (n / 1024).toFixed(1) + " KB" : (n / 1048576).toFixed(1) + " MB"; diff --git a/src/client/channel.ts b/src/client/channel.ts index 2b4c5af..fbf28a5 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -5,6 +5,16 @@ import { NOTIFICATION_SOUNDS, channelNotificationPreference, previewNotification export type ChannelView = "chat" | "texts" | "board" | "threads" | "cowork" | "notes" | "files" | "terminal" | "memory" | "activity" | "settings"; +type RenderRefreshOptions = { + preserveExisting?: boolean; + isCurrent?: () => boolean; + onPaint?: () => void; +}; + +function refreshIsCurrent(options: RenderRefreshOptions): boolean { + return options.isCurrent?.() !== false; +} + export function openCreateChannel(onCreated: (channel: Channel) => void): void { const name = h("input", { class: "field", placeholder: "launch", autocomplete: "off" }) as HTMLInputElement; const purpose = h("textarea", { class: "field min-h-28", placeholder: "This channel owns planning and coordinating the product launch." }) as HTMLTextAreaElement; @@ -166,15 +176,16 @@ function startBoardCountdownTicker(root: HTMLElement): void { boardCountdownTimer = window.setInterval(tick, 1000); } -export function renderThreads(container: HTMLElement, channelId: number, onOpen: (thread: ThreadState) => void): void { - panelLoading(container, "Threads", "Focused sessions with durable status and rolling summaries."); +export function renderThreads(container: HTMLElement, channelId: number, onOpen: (thread: ThreadState) => void, refresh: RenderRefreshOptions = {}): void { + if (!refresh.preserveExisting) panelLoading(container, "Threads", "Focused sessions with durable status and rolling summaries."); void api<{ threads: ThreadState[] }>(`/api/channels/${channelId}/threads`).then(({ threads }) => { + if (!refreshIsCurrent(refresh)) return; const list = h("div", { class: "space-y-2" }); if (!threads.length) list.append(empty("No sessions yet", "Start a top-level message in Chat to open a focused session.")); for (const thread of threads) { list.append(h("article", { class: "card flex w-full min-w-0 items-start gap-2.5 p-3" }, h("span", { class: "mt-0.5 shrink-0 text-accent" }, icon("thread")), - h("button", { class: "min-w-0 flex-1 text-left", type: "button", dataset: { threadOpen: String(thread.id) }, onclick: () => onOpen(thread) }, + h("button", { class: "min-w-0 flex-1 text-left", type: "button", dataset: { threadOpen: String(thread.id), continuityKey: `channel-thread-${thread.id}` }, onclick: () => onOpen(thread) }, h("div", { class: "truncate font-semibold text-fg hover:text-accent" }, thread.title), h("div", { class: "md mt-0.5 line-clamp-2 text-[13px] leading-snug text-muted", html: md(thread.summary || "No summary yet.") }), followupMeta(thread), @@ -182,7 +193,8 @@ export function renderThreads(container: HTMLElement, channelId: number, onOpen: } panelContent(container, "Threads", "Focused sessions with durable status and rolling summaries.", list); startBoardCountdownTicker(list); - }).catch((error) => panelError(container, error)); + refresh.onPaint?.(); + }).catch((error) => { if (refreshIsCurrent(refresh)) panelError(container, error); }); } /** @@ -197,16 +209,19 @@ export function renderThreads(container: HTMLElement, channelId: number, onOpen: * `agent_followups` rows (next pending due_at). Cards with a pending wake sit * only in Scheduled so the Captain can see the real countdown. */ -export function renderBoard(container: HTMLElement, channelId: number, onOpen: (root: Message) => void): void { - clear(container); - container.append(h("div", { class: "board-shell" }, - h("div", { class: "board-header" }, - h("div", { class: "min-w-0" }, - h("h2", { class: "font-display text-xl leading-tight text-fg sm:text-[1.45rem]" }, "Board"), - h("p", { class: "mt-0.5 text-xs text-muted sm:text-sm" }, "Sessions by status. Scheduled = durable agent wake with live countdown.")), - h("span", { class: "board-header-hint" }, "Loading…")))); +export function renderBoard(container: HTMLElement, channelId: number, onOpen: (root: Message) => void, refresh: RenderRefreshOptions = {}): void { + if (!refresh.preserveExisting) { + clear(container); + container.append(h("div", { class: "board-shell" }, + h("div", { class: "board-header" }, + h("div", { class: "min-w-0" }, + h("h2", { class: "font-display text-xl leading-tight text-fg sm:text-[1.45rem]" }, "Board"), + h("p", { class: "mt-0.5 text-xs text-muted sm:text-sm" }, "Sessions by status. Scheduled = durable agent wake with live countdown.")), + h("span", { class: "board-header-hint" }, "Loading…")))); + } void api<{ threads: ThreadState[] }>(`/api/channels/${channelId}/threads`).then(({ threads }) => { + if (!refreshIsCurrent(refresh)) return; const statuses: { status: ThreadState["status"]; label: string }[] = [ { status: "open", label: "Open" }, { status: "waiting", label: "Waiting" }, @@ -233,7 +248,7 @@ export function renderBoard(container: HTMLElement, channelId: number, onOpen: ( const threadCard = (thread: ThreadState): HTMLElement => h("button", { class: `board-card${hasPendingFollowup(thread) ? " board-card-scheduled" : ""}`, type: "button", - dataset: { threadOpen: String(thread.id) }, + dataset: { threadOpen: String(thread.id), continuityKey: `board-thread-${thread.id}` }, onclick: () => onOpen(thread.root), }, h("div", { class: "truncate text-[13px] font-semibold leading-snug text-fg" }, thread.title || "Untitled session"), @@ -257,7 +272,7 @@ export function renderBoard(container: HTMLElement, channelId: number, onOpen: ( h("h3", { class: "font-semibold text-fg" }, "Scheduled"), h("p", { class: "mt-0.5 text-[11px] text-muted" }, "Agent wake · live countdown")), h("span", { class: "font-mono text-[10px] text-faint" }, String(scheduled.length))), - h("div", { class: "board-lane-cards" }, ...scheduled.map(threadCard), + h("div", { class: "board-lane-cards", dataset: { continuityKey: "board-lane-scheduled" } }, ...scheduled.map(threadCard), scheduled.length ? null : h("p", { class: "px-1 py-6 text-center text-xs leading-5 text-faint" }, "No scheduled wakes"))); const lanes = statuses.map(({ status, label }) => { @@ -266,7 +281,7 @@ export function renderBoard(container: HTMLElement, channelId: number, onOpen: ( h("div", { class: "board-lane-heading" }, h("h3", { class: "font-semibold text-fg" }, label), h("span", { class: "font-mono text-[10px] text-faint" }, String(laneThreads.length))), - h("div", { class: "board-lane-cards" }, ...laneThreads.map(threadCard), + h("div", { class: "board-lane-cards", dataset: { continuityKey: `board-lane-${status}` } }, ...laneThreads.map(threadCard), laneThreads.length ? null : h("p", { class: "px-1 py-6 text-center text-xs leading-5 text-faint" }, "No sessions"))); }); @@ -280,7 +295,8 @@ export function renderBoard(container: HTMLElement, channelId: number, onOpen: ( h("div", { class: "board-scroll" }, h("div", { class: "board-lanes" }, incoming, scheduledLane, ...lanes))); container.append(shell); startBoardCountdownTicker(shell); - }).catch((error) => panelError(container, error)); + refresh.onPaint?.(); + }).catch((error) => { if (refreshIsCurrent(refresh)) panelError(container, error); }); } function openBoardComposer(channelId: number, onOpen: (root: Message) => void): void { @@ -330,10 +346,12 @@ function openBoardComposer(channelId: number, onOpen: (root: Message) => void): export function renderGlobalThreads( container: HTMLElement, opts: { unreadOnly: boolean; onToggleUnread: (next: boolean) => void; onOpen: (thread: GlobalThread) => void }, + refresh: RenderRefreshOptions = {}, ): void { - panelLoading(container, "Threads", "Sessions across every agent channel. Filter to only unread activity."); + if (!refresh.preserveExisting) panelLoading(container, "Threads", "Sessions across every agent channel. Filter to only unread activity."); const path = opts.unreadOnly ? "/api/threads?unread=1" : "/api/threads"; void api<{ threads: GlobalThread[] }>(path).then(({ threads }) => { + if (!refreshIsCurrent(refresh)) return; const toolbar = h("div", { class: "mb-2" }, h("p", { class: "text-xs text-muted" }, opts.unreadOnly ? "Unread activity across your channels." : `${threads.length} session${threads.length === 1 ? "" : "s"} · newest first`)); const list = h("div", { class: "overflow-hidden rounded-lg border border-line bg-surface", dataset: { globalThreadsList: "compact" } }); @@ -347,7 +365,7 @@ export function renderGlobalThreads( list.append(h("article", { class: `border-b border-line last:border-0 ${thread.unread ? "bg-accent-soft/30" : "hover:bg-hover"}` }, h("button", { class: "flex w-full min-w-0 items-start gap-2.5 px-3 py-2 text-left", type: "button", - dataset: { globalThreadOpen: String(thread.id) }, + dataset: { globalThreadOpen: String(thread.id), continuityKey: `global-thread-${thread.id}` }, onclick: () => opts.onOpen(thread), }, h("span", { class: `mt-1.5 h-2 w-2 shrink-0 rounded-full ${thread.unread ? "bg-danger" : "bg-line"}`, "aria-label": thread.unread ? "Unread" : undefined }), @@ -366,15 +384,17 @@ export function renderGlobalThreads( h("h2", { class: "font-display text-[1.75rem] leading-tight text-fg" }, "Threads"), h("p", { class: "mt-1.5 text-sm text-muted" }, "Sessions across every agent channel. Filter to only unread activity.")), toolbar, list)); - }).catch((error) => panelError(container, error)); + refresh.onPaint?.(); + }).catch((error) => { if (refreshIsCurrent(refresh)) panelError(container, error); }); } /** Private Captain ↔ Skipper conversations originated through Photon. */ -export function renderTexts(container: HTMLElement, selectedId?: number, onSelect?: (id: number) => void): void { - panelLoading(container, "Texts", "Your direct, channel-free conversations with Skipper."); +export function renderTexts(container: HTMLElement, selectedId?: number, onSelect?: (id: number) => void, refresh: RenderRefreshOptions = {}): void { + if (!refresh.preserveExisting) panelLoading(container, "Texts", "Your direct, channel-free conversations with Skipper."); void api<{ conversations: TextConversation[] }>("/api/texts").then(async ({ conversations }) => { + if (!refreshIsCurrent(refresh)) return; const selected = conversations.find((conversation) => conversation.id === selectedId) || conversations[0]; - const sidebar = h("aside", { class: "min-h-0 overflow-y-auto border-b border-line bg-raised/55 md:border-b-0 md:border-r" }); + const sidebar = h("aside", { class: "min-h-0 overflow-y-auto border-b border-line bg-raised/55 md:border-b-0 md:border-r", dataset: { continuityKey: "texts-conversations" } }); sidebar.append(h("div", { class: "border-b border-line px-4 py-3" }, h("div", { class: "eyebrow text-muted" }, "Conversations"), h("p", { class: "mt-1 text-xs leading-5 text-muted" }, "Send /new from your phone to start another."))); const list = h("div", { class: "p-2", dataset: { textsThreadList: "" } }); if (!conversations.length) list.append(empty("No text threads yet", "Text your 1Helm number. Your first message starts a private conversation with Skipper.")); @@ -390,8 +410,9 @@ export function renderTexts(container: HTMLElement, selectedId?: number, onSelec conversationPanel.append(h("div", { class: "grid min-h-[24rem] flex-1 place-items-center p-8 text-center" }, h("div", {}, h("div", { class: "text-accent" }, icon("chat", 28)), h("h3", { class: "mt-3 font-display text-xl text-fg" }, "Text Skipper to begin"), h("p", { class: "mt-2 max-w-sm text-sm leading-6 text-muted" }, "Once Photon receives your message, this inbox appears automatically and keeps the conversation available on desktop.")))); } else { const detail = await api<{ conversation: TextConversation }>(`/api/texts/${selected.id}`); + if (!refreshIsCurrent(refresh)) return; const messages = detail.conversation.messages || []; - const stream = h("div", { class: "min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-5 sm:px-6", dataset: { textsMessages: String(selected.id) } }); + const stream = h("div", { class: "min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-5 sm:px-6", dataset: { textsMessages: String(selected.id), continuityKey: `texts-messages-${selected.id}` } }); for (const message of messages) { const captain = message.author.kind === "user" || message.transport === "inbound"; stream.append(h("div", { class: `flex ${captain ? "justify-end" : "justify-start"}` }, @@ -412,11 +433,12 @@ export function renderTexts(container: HTMLElement, selectedId?: number, onSelec input.onkeydown = (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void submit(); } }; conversationPanel.append(h("div", { class: "flex min-h-14 items-center gap-3 border-b border-line px-4 py-3" }, h("span", { class: "text-accent" }, icon("chat", 18)), h("div", { class: "min-w-0" }, h("h3", { class: "truncate font-semibold text-fg" }, selected.title || "Text with Skipper"), h("p", { class: "truncate text-xs text-muted" }, selected.active ? "Current thread · phone and desktop share context" : "Closed with /new · still resumable in 1Helm"))), stream, h("div", { class: "border-t border-line bg-surface p-3 sm:p-4" }, h("div", { class: "mx-auto flex max-w-3xl items-end gap-2 rounded-xl border border-line bg-raised/45 p-2.5 focus-within:border-accent/50" }, input, send), h("div", { class: "mx-auto mt-1.5 flex max-w-3xl justify-between px-1" }, h("span", { class: "text-[11px] text-faint" }, "Desktop messages stay in 1Helm; return to your phone anytime."), status))); - requestAnimationFrame(() => { stream.scrollTop = stream.scrollHeight; }); + if (!refresh.preserveExisting) requestAnimationFrame(() => { stream.scrollTop = stream.scrollHeight; }); } clear(container); container.append(h("div", { class: "flex h-full min-h-0 flex-col" }, h("header", { class: "border-b border-line px-4 py-3" }, h("h2", { class: "font-display text-xl text-fg" }, "Texts"), h("p", { class: "mt-0.5 text-xs text-muted" }, "One continuous thread with Skipper until you send /new.")), h("div", { class: "grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[18rem_minmax(0,1fr)]" }, sidebar, conversationPanel))); - }).catch((error) => panelError(container, error)); + refresh.onPaint?.(); + }).catch((error) => { if (refreshIsCurrent(refresh)) panelError(container, error); }); } function workspaceIcon(file: ChannelFile, size = 18): SVGElement { @@ -724,10 +746,11 @@ export function renderNotes(container: HTMLElement, channelId: number, onClose?: void reloadList().catch((error) => panelError(container, error)); } -export function renderMemory(container: HTMLElement, channelId: number): void { - panelLoading(container, "Memory / Knowledge", "Provider-neutral continuity owned by this channel, with provenance."); +export function renderMemory(container: HTMLElement, channelId: number, refresh: RenderRefreshOptions = {}): void { + if (!refresh.preserveExisting) panelLoading(container, "Memory / Knowledge", "Provider-neutral continuity owned by this channel, with provenance."); const load = (): void => { void api<{ memory: MemoryItem[] }>(`/api/channels/${channelId}/memory`).then(({ memory }) => { + if (!refreshIsCurrent(refresh)) return; const current = memory.filter((item) => item.status === "current"); const list = h("div", { class: "space-y-2" }); if (!current.length) list.append(empty("No durable knowledge yet", "Record decisions, facts, preferences, and artifact references worth carrying into future sessions. Session recaps live in Threads, not here.")); @@ -737,7 +760,8 @@ export function renderMemory(container: HTMLElement, channelId: number): void { h("div", { class: "md text-sm text-fg", html: md(item.content) }))); const add = h("button", { class: "btn-primary text-sm", onclick: () => addMemory(channelId, load) }, icon("plus"), "Record knowledge"); panelContent(container, "Memory / Knowledge", "Provider-neutral continuity owned by this channel, with provenance.", h("div", {}, h("div", { class: "mb-4 flex justify-end" }, add), list)); - }).catch((error) => panelError(container, error)); + refresh.onPaint?.(); + }).catch((error) => { if (refreshIsCurrent(refresh)) panelError(container, error); }); }; load(); } @@ -758,6 +782,7 @@ const ACTIVITY_SYSTEM = new Set(["agent_status", "lifecycle", "profile"]); ACTIVITY_SKIPPER.add("computer"); type ActivityFilter = "all" | "skipper" | "work" | "system"; +const activityFilters = new Map(); function activityBucket(kind: string): ActivityFilter { if (ACTIVITY_SKIPPER.has(kind)) return "skipper"; @@ -833,7 +858,7 @@ function activityCard(item: ActivityItem): HTMLElement { if (item.action_result) evidence.push( h("div", {}, h("div", { class: "mb-1 font-mono text-[9.5px] uppercase tracking-[0.16em] text-faint" }, "Outcome evidence"), h("pre", { class: "m-0 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-4 text-muted" }, item.action_result))); - return h("details", { class: `overflow-hidden rounded-lg border ${border}`, dataset: { actionId: String(item.action_id) } }, + return h("details", { class: `overflow-hidden rounded-lg border ${border}`, dataset: { actionId: String(item.action_id), continuityKey: `activity-action-${item.action_id}` } }, h("summary", { class: "cursor-pointer select-none list-none" }, headline), h("div", { class: "space-y-3 border-t border-line/70 bg-raised/30 px-4 py-3" }, ...(evidence.length ? evidence : [h("p", { class: "text-xs text-muted" }, item.status === "running" ? "The action is still running; evidence will appear here when it settles." : "No additional output was retained for this action.")]))); @@ -853,10 +878,11 @@ function activitySection(title: string, copy: string, items: ActivityItem[]): HT body); } -export function renderActivity(container: HTMLElement, channelId: number): void { - panelLoading(container, "Activity", "Ops log for this channel — tools, escalations, and Skipper's background checks (not the chat stream)."); +export function renderActivity(container: HTMLElement, channelId: number, refresh: RenderRefreshOptions = {}): void { + if (!refresh.preserveExisting) panelLoading(container, "Activity", "Ops log for this channel — tools, escalations, and Skipper's background checks (not the chat stream)."); void api<{ activity: ActivityItem[] }>(`/api/channels/${channelId}/activity`).then(({ activity }) => { - let filter: ActivityFilter = "all"; + if (!refreshIsCurrent(refresh)) return; + let filter: ActivityFilter = activityFilters.get(channelId) || "all"; const root = h("div", { class: "space-y-5" }); const paint = (): void => { @@ -875,7 +901,8 @@ export function renderActivity(container: HTMLElement, channelId: number): void type: "button", class: `btn-subtle min-h-11 shrink-0 text-sm sm:min-h-0 ${filter === item.id ? "border-accent/40 bg-accent-soft text-accent" : ""}`, "aria-pressed": String(filter === item.id), - onclick: () => { filter = item.id; paint(); }, + dataset: { continuityKey: `activity-filter-${channelId}-${item.id}` }, + onclick: () => { filter = item.id; activityFilters.set(channelId, filter); paint(); }, }, item.label))); const blurb = h("p", { class: "text-sm leading-6 text-muted" }, @@ -917,20 +944,22 @@ export function renderActivity(container: HTMLElement, channelId: number): void paint(); panelContent(container, "Activity", "Ops log for this channel — tools, escalations, and Skipper's background checks (not the chat stream).", root); - }).catch((error) => panelError(container, error)); + refresh.onPaint?.(); + }).catch((error) => { if (refreshIsCurrent(refresh)) panelError(container, error); }); } -export function renderChannelSettings(container: HTMLElement, channel: Channel, onChanged: (deleted?: boolean) => void): void { +export function renderChannelSettings(container: HTMLElement, channel: Channel, onChanged: (deleted?: boolean) => void, onPaint?: () => void): void { const nameField = h("input", { class: "field font-mono", value: channel.name, autocomplete: "off", spellcheck: "false", + dataset: { continuityKey: `channel-settings-name-${channel.id}` }, disabled: channel.name === "main" || !channel.can_manage ? true : undefined, }) as HTMLInputElement; - const purpose = h("textarea", { class: "field min-h-24" }, channel.purpose || "") as HTMLTextAreaElement; - const provider = h("select", { class: "field" }, h("option", { value: "" }, "Loading providers…")) as HTMLSelectElement; - const model = h("select", { class: "field" }, h("option", { value: channel.agent?.model || "" }, channel.agent?.model || "Choose a model")) as HTMLSelectElement; + const purpose = h("textarea", { class: "field min-h-24", "aria-label": "Channel purpose", dataset: { continuityKey: `channel-settings-purpose-${channel.id}` } }, channel.purpose || "") as HTMLTextAreaElement; + const provider = h("select", { class: "field", "aria-label": "Serving provider", dataset: { continuityKey: `channel-settings-provider-${channel.id}` } }, h("option", { value: "" }, "Loading providers…")) as HTMLSelectElement; + const model = h("select", { class: "field", "aria-label": "Serving model", dataset: { continuityKey: `channel-settings-model-${channel.id}` } }, h("option", { value: channel.agent?.model || "" }, channel.agent?.model || "Choose a model")) as HTMLSelectElement; const status = h("p", { class: "min-h-5 text-sm text-muted" }); const notificationPreference = channelNotificationPreference(channel.id); const channelMuted = h("input", { type: "checkbox", checked: notificationPreference.muted, class: "accent-accent" }) as HTMLInputElement; @@ -971,13 +1000,14 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, } }; provider.onchange = () => { void loadModels(); }; - void api<{ models: RoutingModel[] }>("/api/workspace/model-policy").then(({ models }) => { + void api<{ models: RoutingModel[] }>("/api/workspace/model-policy").then(async ({ models }) => { routedModels = models; const groups = new Map(); for (const item of models) groups.set(providerKey(item), item.kind === "route" ? "Named routes" : String(item.providerName || item.providerType || "Provider")); const current = models.find((item) => item.id === channel.agent?.model); clear(provider); provider.append(h("option", { value: "" }, "Choose a provider"), ...[...groups].map(([value, label]) => h("option", { value, selected: current ? providerKey(current) === value : false }, label))); - void loadModels(); + await loadModels(); + onPaint?.(); }).catch((error) => { status.textContent = (error as Error).message; }); const saveName = async (): Promise => { if (channel.name === "main") { status.textContent = "#main cannot be renamed."; return; } @@ -1110,6 +1140,7 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, h("div", { class: "card p-4" }, h("h3", { class: "font-semibold text-fg" }, "Capabilities"), h("div", { class: "mt-3 flex flex-wrap gap-2" }, ...(channel.agent?.capabilities || []).map((capability) => h("span", { class: "chip" }, capability))), h("p", { class: "mt-3 text-xs text-muted" }, "The resident agent is channel-scoped. It calls @skipper for host-level, cross-channel, credential, guest-expert, or missing-capability work.")), computerCard, lifecycle)); + onPaint?.(); if (channel.agent?.provider_id) void loadModels(); } diff --git a/src/client/settings.ts b/src/client/settings.ts index 5e0a314..111eacb 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -105,8 +105,18 @@ export function openSettings(tab: Tab = "agents"): void { export function refreshOpenSkillsSettings(): void { const overlay = document.querySelector("[data-settings-overlay]"); if (overlay?.dataset.settingsTab !== "skills") return; - overlay.remove(); - openSettings("skills"); + let notice = overlay.querySelector("[data-skills-refresh-notice]"); + if (notice) return; + notice = h("div", { + class: "mx-auto mb-4 flex w-full max-w-5xl flex-wrap items-center gap-3 rounded-lg border border-accent/30 bg-accent-soft px-4 py-3 text-sm text-fg", + dataset: { skillsRefreshNotice: "" }, + role: "status", + }, h("span", { class: "min-w-0 flex-1" }, "The workspace skill catalog changed in the background."), h("button", { + class: "btn-subtle shrink-0 text-xs", + type: "button", + onclick: () => { overlay.remove(); openSettings("skills"); }, + }, "Refresh when ready")); + overlay.querySelector("main > div")?.prepend(notice); } const adminNote = (): HTMLElement => h("p", { class: "rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-300" }, "Only admins can add or edit these."); diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 77d49d0..a9426bf 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.18"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.19"; 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 079368a..89f3c1b 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1005,7 +1005,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.18"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.19"); // 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 46b9fb4..10d333a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1565,7 +1565,9 @@ const server = createServer(async (req, res) => { const resident = agentForChannel(Number(thread.channel_id)); if (resident) scheduleAgentReview(Number(resident.id)); } if (b.summary != null) run("UPDATE threads SET summary=?, updated_at=? WHERE id=?", String(b.summary).slice(0, 10000), now(), thread.id); - return json(res, 200, { thread: q1("SELECT * FROM threads WHERE id=?", thread.id) }); + const updated = q1("SELECT * FROM threads WHERE id=?", thread.id); + broadcastToChannel(Number(thread.channel_id), { type: "thread_update", channelId: Number(thread.channel_id), thread: updated }); + return json(res, 200, { thread: updated }); } if (p === "/api/dm" && m === "POST") { const b = await jbody(req); diff --git a/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index 3201199..57c6ef1 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -213,6 +213,21 @@ try { await page.waitForFunction(() => document.querySelector('[data-texts-messages]')?.innerText.includes("Answer complete"), { timeout: 10_000 }); const outboundAfterDesktop = (await api("/api/testing/photon", { body: {} }, token)).outbound; ok(outboundAfterDesktop === outboundBeforeDesktop, "desktop Texts continuation shares Skipper context without echoing app messages back to iMessage"); + await page.type(`[data-texts-composer="${textConversation}"]`, "Text draft remains under my cursor"); + await page.evaluate((conversationId) => { + const input = document.querySelector(`[data-texts-composer="${conversationId}"]`); + input.focus(); input.setSelectionRange(5, 10); + }, textConversation); + await api("/api/testing/photon", { body: { event: { id: "browser-phone-live-continuity", space_id: "browser-space", space_type: "dm", sender: "+15551234567", text: "Phone update while drafting", timestamp: new Date().toISOString() } } }, token); + await page.waitForFunction((conversationId) => { + const input = document.querySelector(`[data-texts-composer="${conversationId}"]`); + const stream = document.querySelector(`[data-texts-messages="${conversationId}"]`); + return document.activeElement === input + && input?.value === "Text draft remains under my cursor" + && input?.selectionStart === 5 && input?.selectionEnd === 10 + && stream?.textContent?.includes("Phone update while drafting"); + }, {}, textConversation); + ok(true, "live phone updates preserve the focused Texts draft and exact cursor selection"); await page.setViewport({ width: 1440, height: 900 }); await page.goto(`${base}/c/${channel.slug}/thread/${rootMessage.id}`, { waitUntil: "networkidle0" }); @@ -318,6 +333,39 @@ try { }, actionId); ok(activityEvidence.rows === 1 && activityEvidence.open && /Input/.test(activityEvidence.text) && /Outcome evidence/.test(activityEvidence.text) && /status=completed/.test(activityEvidence.text), "Activity mutates one outcome-first row and expands to the retained input and outcome evidence"); + const liveActivityBefore = await page.evaluate((expectedActionId) => { + const view = document.getElementById("channelview"); + const details = document.querySelector(`#channelview details[data-action-id="${expectedActionId}"]`); + view.scrollTop = Math.max(1, Math.min(view.scrollHeight - view.clientHeight, 180)); + details.open = true; + window.__briefActivityView = view; + return { top: view.scrollTop, action: details.getAttribute("data-continuity-key") }; + }, actionId); + await api(`/api/channels/${channel.id}/messages`, { body: { body: `@${channel.agent.name} run whoami for live activity continuity` } }, token); + await page.waitForFunction((expectedAction) => { + const details = document.querySelector(`#channelview details[data-action-id="${expectedAction}"]`); + return details?.open && document.getElementById("channelview")?.scrollTop > 0; + }, {}, actionId); + const liveActivityAfter = await page.evaluate(({ expectedActionId, priorTop }) => { + const view = document.getElementById("channelview"); + const details = document.querySelector(`#channelview details[data-action-id="${expectedActionId}"]`); + return { sameView: view === window.__briefActivityView, open: details?.open, top: view?.scrollTop, priorTop }; + }, { expectedActionId: actionId, priorTop: liveActivityBefore.top }); + ok(liveActivityAfter.sameView && liveActivityAfter.open && Math.abs(liveActivityAfter.top - liveActivityAfter.priorTop) <= 1, + "live Activity refreshes preserve the active panel node, expanded evidence, and reading position"); + + await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Settings"))?.click()); + await page.waitForSelector('textarea[aria-label="Channel purpose"]'); + await page.evaluate(() => { + const input = document.querySelector('textarea[aria-label="Channel purpose"]'); + input.value = "Unsaved settings draft survives background updates."; + input.focus(); input.setSelectionRange(8, 16); + }); + await api(`/api/providers/${provider.provider.id}`, { method: "PATCH", body: { name: "Mock live continuity" } }, token); + await page.waitForFunction(() => document.activeElement?.getAttribute("aria-label") === "Channel purpose" + && document.activeElement?.value === "Unsaved settings draft survives background updates." + && document.activeElement?.selectionStart === 8 && document.activeElement?.selectionEnd === 16); + ok(true, "live provider updates preserve the focused channel-settings draft and exact cursor selection"); await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Chat"))?.click()); await page.waitForSelector('textarea[data-composer-parent="root"]'); const restoredDraft = await page.$eval('textarea[data-composer-parent="root"]', (element) => element.value); @@ -424,6 +472,42 @@ try { }); ok(laneScroll?.overflows && laneScroll.before === 0 && laneScroll.after > 0 && laneScroll.lastReachable, "a crowded Board swim lane scrolls vertically until its final task is reachable"); + const boardContinuity = await page.evaluate(() => { + const cards = document.querySelector('[data-board-status="open"] .board-lane-cards'); + const focused = cards?.querySelector('button[data-thread-open]'); + cards.scrollTop = Math.max(1, Math.floor((cards.scrollHeight - cards.clientHeight) / 2)); + focused?.focus(); + return { top: cards?.scrollTop, threadId: focused?.getAttribute("data-thread-open") }; + }); + await api(`/api/threads/${Number(boardContinuity.threadId)}`, { method: "PATCH", body: { summary: "**Goal** live Board continuity" } }, token); + await page.waitForFunction(({ threadId, priorTop }) => { + const focused = document.querySelector(`[data-thread-open="${threadId}"]`); + const cards = focused?.closest(".board-lane-cards"); + return document.activeElement === focused && Math.abs((cards?.scrollTop || 0) - priorTop) <= 1; + }, {}, { threadId: boardContinuity.threadId, priorTop: boardContinuity.top }); + ok(true, "live Board status refreshes preserve the focused session card and lane scroll position"); + + await page.evaluate(() => [...document.querySelectorAll('[data-sidebar="desktop"] button')].find((button) => button.textContent?.trim() === "Threads")?.click()); + await page.waitForSelector('[data-global-threads-list] [data-global-thread-open]'); + const globalThreadsContinuity = await page.evaluate(() => { + const view = document.getElementById("channelview"); + const focused = document.querySelector('[data-global-thread-open]'); + view.scrollTop = Math.max(1, Math.floor((view.scrollHeight - view.clientHeight) / 2)); + focused?.focus(); + window.__briefGlobalThreadsView = view; + return { top: view.scrollTop, threadId: focused?.getAttribute("data-global-thread-open") }; + }); + await api(`/api/threads/${Number(globalThreadsContinuity.threadId)}`, { method: "PATCH", body: { summary: "**Session Status** global continuity" } }, token); + await page.waitForFunction(({ threadId, priorTop }) => { + const view = document.getElementById("channelview"); + const focused = document.querySelector(`[data-global-thread-open="${threadId}"]`); + return view === window.__briefGlobalThreadsView + && document.activeElement === focused + && Math.abs((view?.scrollTop || 0) - priorTop) <= 1 + && focused?.querySelector("strong")?.textContent === "Session Status"; + }, {}, { threadId: globalThreadsContinuity.threadId, priorTop: globalThreadsContinuity.top }); + ok(true, "live global Threads refreshes preserve the active panel, focused session, scroll position, and rendered Markdown"); + await page.goto(`${base}/c/${channel.slug}`, { waitUntil: "networkidle0" }); await page.evaluate(() => [...document.querySelectorAll("nav button")].find((button) => button.textContent.includes("Terminal"))?.click()); await page.waitForSelector(".xterm"); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 32a04bc..87a7ae0 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.18"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.19"); 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/feedback-browser.mjs b/test/feedback-browser.mjs index 1839ff2..59ce407 100644 --- a/test/feedback-browser.mjs +++ b/test/feedback-browser.mjs @@ -135,12 +135,15 @@ test("Feedback button saves a real report and the admin inbox shows it", { assert.ok(await page.$(`[data-feedback-report="${reports[0].public_id}"]`)); // Keep the Skills control plane open while Skipper creates a new skill. The - // WebSocket event must invalidate the view; no page reload or tab hop is used. + // WebSocket event must never tear down the user's active settings surface; + // it offers an explicit refresh at the user's chosen moment instead. await page.evaluate(() => [...document.querySelectorAll('nav[aria-label="Settings sections"] button')] .find((button) => button.textContent?.trim() === "Skills")?.click()); await page.waitForSelector('article[data-skill-slug="outcome-ownership"]'); await api("/api/skills/learn", { body: { notes: "Turn this UI refresh evidence into a reusable incident postmortem skill." } }, token); await waitFor(async () => (await api("/api/skills", {}, token)).skills?.find((skill) => skill.slug === "incident-postmortem"), "new skill in authoritative arsenal"); + await page.waitForSelector("[data-skills-refresh-notice]"); + await page.click("[data-skills-refresh-notice] button"); await page.waitForSelector('article[data-skill-slug="incident-postmortem"]', { timeout: 10_000 }); // Assignment must likewise repaint an already-open channel Settings surface.