From 9725ef825cd68ccb2450cc52b0df58e5ced6c040 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Sun, 26 Jul 2026 01:37:23 +0000 Subject: [PATCH] feat: make Notes durable and Photon channel-free Keep active Markdown drafts stable across shell and service-worker updates, add formatting and a cleaner Notes workspace, and constrain long channel headers. Replace Photon mappings with private Captain-to-Skipper Texts conversations that can move between phone and desktop without echoing desktop-only turns. Release as 0.0.11 with migration, browser, isolation, and packaging pins updated. Signed-off-by: Joseph Yaksich --- CHANGELOG.md | 36 +++++- README.md | 9 +- docs/RELIABILITY.md | 7 +- docs/USER_GUIDE.md | 19 ++-- package-lock.json | 4 +- package.json | 2 +- public/index.html | 4 +- src/client/api.ts | 3 +- src/client/app.ts | 55 ++++++--- src/client/channel.ts | 172 +++++++++++++++++++++++++---- src/client/settings.ts | 21 +--- src/client/styles.css | 12 ++ src/server/agents.ts | 30 +++-- src/server/bots.ts | 48 +------- src/server/channel-computers.ts | 2 +- src/server/db.ts | 42 ++++++- src/server/feedback.ts | 3 +- src/server/index.ts | 39 +++++-- src/server/photon.ts | 141 ++++++++++++----------- src/server/store.ts | 4 + test/brief-regressions-browser.mjs | 62 ++++++++++- test/channel-computers.mjs | 2 +- test/channel-surfaces.mjs | 22 +++- test/photon.mjs | 110 +++++++++++------- test/workspace-interactions.mjs | 11 ++ 25 files changed, 612 insertions(+), 248 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da3bfd3..2d36602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.11] - 2026-07-26 + +### Added + +- Notes now include a focused Markdown toolbar for headings, bold, italic, + lists, code, and links, plus Write and Preview modes that render the current + unsaved draft. +- Configured Photon workspaces now show a private **Texts** tab only in the + Captain's personal `#main`. It lists every Skipper conversation, preserves + each thread, and lets the Captain select, resume, and continue it on desktop. + ### Changed +- Notes use a cleaner searchable sidebar, friendly titles without a visible + `.md` suffix, and a more spacious editor layout. +- Photon is now channel-agnostic: the configured Captain phone always texts + Skipper, one conversation remains current until `/new`, and legacy mapped + conversations move to the Captain's private Texts inbox during migration. + Desktop continuations share the same context without echoing to iMessage; + returning to the phone continues the conversation selected on desktop. - GitHub Releases now require the complete numbered acceptance ledger from a multi-item request, plus artifact and verification evidence. Generated commit summaries can no longer replace the user-facing change list. @@ -16,6 +34,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 clean build, signing, notarization, public-download installation, launch, smoke verification, and Application Support preservation. +### Fixed + +- New notes accept titles with or without `.md`, add the suffix when omitted, + and reject another extension with a corrected `.md` suggestion. +- Theme, reconnect, and service-worker update refreshes no longer discard or + replace the active Notes editor. The exact editor node, unsaved draft, focus, + selection, and preview state survive shell rebuilds. +- Long channel descriptions remain single-line and truncated at narrower + desktop widths instead of expanding the top bar into a one-character column. +- Newly provisioned residents prefer an unused character/color avatar + combination, eliminating stochastic duplicate identities in small fleets. +- Photon content is excluded from channel Chat, channel/global Threads, Board, + and unread counts, and the obsolete resident Photon tools and mapping UI are + removed. + ## [0.0.10] - 2026-07-25 ### Fixed @@ -301,7 +334,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.10...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.11...HEAD +[0.0.11]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.11 [0.0.10]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.10 [0.0.9]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.9 [0.0.8]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.8 diff --git a/README.md b/README.md index 8e0f7e0..7512a4b 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,10 @@ keys per member. Connections are host-owned brokers, not secrets copied into every resident's shell. Gmail exposes scoped account listing, search, read, and draft creation; -sending remains disabled by default. Photon maps allowlisted inbound iMessage -threads to a resident and permits narrow replies in an already-authorized -conversation. Provider, Gmail, and Photon credentials stay on the host. +sending remains disabled by default. Photon gives the Captain one direct line +to Skipper: every text stays in a private Texts thread until `/new`, and that +same context can continue on desktop without echoing desktop-only turns back to +iMessage. Provider, Gmail, and Photon credentials stay on the host. New connection types have to earn their place with least-privilege scoping, secret isolation, reconnect and recovery, deduplication, deterministic tests, @@ -256,7 +257,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.10` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.11` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/docs/RELIABILITY.md b/docs/RELIABILITY.md index 17cbcba..d2381e5 100644 --- a/docs/RELIABILITY.md +++ b/docs/RELIABILITY.md @@ -59,9 +59,10 @@ ownership, recovery, and tests rather than depend on a prompt alone. - Delivery state records pending, attempting, delivered, failed, or uncertain. A restart may resume a never-attempted reply. An interrupted attempt is surfaced as uncertain and is not blindly replayed into a duplicate. -- An external message received through Photon defaults to the Captain's - `#main`/Skipper mapping, invokes the mapped partner, and returns the final - response to the originating authorized conversation. +- An external message received through Photon is private Captain ↔ Skipper + state. It appears only in `#main` Texts, returns the final response to the + originating phone conversation, and continues until the Captain sends + `/new`; desktop continuations share context without producing iMessage echo. - Timers, recurring workflows, long-running follow-ups, and connector replies are durable obligations. Sleep, restart, or a closed renderer must not erase them. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e45dc14..bf4d3d7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -248,15 +248,16 @@ running on that host. ## Photon / iMessage Connections guides Photon device authorization, project setup, secret rotation, -phone registration, sidecar health, and conversation mapping. The first mapping -defaults to the Captain's `#main` and Skipper. - -An allowlisted inbound text creates a real 1Helm thread, invokes the mapped -agent, retains its reply in 1Helm, and sends that final reply to the exact inbound -conversation once. If the agent explicitly uses `photon_send`, the automatic -return suppresses its duplicate. New unrelated outbound destinations remain -blocked unless explicitly granted. Rich attachment fidelity is not yet part of -the verified contract. +phone registration, and sidecar health. There are no channel mappings: the +Captain's configured phone always talks directly to Skipper. + +The first inbound text creates a private conversation in `#main`'s **Texts** +tab. Phone messages and Skipper's iMessage replies are retained there, and the +conversation keeps one context across connector restarts and Photon space-ID +changes until the Captain sends `/new`. The Captain can continue the same +thread inside 1Helm and return to the phone later; desktop-only turns stay in +1Helm rather than being replayed as iMessages. Rich attachment fidelity is not +yet part of the verified contract. ## Workflows, follow-ups, and computer care diff --git a/package-lock.json b/package-lock.json index 299fe53..f30380e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.10", + "version": "0.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.10", + "version": "0.0.11", "license": "AGPL-3.0-only", "hasInstallScript": true, "dependencies": { diff --git a/package.json b/package.json index 4e7aac7..c8af99b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.10", + "version": "0.0.11", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/public/index.html b/public/index.html index 9e3e5cc..9d3bad2 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/src/client/api.ts b/src/client/api.ts index ab207de..3a44703 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -13,7 +13,7 @@ export type AgentQuestions = { answers?: Array<{ question_id: string; question: string; values: string[]; custom: string }> | null; answered?: number | null; }; -export type Message = { id: number; channel_id: number; parent_id: number | null; body: string; created: number; reply_count: number; last_reply: number | null; author: Author; attachments: Attachment[]; progress?: AgentProgress[]; questions?: AgentQuestions | null }; +export type Message = { id: number; channel_id: number; parent_id: number | null; body: string; created: number; reply_count: number; last_reply: number | null; author: Author; attachments: Attachment[]; progress?: AgentProgress[]; questions?: AgentQuestions | null; photon_conversation_id?: number | null; transport?: "inbound" | "outbound" | "app" }; export type ModelPolicy = { provider_id: number | null; provider_name: string | null; provider_kind: string | null; model: string; overridden: boolean; editable: boolean }; export type ResidentAgent = { id: number; bot_id: number; kind: "channel" | "skipper"; name: string; display_name: string; @@ -57,6 +57,7 @@ export type ThreadState = { followup?: ThreadFollowup | null; }; export type GlobalThread = ThreadState & { channel_name: string; channel_slug: string; unread: boolean }; +export type TextConversation = { id: number; sender: string; root_message_id: number; thread_id: number; active: number; started: number; updated: number; closed?: number | null; title: string; summary: string; status: string; last_body?: string; message_count?: number; messages?: Message[] }; export type ChannelFile = { path: string; name: string; size: number; modified: number; kind: "file" | "directory" }; export type MemoryItem = { id: number; channel_id: number; thread_id: number | null; root_message_id?: number | null; kind: "summary" | "decision" | "fact" | "preference" | "artifact_ref"; content: string; author_type: string; scope: string; status: "current" | "superseded"; created: number }; export type ActivityItem = { diff --git a/src/client/app.ts b/src/client/app.ts index 0185baa..6b15efb 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -5,7 +5,7 @@ import { hydrateNotificationPreferences, playNotification } from "./notification 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, renderThreads, type ChannelView } from "./channel.ts"; +import { openCreateChannel, renderActivity, renderBoard, renderChannelSettings, renderFiles, renderGlobalThreads, renderMemory, renderNotes, renderTexts, renderThreads, type ChannelView } from "./channel.ts"; /** Per-channel layout bound to the user profile (server user_ui_state). */ type ChannelUiView = { @@ -38,6 +38,8 @@ type State = { globalThreadsUnreadOnly: boolean; /** Profile-bound sidebar preference loaded from /api/me/ui-state. */ groupUnreadChannelsFirst: boolean; + photonConfigured: boolean; + selectedTextConversationId: number | null; }; export const S = { mobileMenuOpen: false, @@ -49,6 +51,8 @@ export const S = { globalThreadsOpen: false, globalThreadsUnreadOnly: false, groupUnreadChannelsFirst: false, + photonConfigured: false, + selectedTextConversationId: null, threadUsage: { input_tokens: 0, output_tokens: 0 }, threadFollowup: null, } as State; @@ -152,7 +156,7 @@ export function toggleTheme(): void { } type AppRoute = { slug: string | null; view: ChannelView; threadRootId: number | null }; -const VIEWS = new Set(["chat", "board", "threads", "notes", "files", "terminal", "memory", "activity", "settings"]); +const VIEWS = new Set(["chat", "texts", "board", "threads", "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 }; @@ -304,16 +308,18 @@ async function resyncAfterReconnect(): Promise { } async function loadWorkspace(): Promise { - const [ch, us, bots, comps, provs, ws] = await Promise.all([ + const [ch, us, bots, comps, provs, ws, photon] = await Promise.all([ api<{ channels: Channel[] }>("/api/channels"), api<{ users: User[] }>("/api/users"), api<{ bots: Bot[] }>("/api/bots"), S.me.is_admin ? api<{ computers: Computer[] }>("/api/computers") : Promise.resolve({ computers: [] }), S.me.is_admin ? api<{ providers: Provider[] }>("/api/providers") : Promise.resolve({ providers: [] }), api<{ workspace: Workspace }>("/api/workspace").catch(() => ({ workspace: S.workspace })), + S.me.is_admin ? api<{ photon: { configured: boolean } }>("/api/connectors/photon").catch(() => ({ photon: { configured: false } })) : Promise.resolve({ photon: { configured: false } }), ]); S.channels = ch.channels; S.users = us.users; S.bots = bots.bots; S.computers = comps.computers; S.providers = provs.providers; if (ws?.workspace) S.workspace = ws.workspace; + S.photonConfigured = Boolean(photon?.photon?.configured); if (!S.channelId || !S.channels.find((c) => c.id === S.channelId)) { const main = S.channels.find((c) => c.name === "main" && c.kind === "channel"); S.channelId = main?.id || S.channels[0]?.id || 0; @@ -323,6 +329,8 @@ async function loadWorkspace(): Promise { async function openChannel(id: number, view: ChannelView = "chat", threadRootId: number | null = null, replaceRoute = false): Promise { // Persist the channel we're leaving so terminal/thread docks survive hops. if (S.channelId && S.channelId !== id) persistCurrentChannelView(); + const requestedChannel = S.channels.find((channel) => channel.id === id); + if (view === "texts" && !textsAvailable(requestedChannel)) view = "chat"; S.channelId = id; S.threadRoot = null; S.threadFollowup = null; S.threadUsage = { input_tokens: 0, output_tokens: 0 }; S.view = view; S.globalThreadsOpen = false; applyChannelViewToState(id); // Full Terminal tab is separate from the docked header terminal. @@ -391,6 +399,10 @@ function onEvent(e: any): void { if (e.type === "message" || e.type === "message_update") { const msg = e.message as Message; if (!msg?.channel_id) return; + if (msg.photon_conversation_id) { + if (S.view === "texts") renderChannelView(); + return; + } const mine = msg.author?.kind === "user" && msg.author.id === S.me.id; const mentionsMe = new RegExp(`@${S.me.username}\\b`, "i").test(msg.body || ""); // Only "viewing" a channel when its chat surface is open — not while sitting in global Threads. @@ -419,6 +431,10 @@ function onEvent(e: any): void { // Working… started in another channel — amber dots come from agent_status; no badge yet. playNotification(msg.channel_id, mentionsMe ? "mention" : "msg"); } + } else if (e.type === "photon_update") { + S.photonConfigured = true; + if (e.conversationId) S.selectedTextConversationId = Number(e.conversationId); + if (S.view === "texts") renderChannelView(); else renderHeader(); } else if (e.type === "message_deleted") { applyMessageDeleted(e); } else if (e.type === "mention_confirmation") { @@ -707,6 +723,9 @@ 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; 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" }, @@ -715,6 +734,10 @@ export function renderApp(): void { 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); + } } function setMobileMenu(open: boolean): void { @@ -1340,13 +1363,18 @@ function renderGlobalThreadsHeader(): void { }, S.globalThreadsUnreadOnly ? "Unread · on" : "Unread · off"))); } +function textsAvailable(channel?: Channel): boolean { + return Boolean(S.me.is_admin && S.photonConfigured && channel?.kind === "channel" && channel.name === "main" && channel.personal_main); +} + function channelTabs(): HTMLElement { const currentChannel = S.channels.find((channel) => channel.id === S.channelId); const tabs: [ChannelView, string][] = [ - ["chat", "Chat"], ["board", "Board"], ["threads", "Threads"], ["notes", "Notes"], ["files", "Files"], + ["chat", "Chat"], ["texts", "Texts"], ["board", "Board"], ["threads", "Threads"], ["notes", "Notes"], ["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 + .filter(([id]) => id !== "texts" || textsAvailable(currentChannel)) .filter(([id]) => id !== "terminal" || S.workspace?.terminals_enabled !== false) .filter(([id]) => id !== "terminal" || currentChannel?.agent?.kind === "channel" || S.me.is_admin) .filter(([id]) => id !== "settings" || Boolean(currentChannel?.can_manage)) @@ -1354,6 +1382,7 @@ function channelTabs(): HTMLElement { } export function navigateChannelView(view: ChannelView): void { + if (view === "texts" && !textsAvailable(S.channels.find((channel) => channel.id === S.channelId))) view = "chat"; S.view = view; S.threadRoot = null; S.globalThreadsOpen = false; if (view === "terminal") { S.terminalOpen = false; @@ -1428,7 +1457,8 @@ export function renderChannelView(): void { const container = document.getElementById("channelview"); const channel = S.channels.find((item) => item.id === S.channelId); if (!container || !channel) return; - if (S.view === "board") renderBoard(container, channel.id, (root) => { + if (S.view === "texts") renderTexts(container, S.selectedTextConversationId || undefined, (id) => { S.selectedTextConversationId = id; renderChannelView(); }); + 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"; @@ -1492,10 +1522,10 @@ function renderHeader(): void { } }; add(el, - h("div", { class: "flex min-w-0 flex-1 items-start gap-2" }, + h("div", { class: "flex min-w-0 flex-1 items-center gap-2 overflow-hidden" }, mobileMenuButton(), - h("div", { class: "flex min-w-0 max-w-[46%] shrink items-start gap-1 text-[15px] font-semibold leading-5 tracking-[-0.01em] text-fg sm:max-w-none sm:text-[16px]" }, channel?.kind === "dm" ? null : h("span", { class: "shrink-0 font-normal text-faint" }, "#"), h("span", { class: "min-w-0 whitespace-normal break-words", title: channel?.name || "" }, channel?.name || "")), - channel?.purpose ? h("span", { class: "hidden min-w-0 max-w-[38vw] whitespace-normal break-words border-l border-line pl-2.5 text-[12px] leading-4 text-muted xl:inline", title: channel.purpose }, channel.purpose) : null, + h("div", { class: "flex min-w-0 max-w-[46%] shrink items-center gap-1 text-[15px] font-semibold leading-5 tracking-[-0.01em] text-fg sm:max-w-none sm:text-[16px]" }, channel?.kind === "dm" ? null : h("span", { class: "shrink-0 font-normal text-faint" }, "#"), h("span", { class: "min-w-0 truncate", title: channel?.name || "" }, channel?.name || "")), + channel?.purpose ? h("span", { class: "hidden min-w-0 max-w-[min(38vw,32rem)] truncate border-l border-line pl-2.5 text-[12px] leading-4 text-muted 2xl:block", title: channel.purpose }, channel.purpose) : null, channel?.status === "archived" ? h("span", { class: "chip shrink-0" }, "Paused") : null), h("div", { class: "flex max-w-[52%] shrink-0 items-center justify-end gap-1.5 sm:max-w-none sm:gap-2" }, channel ? h("button", { @@ -3040,12 +3070,9 @@ function registerServiceWorker(): void { // Pick up a new SW when the host ships a build — no manual hard refresh. void reg.update().catch(() => {}); setInterval(() => { void reg.update().catch(() => {}); }, 60 * 60_000); - navigator.serviceWorker.addEventListener("controllerchange", () => { - // One soft reload after SW takeover so the stamped index/bundle bind. - if ((window as any).__1helmSwReloaded) return; - (window as any).__1helmSwReloaded = true; - location.reload(); - }); + // A newly activated worker controls future navigations. Never force this + // live document to reload: that can destroy an editor draft or pull the + // Captain out of the note/thread they are using. }) .catch((error) => console.warn("1Helm service worker registration failed", error)); } diff --git a/src/client/channel.ts b/src/client/channel.ts index 90fb812..03f4efe 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -1,9 +1,9 @@ -import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, type ActivityItem, type AgentTemplate, type Channel, type ChannelFile, type GlobalThread, type MemoryItem, type Message, type ThreadState, type RoutingModel } from "./api.ts"; +import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, type ActivityItem, type AgentTemplate, type Channel, type ChannelFile, type GlobalThread, type MemoryItem, type Message, type TextConversation, type ThreadState, type RoutingModel } from "./api.ts"; 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" | "board" | "threads" | "notes" | "files" | "terminal" | "memory" | "activity" | "settings"; +export type ChannelView = "chat" | "texts" | "board" | "threads" | "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; @@ -369,6 +369,56 @@ export function renderGlobalThreads( }).catch((error) => 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."); + void api<{ conversations: TextConversation[] }>("/api/texts").then(async ({ conversations }) => { + 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" }); + 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.")); + for (const conversation of conversations) list.append(h("button", { + class: `mb-1 block w-full rounded-lg border px-3 py-2.5 text-left ${selected?.id === conversation.id ? "border-accent/30 bg-accent-soft" : "border-transparent hover:border-line hover:bg-hover"}`, + type: "button", dataset: { textConversation: String(conversation.id) }, onclick: () => onSelect?.(conversation.id), + }, h("span", { class: "flex items-center gap-2" }, h("span", { class: "min-w-0 flex-1 truncate text-sm font-semibold text-fg" }, conversation.title || "Text with Skipper"), conversation.active ? h("span", { class: "h-1.5 w-1.5 rounded-full bg-ok", title: "Current thread" }) : null), + h("span", { class: "mt-1 block truncate text-xs text-muted" }, conversation.last_body || conversation.summary || "Conversation"), + h("span", { class: "mt-1 block font-mono text-[10px] text-faint" }, `${conversation.message_count || 0} messages · ${timeLabel(conversation.updated)}`))); + sidebar.append(list); + const conversationPanel = h("section", { class: "flex min-h-0 min-w-0 flex-col bg-surface" }); + if (!selected) { + 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}`); + 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) } }); + 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"}` }, + h("article", { class: `max-w-[min(88%,44rem)] rounded-xl border px-3.5 py-2.5 ${captain ? "border-accent/25 bg-accent-soft" : "border-line bg-raised/60"}` }, + h("div", { class: "mb-1 flex items-center gap-2 font-mono text-[10px] text-faint" }, h("span", { class: "font-semibold text-muted" }, captain ? "You" : "Skipper"), h("span", {}, "·"), h("span", {}, message.transport === "inbound" ? "Phone" : message.transport === "outbound" ? "iMessage reply" : "1Helm"), h("span", {}, "·"), h("span", {}, timeLabel(message.created))), + h("div", { class: "md text-sm leading-6 text-fg", html: md(message.body) })))); + } + const input = h("textarea", { class: "max-h-40 min-h-12 flex-1 resize-none bg-transparent px-1 py-1 text-sm text-fg outline-none placeholder:text-faint", rows: 2, placeholder: "Continue with Skipper in 1Helm…", dataset: { textsComposer: String(selected.id) } }) as HTMLTextAreaElement; + const status = h("span", { class: "text-xs text-muted", role: "status" }); + const send = h("button", { class: "btn-primary shrink-0", type: "button" }, "Send") as HTMLButtonElement; + const submit = async (): Promise => { + const body = input.value.trim(); if (!body || send.disabled) return; + send.disabled = true; input.disabled = true; status.textContent = "Sending to Skipper…"; + try { await api(`/api/texts/${selected.id}`, { body: { body } }); input.value = ""; onSelect?.(selected.id); } + catch (error) { status.textContent = (error as Error).message; send.disabled = false; input.disabled = false; input.focus(); } + }; + send.onclick = () => { void submit(); }; + 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; }); + } + 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)); +} + export function renderFiles(container: HTMLElement, channelId: number, initialPath = ""): void { let currentPath = initialPath.replace(/^\/?workspace\/?/, "").replace(/^\/+|\/+$/g, ""); panelLoading(container, "Files", "Browse the same folders the channel computer sees under /workspace."); @@ -432,38 +482,63 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa } type ChannelNoteView = { name: string; size: number; modified: number; content?: string }; +type NoteSurface = { node: HTMLElement; setClose: (onClose?: () => void) => void; reload: () => Promise }; + +/** Notes are a long-lived editor, not disposable render output. Shell refreshes + * (including event-socket reconnects) move this node instead of recreating it, + * preserving the active note, draft, focus, selection, and preview mode. */ +const noteSurfaces = new Map(); /** Dock-ready Notes surface; app.ts only needs to mount this function for a tab or side dock. */ export function renderNotes(container: HTMLElement, channelId: number, onClose?: () => void): void { + const cached = noteSurfaces.get(channelId); + if (cached) { + cached.setClose(onClose); + clear(container); + container.append(cached.node); + void cached.reload().catch(() => undefined); + return; + } let notes: ChannelNoteView[] = []; let active: ChannelNoteView | null = null; let savedContent = ""; let loadingNote = false; - const editor = h("textarea", { class: "field min-h-[24rem] flex-1 resize-none font-mono text-sm leading-6", placeholder: "Choose a note, or create one.", disabled: true, "aria-label": "Note content" }) as HTMLTextAreaElement; + let closeHandler = onClose; + let previewing = false; + let filter = ""; + const editor = h("textarea", { class: "note-editor min-h-[24rem] flex-1 resize-none bg-surface px-5 py-4 font-mono text-sm leading-6 text-fg outline-none placeholder:text-faint", placeholder: "Choose a note, or create one.", disabled: true, "aria-label": "Note content", spellcheck: "true" }) as HTMLTextAreaElement; + const preview = h("div", { class: "md note-preview hidden min-h-[24rem] flex-1 overflow-y-auto px-6 py-5 text-sm leading-6 text-fg", dataset: { notePreview: "" } }); const title = h("div", { class: "truncate font-semibold text-fg" }, "No note selected"); const saveStatus = h("span", { class: "text-xs text-muted", role: "status" }); const saveButton = h("button", { class: "btn-primary text-sm", type: "button", disabled: true }, "Save") as HTMLButtonElement; const renameButton = h("button", { class: "btn-subtle text-sm", type: "button", disabled: true }, "Rename") as HTMLButtonElement; - const list = h("div", { class: "min-h-0 overflow-y-auto", dataset: { noteList: "" } }); + const editButton = h("button", { class: "note-mode-active rounded px-2.5 py-1 text-xs font-semibold", type: "button", "aria-pressed": "true" }, "Write") as HTMLButtonElement; + const previewButton = h("button", { class: "rounded px-2.5 py-1 text-xs font-semibold text-muted hover:text-fg", type: "button", "aria-pressed": "false", dataset: { notePreviewToggle: "" } }, "Preview") as HTMLButtonElement; + const list = h("div", { class: "min-h-0 flex-1 overflow-y-auto px-2 pb-3", dataset: { noteList: "" } }); + const search = h("input", { class: "field h-9 bg-surface text-xs", type: "search", placeholder: "Find a note", "aria-label": "Find a note" }) as HTMLInputElement; const dirty = (): boolean => Boolean(active) && editor.value !== savedContent; const updateEditorState = (): void => { saveButton.disabled = !active || !dirty(); saveStatus.textContent = dirty() ? "Unsaved changes" : active ? `Saved · ${formatBytes(active.size)}` : ""; + if (previewing) preview.innerHTML = md(editor.value || "_This note is empty._"); }; const confirmDiscard = async (): Promise => !dirty() || appConfirm("Discard the unsaved changes to this note?"); const drawList = (): void => { clear(list); - if (!notes.length) list.append(empty("No notes yet", "Create a Markdown note for plans, meeting notes, or working context.")); - for (const note of notes) list.append(h("button", { class: `block w-full border-b border-line px-3 py-2.5 text-left last:border-0 ${active?.name === note.name ? "bg-accent-soft" : "hover:bg-hover"}`, type: "button", dataset: { noteName: note.name }, onclick: async () => { + const visible = notes.filter((note) => !filter || note.name.toLowerCase().includes(filter)); + if (!notes.length) list.append(empty("No notes yet", "Create a note for plans, meetings, or working context.")); + else if (!visible.length) list.append(h("p", { class: "px-2 py-8 text-center text-xs leading-5 text-faint" }, "No notes match that search.")); + for (const note of visible) list.append(h("button", { class: `group mb-1 flex w-full items-start gap-2.5 rounded-lg border px-2.5 py-2.5 text-left transition ${active?.name === note.name ? "border-accent/30 bg-accent-soft" : "border-transparent hover:border-line hover:bg-hover"}`, type: "button", dataset: { noteName: note.name }, onclick: async () => { if (loadingNote || active?.name === note.name || !(await confirmDiscard())) return; loadingNote = true; try { const result = await api<{ note: ChannelNoteView }>(`/api/channels/${channelId}/notes/${encodeURIComponent(note.name)}`); active = result.note; savedContent = result.note.content || ""; editor.value = savedContent; editor.disabled = false; - title.textContent = result.note.name; renameButton.disabled = false; drawList(); updateEditorState(); editor.focus(); + title.textContent = result.note.name; renameButton.disabled = false; previewButton.disabled = false; drawList(); updateEditorState(); editor.focus(); } catch (error) { void appAlert((error as Error).message); } finally { loadingNote = false; } - } }, h("span", { class: "block truncate font-mono text-sm text-fg" }, note.name), h("span", { class: "mt-0.5 block text-xs text-muted" }, `${formatBytes(note.size)} · ${timeLabel(note.modified)}`))); + } }, h("span", { class: `mt-0.5 shrink-0 ${active?.name === note.name ? "text-accent" : "text-faint group-hover:text-muted"}` }, icon("file", 16)), + h("span", { class: "min-w-0 flex-1" }, h("span", { class: "block truncate text-sm font-semibold text-fg" }, note.name.replace(/\.md$/i, "")), h("span", { class: "mt-0.5 block truncate text-[11px] text-muted" }, `${timeLabel(note.modified)} · ${formatBytes(note.size)}`)))); }; const reloadList = async (selectName?: string): Promise => { const result = await api<{ notes: ChannelNoteView[] }>(`/api/channels/${channelId}/notes`); @@ -482,7 +557,7 @@ export function renderNotes(container: HTMLElement, channelId: number, onClose?: saveButton.onclick = () => { void save(); }; renameButton.onclick = async () => { if (!active || !(await confirmDiscard())) return; - const name = await appPrompt("Rename note (.md)", active.name); + const name = await appPrompt("Rename note", active.name); if (!name || name === active.name) return; renameButton.disabled = true; try { @@ -492,27 +567,86 @@ export function renderNotes(container: HTMLElement, channelId: number, onClose?: } catch (error) { void appAlert((error as Error).message); } finally { renameButton.disabled = !active; } }; + const insertFormatting = (before: string, after = before, placeholder = "text"): void => { + if (!active) return; + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selected = editor.value.slice(start, end) || placeholder; + editor.setRangeText(`${before}${selected}${after}`, start, end, "end"); + editor.focus(); + editor.dispatchEvent(new Event("input")); + }; + const prefixLines = (prefix: string): void => { + if (!active) return; + const start = editor.value.lastIndexOf("\n", Math.max(0, editor.selectionStart - 1)) + 1; + const selectionEnd = editor.selectionEnd; + const nextBreak = editor.value.indexOf("\n", selectionEnd); + const end = nextBreak < 0 ? editor.value.length : nextBreak; + const text = editor.value.slice(start, end) || "Text"; + editor.setRangeText(text.split("\n").map((line) => `${prefix}${line}`).join("\n"), start, end, "select"); + editor.focus(); + editor.dispatchEvent(new Event("input")); + }; + const setPreview = (next: boolean): void => { + previewing = next; + editor.classList.toggle("hidden", next); + preview.classList.toggle("hidden", !next); + editButton.className = `${next ? "text-muted hover:text-fg" : "note-mode-active"} rounded px-2.5 py-1 text-xs font-semibold`; + previewButton.className = `${next ? "note-mode-active" : "text-muted hover:text-fg"} rounded px-2.5 py-1 text-xs font-semibold`; + editButton.setAttribute("aria-pressed", String(!next)); + previewButton.setAttribute("aria-pressed", String(next)); + updateEditorState(); + if (!next) editor.focus(); + }; + editButton.onclick = () => setPreview(false); + previewButton.onclick = () => setPreview(true); + search.oninput = () => { filter = search.value.trim().toLowerCase(); drawList(); }; editor.oninput = updateEditorState; - editor.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void save(); } }); + editor.addEventListener("keydown", (event) => { + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === "s") { event.preventDefault(); void save(); } + else if (key === "b") { event.preventDefault(); insertFormatting("**", "**", "bold text"); } + else if (key === "i") { event.preventDefault(); insertFormatting("*", "*", "italic text"); } + }); const newButton = h("button", { class: "btn-primary text-sm", type: "button", onclick: async () => { if (!(await confirmDiscard())) return; - const name = await appPrompt("New note name (.md)", "untitled.md"); + const name = await appPrompt("New note name", "Untitled"); if (!name) return; try { const result = await api<{ note: ChannelNoteView }>(`/api/channels/${channelId}/notes`, { body: { name, content: "" } }); - active = result.note; savedContent = result.note.content || ""; editor.value = savedContent; editor.disabled = false; title.textContent = result.note.name; renameButton.disabled = false; await reloadList(result.note.name); updateEditorState(); editor.focus(); + active = result.note; savedContent = result.note.content || ""; editor.value = savedContent; editor.disabled = false; title.textContent = result.note.name; renameButton.disabled = false; previewButton.disabled = false; setPreview(false); await reloadList(result.note.name); updateEditorState(); editor.focus(); } catch (error) { void appAlert((error as Error).message); } } }, icon("plus"), "New note"); - const closeButton = onClose ? h("button", { class: "btn-subtle text-sm", type: "button", "aria-label": "Close notes", onclick: async () => { if (await confirmDiscard()) onClose(); } }, icon("x"), "Close") : null; + const closeButton = h("button", { class: "btn-subtle text-sm", type: "button", "aria-label": "Close notes", onclick: async () => { if (closeHandler && await confirmDiscard()) closeHandler(); } }, icon("x"), "Close") as HTMLButtonElement; + const formatting = h("div", { class: "flex min-w-0 flex-wrap items-center gap-1 border-b border-line bg-raised/40 px-3 py-2", role: "toolbar", "aria-label": "Note formatting" }, + h("button", { class: "note-format-button", type: "button", title: "Heading", onclick: () => prefixLines("## ") }, "H"), + h("button", { class: "note-format-button font-bold", type: "button", title: "Bold", onclick: () => insertFormatting("**", "**", "bold text") }, "B"), + h("button", { class: "note-format-button italic", type: "button", title: "Italic", onclick: () => insertFormatting("*", "*", "italic text") }, "I"), + h("button", { class: "note-format-button", type: "button", title: "Bulleted list", onclick: () => prefixLines("- ") }, "• List"), + h("button", { class: "note-format-button", type: "button", title: "Numbered list", onclick: () => prefixLines("1. ") }, "1. List"), + h("button", { class: "note-format-button font-mono", type: "button", title: "Inline code", onclick: () => insertFormatting("`", "`", "code") }, "Code"), + h("button", { class: "note-format-button", type: "button", title: "Link", onclick: () => insertFormatting("[", "](https://)", "link text") }, "Link")); clear(container); - container.append(h("section", { class: "flex h-full min-h-[32rem] flex-col", dataset: { notesSurface: String(channelId) } }, + const node = h("section", { class: "flex h-full min-h-[32rem] flex-col bg-surface", dataset: { notesSurface: String(channelId) } }, h("div", { class: "flex items-start gap-3 border-b border-line px-4 py-3" }, - h("div", { class: "min-w-0 flex-1" }, h("h2", { class: "font-display text-xl text-fg" }, "Notes"), h("p", { class: "mt-0.5 text-xs text-muted" }, "Markdown notes shared with this channel computer in /workspace/notes.")), + h("div", { class: "min-w-0 flex-1" }, h("h2", { class: "font-display text-xl text-fg" }, "Notes"), h("p", { class: "mt-0.5 text-xs text-muted" }, "Fast Markdown notes shared with this channel's /workspace/notes.")), newButton, closeButton), - h("div", { class: "grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[15rem_minmax(0,1fr)]" }, - h("aside", { class: "min-h-0 border-b border-line bg-raised md:border-b-0 md:border-r" }, list), - h("div", { class: "flex min-h-0 flex-col gap-3 p-4" }, - h("div", { class: "flex min-h-9 items-center gap-2" }, title, h("div", { class: "flex-1" }), saveStatus, renameButton, saveButton), editor)))); + h("div", { class: "grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[17rem_minmax(0,1fr)]" }, + h("aside", { class: "flex min-h-0 flex-col border-b border-line bg-raised/55 md:border-b-0 md:border-r" }, + h("div", { class: "space-y-2 p-3" }, h("div", { class: "flex items-center justify-between gap-2" }, h("span", { class: "eyebrow text-muted" }, "All notes"), h("span", { class: "font-mono text-[10px] text-faint" }, "Markdown")), search), list), + h("div", { class: "flex min-h-0 min-w-0 flex-col" }, + h("div", { class: "flex min-h-12 flex-wrap items-center gap-2 border-b border-line px-4 py-2" }, title, h("div", { class: "flex-1" }), h("div", { class: "flex rounded-md bg-raised p-0.5" }, editButton, previewButton), saveStatus, renameButton, saveButton), + formatting, + h("div", { class: "flex min-h-0 flex-1 flex-col overflow-hidden" }, editor, preview)))); + closeButton.classList.toggle("hidden", !closeHandler); + const surface: NoteSurface = { + node, + setClose: (next) => { closeHandler = next; closeButton.classList.toggle("hidden", !next); }, + reload: reloadList, + }; + noteSurfaces.set(channelId, surface); + container.append(node); void reloadList().catch((error) => panelError(container, error)); } diff --git a/src/client/settings.ts b/src/client/settings.ts index 3a342dc..03423ea 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -314,8 +314,7 @@ function learnSkillDialog(): void { overlay.append(modal); document.body.append(overlay); notes.focus(); } -type PhotonMapping = { channel_id: number; channel_name: string; agent_name: string; allowed_users: string; updated: number }; -type PhotonStatus = { configured: boolean; connected: boolean; project_id: string; operator_phone: string; assigned_phone: string; secret: "stored" | "missing"; mappings: PhotonMapping[] }; +type PhotonStatus = { configured: boolean; connected: boolean; project_id: string; operator_phone: string; assigned_phone: string; secret: "stored" | "missing"; thread_count: number }; type PhotonSetup = { active: boolean; status?: string | PhotonStatus; operator_phone?: string; user_code?: string; verification_uri?: string; verification_uri_complete?: string; expires_at?: number; error?: string; connector?: PhotonStatus }; type GmailStatus = { accounts: string[]; has_oauth_client: boolean; setup: { active: boolean; status: string; authorization_url?: string; manual_completion?: boolean; error?: string } }; @@ -393,7 +392,7 @@ function connectionsPanel(): HTMLElement { clear(box); box.append( h("div", { class: "flex flex-wrap items-start justify-between gap-3" }, - h("div", {}, h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-display text-lg text-fg" }, "Photon · iMessage"), h("span", { class: `chip ${photon.connected ? "border-emerald-500/30 text-emerald-700 dark:text-emerald-300" : "border-amber-400/40 text-amber-700 dark:text-amber-300"}` }, photon.connected ? "Connected" : photon.configured ? "Reconnecting" : "Not configured")), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "A supervised loopback connector streams task-scoped messages into the mapped resident. Photon credentials stay on the 1Helm host; residents never receive the Mac Messages database.")), + h("div", {}, h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-display text-lg text-fg" }, "Photon · iMessage"), h("span", { class: `chip ${photon.connected ? "border-emerald-500/30 text-emerald-700 dark:text-emerald-300" : "border-amber-400/40 text-amber-700 dark:text-amber-300"}` }, photon.connected ? "Connected" : photon.configured ? "Reconnecting" : "Not configured")), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Text your 1Helm number to talk directly with Skipper. Conversations stay private to your Texts inbox and continue until you send /new. Photon credentials stay on the 1Helm host.")), photon.assigned_phone ? h("div", { class: "text-right text-xs text-muted" }, "Text 1Helm at", h("strong", { class: "mt-1 block text-sm text-fg" }, photon.assigned_phone)) : null), ); if (!photon.configured || ["waiting", "provisioning", "failed", "expired"].includes(String(setup.status || ""))) { @@ -413,18 +412,10 @@ function connectionsPanel(): HTMLElement { box.append(h("div", { class: "rounded-lg border border-accent/30 bg-accent-soft p-4" }, h("p", { class: "text-xs font-semibold uppercase tracking-wide text-muted" }, "Approve Photon login"), h("div", { class: "mt-2 flex flex-wrap items-center gap-3" }, h("code", { class: "rounded bg-panel px-3 py-2 text-lg font-bold text-fg" }, setup.user_code), h("a", { class: "btn-primary text-sm", href: destination, target: "_blank", rel: "noopener noreferrer" }, "Open Photon ↗")), h("p", { class: "mt-2 text-xs text-muted" }, "This page updates automatically after approval."))); } if (photon.configured) { - const main = S.channels.find((entry) => entry.kind === "channel" && entry.name === "main" && entry.agent); - const channel = h("select", { class: "field" }, ...S.channels.filter((entry) => entry.kind === "channel" && entry.agent).map((entry) => h("option", { value: String(entry.id), selected: entry.id === main?.id || undefined }, `#${entry.name} · @${entry.agent!.name}`))) as HTMLSelectElement; - const allowed = h("input", { class: "field", type: "tel", placeholder: photon.operator_phone || "+15551234567", value: photon.operator_phone || "", autocomplete: "tel" }) as HTMLInputElement; - const mappingStatus = h("p", { class: "min-h-5 text-sm text-muted" }); - const map = h("button", { class: "btn-primary text-sm", onclick: async () => { - map.disabled = true; mappingStatus.textContent = "Applying least-privilege mapping…"; - try { await api("/api/connectors/photon/map", { body: { channel_id: Number(channel.value), allowed_users: allowed.value.split(",").map((value) => value.trim()).filter(Boolean) } }); await refresh(); } - catch (error) { map.disabled = false; mappingStatus.textContent = (error as Error).message; } - } }, "Map conversation") as HTMLButtonElement; - box.append(h("div", { class: "space-y-3" }, h("div", {}, h("h4", { class: "font-semibold text-fg" }, "Conversation mappings"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "#main and Skipper are the default. An allowlisted inbound message opens a real thread, invokes the mapped agent, and 1Helm automatically returns that agent's final reply to the same authorized iMessage conversation.")), - ...photon.mappings.map((mapping) => h("div", { class: "rounded-lg border border-border bg-panel p-3" }, h("div", { class: "font-semibold text-fg" }, `#${mapping.channel_name} · @${mapping.agent_name}`), h("div", { class: "mt-1 text-xs text-muted" }, `Allowlisted: ${(() => { try { return (JSON.parse(mapping.allowed_users) as string[]).join(", "); } catch { return "invalid mapping"; } })()}`))), - h("div", { class: "grid gap-2 sm:grid-cols-[1fr_1fr_auto]" }, channel, allowed, map), mappingStatus)); + box.append(h("div", { class: "rounded-lg border border-line bg-panel p-4" }, + h("div", { class: "flex flex-wrap items-center justify-between gap-3" }, + h("div", {}, h("h4", { class: "font-semibold text-fg" }, "Texts inbox"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, `Messages from ${photon.operator_phone} talk to @skipper without belonging to any channel. Continue on phone or desktop; only phone-originated turns receive iMessage replies.`)), + h("span", { class: "chip" }, `${photon.thread_count || 0} thread${photon.thread_count === 1 ? "" : "s"}`)))); } if (setup.active) { stopPolling(); timer = setTimeout(() => { void refresh(); }, 2000); } else stopPolling(); diff --git a/src/client/styles.css b/src/client/styles.css index 19348a3..50981af 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -157,6 +157,18 @@ body { border-color: color-mix(in srgb, var(--c-accent) 70%, var(--c-line)); box-shadow: 0 0 0 3px var(--c-accent-soft); } + +/* Notes workspace */ +.note-format-button { + @apply inline-flex min-h-8 items-center justify-center rounded-md border border-transparent px-2 text-xs text-muted transition hover:border-line hover:bg-surface hover:text-fg; +} +.note-mode-active { + background: var(--c-surface); + color: var(--c-fg); + box-shadow: 0 1px 2px rgba(0,0,0,.12), inset 0 0 0 1px var(--c-line); +} +.note-editor:focus { box-shadow: inset 3px 0 0 var(--c-accent); } +.note-preview { background: color-mix(in srgb, var(--c-surface) 94%, var(--c-raised)); } select.field { @apply cursor-pointer appearance-none bg-[image:var(--chev)] bg-[length:16px] bg-[right_0.6rem_center] bg-no-repeat pr-9; min-height: 2.5rem; diff --git a/src/server/agents.ts b/src/server/agents.ts index 1ecea66..8785a5c 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -12,10 +12,15 @@ const WORLD_DIRS = ["workspace", "files", "state", "memory", "profile"]; 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 => { - const residentCount = Number(q1("SELECT COUNT(*) n FROM agents WHERE kind='channel' AND status<>'deleted'")?.n || 0); - const character = (residentCount + Number(randomBytes(1)[0])) % 9 + 1; - const color = AGENT_COLORS[Number(randomBytes(1)[0]) % AGENT_COLORS.length]; - return `agent:${character}:${color}`; + const options = Array.from({ length: 9 }, (_, character) => AGENT_COLORS.map((color) => `agent:${character + 1}:${color}`)).flat(); + const used = new Set(q(`SELECT b.avatar FROM bots b JOIN agents a ON a.bot_id=b.id + WHERE a.kind='channel' AND a.status<>'deleted'`).map((row) => String(row.avatar))); + const start = Number(randomBytes(1)[0]) % options.length; + for (let offset = 0; offset < options.length; offset++) { + const candidate = options[(start + offset) % options.length]; + if (!used.has(candidate)) return candidate; + } + return options[start]; }; export type ProvisionedChannel = { @@ -398,10 +403,19 @@ const MAX_NOTE_BYTES = 1024 * 1024; /** Notes are deliberately flat: their only path is /workspace/notes/. */ export function validateNoteFilename(input: string): string { const value = String(input || ""); - const name = value.trim(); - if (!name || name !== value || name === "." || name === ".." || name.length > 160 - || /[\\/\0-\x1f\x7f]/.test(name) || !name.toLowerCase().endsWith(".md") || name.toLowerCase() === ".md") { - throw new Error("Note names must be a plain .md filename without folders or control characters."); + let name = value.trim(); + if (!name || name !== value || name === "." || name === ".." + || /[\\/\0-\x1f\x7f]/.test(name)) { + throw new Error("Note names must be a plain Markdown filename without folders or control characters."); + } + const finalDot = name.lastIndexOf("."); + if (finalDot < 0) name += ".md"; + else if (!name.toLowerCase().endsWith(".md")) { + const corrected = `${name.slice(0, finalDot) || name}.md`; + throw new Error(`Notes use .md files. Try “${corrected}”.`); + } + if (name.toLowerCase() === ".md" || name.length > 160) { + throw new Error("Note names must be a plain Markdown filename no longer than 160 characters."); } return name; } diff --git a/src/server/bots.ts b/src/server/bots.ts index de12873..befbd46 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -8,7 +8,6 @@ import { availableGoogleAccounts, createGmailDraft, getGmailMessage, gmailConnec import { recallForAgent, rememberForAgent } from "./memory.ts"; import { agentSkillContext, createSkill, imageGenerationAvailable, listSkills, proposeSkill, provisionSkill, readAgentSkill, requestSkill, skillsForAgent } from "./skills.ts"; import { inspectCatalogSkill, installCatalogSkill, searchSkillCatalog } from "./skill-catalog.ts"; -import { grantPhotonToResident, photonMessages, sendPhoton } from "./photon.ts"; import { createWorkflow, listWorkflows, setWorkflowStatus } from "./workflows.ts"; import { runThreadAuditPass } from "./thread-audit.ts"; import { runImprovementPass } from "./improvements.ts"; @@ -104,7 +103,7 @@ function completedToolAnswer(tool: string, result: string): string { return parsed.setup?.error || "Gmail has no connected accounts yet. Open Settings → Connections to add the one-time Google OAuth client and authorize an account."; } catch { return result; } } - if (["grant_gmail_access", "connect_gmail", "grant_photon_access", "photon_search", "photon_send", "create_channel", "list_channels", "inspect_channel", "archive_channel", "restore_channel", "delete_channel", "inspect_fleet", "care_for_channel_computer", "list_obligations", "run_thread_audit", "run_agent_review", "remember", "search_channel_history", "read_channel_session", "call_skipper", "call_agent", "request_skill", "propose_skill", "create_skill", "search_skill_catalog", "inspect_skill", "install_skill", "invite_agent", "search_web", "inspect_web_source", "attach_web_image", "attach_file", "generate_image", "schedule_followup", "schedule_workflow", "list_workflows", "set_workflow_status"].includes(tool)) return result; + if (["grant_gmail_access", "connect_gmail", "create_channel", "list_channels", "inspect_channel", "archive_channel", "restore_channel", "delete_channel", "inspect_fleet", "care_for_channel_computer", "list_obligations", "run_thread_audit", "run_agent_review", "remember", "search_channel_history", "read_channel_session", "call_skipper", "call_agent", "request_skill", "propose_skill", "create_skill", "search_skill_catalog", "inspect_skill", "install_skill", "invite_agent", "search_web", "inspect_web_source", "attach_web_image", "attach_file", "generate_image", "schedule_followup", "schedule_workflow", "list_workflows", "set_workflow_status"].includes(tool)) return result; if (tool === "gmail_list_accounts") { try { const parsed = JSON.parse(result) as { accounts?: string[] }; @@ -257,14 +256,6 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo }, }, }); - if (q1("SELECT 1 FROM photon_channel_mappings WHERE channel_id=?", channelId)) tools.push({ - type: "function", - function: { - name: "grant_photon_access", - description: "Grant this channel resident host-brokered Photon iMessage search/draft access. Optionally allow sending only when the Captain's request clearly authorizes external messaging. Photon credentials never enter the resident computer.", - parameters: { type: "object", properties: { can_send: { type: "boolean" } } }, - }, - }); tools.push({ type: "function", function: { @@ -697,29 +688,6 @@ function toolsFor(bot: Row, agent: RuntimeAgent | undefined, hostAuthorized: boo }, }); } - const photon = q1("SELECT config FROM agent_capabilities WHERE agent_id=? AND capability='photon'", agent.id); - if (photon) { - let config: Record = {}; - try { config = JSON.parse(String(photon.config || "{}")); } catch { /* disabled */ } - if (config.can_read !== false) tools.push({ - type: "function", - function: { - name: "photon_search", - description: "Search task-scoped Photon iMessage history already brokered into this channel. No Photon credentials or unrelated conversations are returned.", - parameters: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 50 } } }, - }, - }); - if (config.can_send === true || config.can_reply === true) tools.push({ - type: "function", - function: { - name: "photon_send", - description: config.can_send === true - ? "Send an iMessage through the channel's host-scoped Photon connector. Use only when the user's intent authorizes sending; resolve the destination carefully and verify the returned message id." - : "Reply through Photon only to a conversation already delivered from an allowlisted sender into this channel. New outbound conversations remain disabled.", - parameters: { type: "object", properties: { space_id: { type: "string", description: "Existing Photon conversation id or an E.164 number." }, text: { type: "string" } }, required: ["space_id", "text"] }, - }, - }); - } } return tools.length ? tools : undefined; } @@ -871,8 +839,6 @@ function actionObject(tool: string, input: string, actor: string): string { if (tool === "gmail_search") return "granted Gmail"; if (tool === "gmail_get") return "a granted Gmail message"; if (tool === "gmail_create_draft") return "a Gmail draft"; - if (tool === "photon_search") return "granted iMessage history"; - if (tool === "photon_send") return "an authorized iMessage conversation"; if (tool === "run_command") return actor === "skipper" ? "the host workspace" : "the resident workspace"; if (tool === "schedule_followup") return "a durable wake"; if (tool === "schedule_workflow") return "a recurring workflow"; @@ -908,9 +874,6 @@ function actionVerb(tool: string): string { gmail_search: "Searched", gmail_get: "Read", gmail_create_draft: "Created", - grant_photon_access: "Granted", - photon_search: "Searched", - photon_send: "Sent to", schedule_followup: "Scheduled", schedule_workflow: "Scheduled", list_workflows: "Listed", @@ -1593,9 +1556,6 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread ? `#${normalizeChannelName(String(args.name || ""))}${args.purpose ? ` — ${String(args.purpose)}` : ""}` : name === "grant_gmail_access" ? `Grant Gmail read/search/draft access for ${Array.isArray(args.accounts) && args.accounts.length ? args.accounts.join(", ") : "all connected accounts"}` - : name === "grant_photon_access" ? `Grant Photon access${args.can_send ? " with sending" : " for search/drafts"}` - : name === "photon_search" ? String(args.query || "") - : name === "photon_send" ? `${String(args.space_id || "")}: ${String(args.text || "").slice(0, 200)}` : name === "gmail_search" ? `${String(args.account || "")}: ${String(args.query || "")}` : name === "gmail_get" @@ -1766,8 +1726,6 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread result = grantGmail(channelId, args.accounts); } else if (name === "connect_gmail" && agent?.kind === "skipper" && hostAuthorized) { result = JSON.stringify(args.start ? await startGmailConnection() : gmailConnectionStatus()); - } else if (name === "grant_photon_access" && agent?.kind === "skipper" && hostAuthorized) { - result = grantPhotonToResident(channelId, Boolean(args.can_send)); } else if (name === "invite_agent" && agent?.kind === "skipper" && hostAuthorized) { result = isMainChannel(channelId) ? "Error: resident agents cannot enter #main. Use Skipper's own tools directly." @@ -1826,10 +1784,6 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread const { account, config } = grantedGmail(agent, args.account, hostAuthorized); if (!config.can_draft) result = "Error: Gmail draft access is not granted to this channel."; else result = JSON.stringify(await createGmailDraft(account, String(args.to || ""), String(args.subject || ""), String(args.body || ""), turnSignal)); - } else if (name === "photon_search" && agent?.kind === "channel") { - result = JSON.stringify(await photonMessages(channelId, String(args.query || ""), Number(args.limit || 20))); - } else if (name === "photon_send" && agent?.kind === "channel") { - result = JSON.stringify(await sendPhoton(channelId, String(args.space_id || ""), String(args.text || ""))); } else if (name === "call_skipper" && agent?.kind === "channel") result = callSkipper(agent, channelId, threadRootId, input); else result = `Error: tool ${name} is not available.`; } catch (error) { diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 436e4bd..924e629 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.10"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.11"; 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 40676d1..ee3bb3f 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -581,6 +581,46 @@ export function migrate(): void { SELECT bot_id, NEW.channel_id FROM agents WHERE id=NEW.agent_id AND bot_id IS NOT NULL; END; `); + // Photon is a private Captain ↔ Skipper inbox. Legacy channel mappings are + // retained only long enough to migrate conversation history; they are no + // longer a user-facing routing primitive. + addColumn("messages", "photon_conversation_id", "photon_conversation_id INTEGER"); + db.exec("CREATE INDEX IF NOT EXISTS idx_messages_photon_conversation ON messages(photon_conversation_id,id)"); + const photonMain = q1(`SELECT c.id FROM channels c JOIN users u ON u.id=c.personal_main_owner_id + WHERE c.kind='channel' AND c.name='main' AND c.status<>'deleted' AND u.is_admin=1 + ORDER BY c.id LIMIT 1`); + if (photonMain?.id) { + for (const sender of q("SELECT sender FROM photon_conversations WHERE active=1 GROUP BY sender HAVING COUNT(*)>1")) { + const keep = q1("SELECT id FROM photon_conversations WHERE sender=? AND active=1 ORDER BY updated DESC,id DESC LIMIT 1", sender.sender); + run("UPDATE photon_conversations SET active=0,closed=COALESCE(closed,updated) WHERE sender=? AND active=1 AND id<>?", sender.sender, keep?.id || 0); + } + for (const conversation of q("SELECT id,channel_id,root_message_id,thread_id FROM photon_conversations ORDER BY id")) { + try { + tx(() => { + run("UPDATE messages SET channel_id=?,photon_conversation_id=? WHERE id=? OR parent_id=?", photonMain.id, conversation.id, conversation.root_message_id, conversation.root_message_id); + run("UPDATE threads SET channel_id=? WHERE id=?", photonMain.id, conversation.thread_id); + run("UPDATE agent_turns SET channel_id=? WHERE thread_root_id=?", photonMain.id, conversation.root_message_id); + run("UPDATE memory_items SET channel_id=? WHERE thread_id=?", photonMain.id, conversation.thread_id); + run("UPDATE channel_activity SET channel_id=? WHERE thread_id=?", photonMain.id, conversation.thread_id); + }); + } catch { + // Historical installations may have an in-flight agent_turn whose + // trigger id uniqueness prevents rebinding. Keep the conversation in + // its original world until the normal recovery pass settles the turn. + } + } + run(`UPDATE photon_conversations SET channel_id=? WHERE NOT EXISTS ( + SELECT 1 FROM messages m WHERE m.id=photon_conversations.root_message_id AND m.channel_id<>?)`, photonMain.id, photonMain.id); + run(`UPDATE photon_messages SET channel_id=(SELECT channel_id FROM photon_conversations pc + WHERE pc.id=(SELECT photon_conversation_id FROM messages m WHERE m.id=photon_messages.message_id)) + WHERE message_id IS NOT NULL AND EXISTS (SELECT 1 FROM messages m WHERE m.id=photon_messages.message_id AND m.photon_conversation_id IS NOT NULL)`); + } + // The table survives only as a schema-compatibility tombstone. Photon no + // longer has channel mappings: credentials identify one Captain and every + // accepted turn goes directly to that Captain's Skipper Texts inbox. + run("DELETE FROM photon_channel_mappings"); + db.exec("DROP INDEX IF EXISTS idx_photon_conversation_active"); + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_photon_conversation_active ON photon_conversations(sender) WHERE active=1"); addColumn("agents", "provider_inherited", "provider_inherited INTEGER NOT NULL DEFAULT 1"); addColumn("agents", "template_slug", "template_slug TEXT NOT NULL DEFAULT 'general'"); addColumn("model_prefs", "provider_id", "provider_id INTEGER"); @@ -939,7 +979,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.10"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.11"); // 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/feedback.ts b/src/server/feedback.ts index a9f395a..154c71e 100644 --- a/src/server/feedback.ts +++ b/src/server/feedback.ts @@ -42,8 +42,7 @@ function reportDiagnostics(appRoot: string): Diagnostics { const failed = q("SELECT tool capability,status,created FROM tool_actions WHERE status='failed' ORDER BY created DESC LIMIT 20") .map((row) => ({ capability: String(row.capability), status: String(row.status), created: Number(row.created) })); const connectorStatus: Array<{ connector: string; status: string }> = []; - const photon = q1("SELECT COUNT(*) mapped FROM photon_channel_mappings"); - connectorStatus.push({ connector: "photon", status: Number(photon?.mapped || 0) ? "mapped" : "not_configured" }); + connectorStatus.push({ connector: "photon", status: existsSync(join(DATA_DIR, "photon-credentials.json")) ? "configured" : "not_configured" }); const google = existsSync(join(DATA_DIR, "connections", "gmail", "tokens")); connectorStatus.push({ connector: "google_workspace", status: google ? "configured" : "not_configured" }); return { diff --git a/src/server/index.ts b/src/server/index.ts index e5edad3..25ac8a9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -64,7 +64,7 @@ import { stopAllConnectors } from "./connectors.ts"; import { ensureImageGenerationSkill, listSkills, listTemplates, provisionSkill, skillsForAgent, setImageGenerationEnabled, imageGenerationAvailable, imageGenerationEnabledIds } from "./skills.ts"; import { inspectCatalogSkill, installCatalogSkill, refreshSkillCatalog, searchSkillCatalog, skillCatalogStatus } from "./skill-catalog.ts"; import { auditEvents, verifyAuditChain } from "./audit.ts"; -import { configurePhoton, mapPhotonChannel, photonStatus, registerPhotonDispatcher, startPhotonConnector, stopPhotonConnector } from "./photon.ts"; +import { configurePhoton, continuePhotonConversation, deliverPhotonEvent, photonConversation, photonConversations, photonStatus, registerPhotonDispatcher, startPhotonConnector, stopPhotonConnector } from "./photon.ts"; import { photonSetupStatus, startPhotonSetup } from "./photon-auth.ts"; import { completeGmailConnection, gmailConnectionStatus, saveGmailOAuthClient, startGmailConnection } from "./gmail.ts"; import { cancelMnemosyneRuntimePreparation, ensureAgentMemory, mnemosyneAvailable, prepareMnemosyneRuntime } from "./memory.ts"; @@ -313,7 +313,7 @@ function messageIsSettledSql(alias = "m"): string { function maxSettledMessageId(channelId: number): number { return Number(q1( `SELECT MAX(m.id) x FROM messages m - WHERE m.channel_id=? AND ${messageIsSettledSql("m")}`, + WHERE m.channel_id=? AND m.photon_conversation_id IS NULL AND ${messageIsSettledSql("m")}`, channelId, )?.x || 0); } @@ -321,7 +321,7 @@ function maxSettledMessageId(channelId: number): number { function channelUnreadCount(userId: number, channelId: number, lastRead: number): number { return Number(q1( `SELECT COUNT(*) n FROM messages m - WHERE m.channel_id=? AND m.id>? AND (m.user_id IS NULL OR m.user_id<>?) + WHERE m.channel_id=? AND m.photon_conversation_id IS NULL AND m.id>? AND (m.user_id IS NULL OR m.user_id<>?) AND ${messageIsSettledSql("m")}`, channelId, lastRead, userId, )?.n || 0); @@ -1028,11 +1028,28 @@ const server = createServer(async (req, res) => { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); return json(res, 200, { photon: photonSetupStatus() }); } - if (p === "/api/connectors/photon/map" && m === "POST") { + if (p === "/api/texts" && m === "GET") { + if (!user.is_admin) return json(res, 403, { error: "Texts are private to the Captain." }); + return json(res, 200, { conversations: photonConversations(Number(user.id)) }); + } + if (p === "/api/testing/photon" && m === "POST" && process.env.NODE_ENV === "test") { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); const b = await jbody(req); - try { return json(res, 200, { photon: mapPhotonChannel(Number(b.channel_id), Array.isArray(b.allowed_users) ? b.allowed_users.map(String) : undefined) }); } - catch (error) { return json(res, 400, { error: (error as Error).message }); } + if (b.event) return json(res, 200, { accepted: await deliverPhotonEvent(b.event as never) }); + return json(res, 200, { outbound: Number(q1("SELECT COUNT(*) n FROM photon_messages WHERE direction='outbound'")?.n || 0) }); + } + const textConversation = p.match(/^\/api\/texts\/(\d+)$/); + if (textConversation && user.is_admin) { + const conversationId = Number(textConversation[1]); + if (m === "GET") { + const conversation = photonConversation(Number(user.id), conversationId); + return conversation ? json(res, 200, { conversation }) : json(res, 404, { error: "Text thread not found." }); + } + if (m === "POST") { + const b = await jbody(req); + try { return json(res, 200, { conversation: await continuePhotonConversation(Number(user.id), conversationId, String(b.body || "")) }); } + catch (error) { return json(res, 409, { error: (error as Error).message }); } + } } if (p === "/api/thread-audit/run" && m === "POST") { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); @@ -1177,11 +1194,11 @@ const server = createServer(async (req, res) => { const threads: Record[] = []; for (const channel of channels) { const channelId = Number(channel.id); - for (const root of q("SELECT id FROM messages WHERE channel_id=? AND parent_id IS NULL ORDER BY id", channelId)) { + for (const root of q("SELECT id FROM messages WHERE channel_id=? AND parent_id IS NULL AND photon_conversation_id IS NULL ORDER BY id", channelId)) { ensureThread(Number(root.id), channelId); } const lastRead = Number(q1("SELECT last_read FROM members WHERE channel_id=? AND user_id=?", channelId, user.id)?.last_read || 0); - for (const thread of q("SELECT * FROM threads WHERE channel_id=? ORDER BY updated_at DESC", channelId)) { + for (const thread of q("SELECT t.* FROM threads t JOIN messages m ON m.id=t.root_message_id WHERE t.channel_id=? AND m.photon_conversation_id IS NULL ORDER BY t.updated_at DESC", channelId)) { const rootId = Number(thread.root_message_id); const unread = Number(q1( `SELECT COUNT(*) n FROM messages m @@ -1284,8 +1301,8 @@ const server = createServer(async (req, res) => { } catch (error) { return json(res, 400, { error: (error as Error).message }); } } if (action === "threads" && m === "GET") { - for (const root of q("SELECT id FROM messages WHERE channel_id=? AND parent_id IS NULL ORDER BY id", channelId)) ensureThread(Number(root.id), channelId); - const threads = q("SELECT * FROM threads WHERE channel_id=? ORDER BY updated_at DESC", channelId).map((thread) => ({ + for (const root of q("SELECT id FROM messages WHERE channel_id=? AND parent_id IS NULL AND photon_conversation_id IS NULL ORDER BY id", channelId)) ensureThread(Number(root.id), channelId); + const threads = q("SELECT t.* FROM threads t JOIN messages m ON m.id=t.root_message_id WHERE t.channel_id=? AND m.photon_conversation_id IS NULL ORDER BY t.updated_at DESC", channelId).map((thread) => ({ ...thread, followup: threadFollowupView(Number(thread.id)), root: serializeMessage(Number(thread.root_message_id)), @@ -1500,7 +1517,7 @@ const server = createServer(async (req, res) => { if (m === "GET") { run("INSERT INTO members (channel_id, user_id, last_read) VALUES (?,?,?) ON CONFLICT(channel_id,user_id) DO UPDATE SET last_read=excluded.last_read", cid, user.id, maxSettledMessageId(cid)); - const rows = q("SELECT id FROM messages WHERE channel_id=? AND parent_id IS NULL ORDER BY id DESC LIMIT 100", cid).reverse(); + const rows = q("SELECT id FROM messages WHERE channel_id=? AND parent_id IS NULL AND photon_conversation_id IS NULL ORDER BY id DESC LIMIT 100", cid).reverse(); return json(res, 200, { messages: rows.map((r) => serializeMessage(Number(r.id))), bots: botsInChannel(cid).map(botView), agent: agentViewForChannel(cid) }); } if (m === "POST") { diff --git a/src/server/photon.ts b/src/server/photon.ts index b31b786..3c25796 100644 --- a/src/server/photon.ts +++ b/src/server/photon.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { DATA_DIR, now, q, q1, run, type Row } from "./db.ts"; import { createMessage, serializeMessage } from "./store.ts"; -import { broadcastToChannel } from "./events.ts"; +import { broadcastToChannel, sendToUsers } from "./events.ts"; import { agentForChannel, ensureThread, refreshThreadSummary } from "./agents.ts"; const CREDENTIAL_FILE = join(DATA_DIR, "photon-credentials.json"); @@ -33,6 +33,7 @@ const credentials = (): PhotonCredential | null => { const hidden = (value: string): string => value ? `${value.slice(0, 4)}…${value.slice(-2)}` : ""; export function photonStatus(): Record { const value = credentials(); + const main = photonMainChannel(); return { configured: Boolean(value?.project_id && value?.project_secret), connected: Boolean(child && child.exitCode == null && base), @@ -40,10 +41,16 @@ export function photonStatus(): Record { operator_phone: value?.operator_phone || "", assigned_phone: value?.assigned_phone || "", secret: value?.project_secret ? "stored" : "missing", - mappings: q("SELECT pcm.*,c.name channel_name,a.name agent_name FROM photon_channel_mappings pcm JOIN channels c ON c.id=pcm.channel_id LEFT JOIN agent_channels ac ON ac.channel_id=c.id LEFT JOIN agents a ON a.id=ac.agent_id ORDER BY pcm.created"), + thread_count: main ? Number(q1("SELECT COUNT(*) n FROM photon_conversations WHERE channel_id=?", main.id)?.n || 0) : 0, }; } +function photonMainChannel(): Row | undefined { + return q1(`SELECT c.*,u.id owner_user_id FROM channels c JOIN users u ON u.id=c.personal_main_owner_id + WHERE c.kind='channel' AND c.name='main' AND c.status='active' AND u.is_admin=1 + ORDER BY c.id LIMIT 1`); +} + export async function configurePhoton(input: { project_id: string; project_secret: string; operator_phone: string; assigned_phone?: string; dashboard_token?: string }): Promise> { const projectId = String(input.project_id || "").trim(); const projectSecret = String(input.project_secret || "").trim(); @@ -65,38 +72,9 @@ export async function configurePhoton(input: { project_id: string; project_secre await restartPhotonConnector().catch(() => undefined); throw error; } - if (!q1("SELECT 1 FROM photon_channel_mappings LIMIT 1")) { - const main = q1(`SELECT c.id FROM channels c JOIN users u ON u.id=c.personal_main_owner_id - WHERE c.kind='channel' AND c.name='main' AND c.status='active' AND u.is_admin=1 ORDER BY c.id LIMIT 1`); - if (main?.id) mapPhotonChannel(Number(main.id), [operatorPhone]); - } - return photonStatus(); -} - -export function mapPhotonChannel(channelId: number, allowedUsers?: string[]): Record { - const channel = q1("SELECT id,name,status FROM channels WHERE id=? AND kind='channel' AND status='active'", channelId); - const resident = agentForChannel(channelId); - if (!channel || !resident?.id || !["channel", "skipper"].includes(String(resident.kind))) throw new Error("Choose an active agent channel, including your #main Skipper channel."); - const configured = credentials(); - const users = (allowedUsers?.length ? allowedUsers : configured?.operator_phone ? [configured.operator_phone] : []) - .map((value) => String(value).trim()).filter((value) => E164.test(value)); - if (!users.length) throw new Error("At least one allowed E.164 sender is required."); - run(`INSERT INTO photon_channel_mappings (channel_id,allowed_users,created,updated) VALUES (?,?,?,?) - ON CONFLICT(channel_id) DO UPDATE SET allowed_users=excluded.allowed_users,updated=excluded.updated`, channelId, JSON.stringify([...new Set(users)]), now(), now()); - run(`INSERT INTO agent_capabilities (agent_id,capability,config,created) VALUES (?,'photon',?,?) - ON CONFLICT(agent_id,capability) DO UPDATE SET config=excluded.config`, resident.id, JSON.stringify({ can_read: true, can_draft: true, can_reply: true, can_send: false, allowed_users: users }), now()); return photonStatus(); } -export function grantPhotonToResident(channelId: number, canSend = false): string { - const resident = agentForChannel(channelId); - const mapping = q1("SELECT allowed_users FROM photon_channel_mappings WHERE channel_id=?", channelId); - if (!resident?.id || !mapping) return "Error: configure and map Photon to this channel in Settings first."; - run(`INSERT INTO agent_capabilities (agent_id,capability,config,created) VALUES (?,'photon',?,?) - ON CONFLICT(agent_id,capability) DO UPDATE SET config=excluded.config`, resident.id, JSON.stringify({ can_read: true, can_draft: true, can_reply: true, can_send: Boolean(canSend), allowed_users: JSON.parse(String(mapping.allowed_users || "[]")) }), now()); - return `Granted @${resident.name} Photon iMessage read, draft, and authorized-conversation reply access${canSend ? " plus new outbound sending" : " (new outbound conversations remain disabled)"}. Credentials remain host-scoped.`; -} - const spectrumHost = (): string => String(process.env.PHOTON_SPECTRUM_HOST || "https://spectrum.photon.codes").replace(/\/+$/, ""); async function validateSpectrumCredentials(projectId: string, projectSecret: string): Promise { const authorization = Buffer.from(`${projectId}:${projectSecret}`).toString("base64"); @@ -123,29 +101,62 @@ async function sidecar(path: string, init: RequestInit = {}): Promise { - const text = String(query || "").toLowerCase(); - const rows = q(`SELECT id,external_id,space_id,sender,body,received_at FROM photon_messages WHERE channel_id=? - AND (?='' OR lower(body) LIKE '%'||?||'%' OR lower(sender) LIKE '%'||?||'%') ORDER BY received_at DESC LIMIT ?`, channelId, text, text, text, Math.max(1, Math.min(50, limit))); - return { query, messages: rows }; +export function photonConversations(ownerUserId: number): Record[] { + const main = photonMainChannel(); + if (!main || Number(main.personal_main_owner_id) !== ownerUserId) return []; + return q(`SELECT pc.id,pc.sender,pc.space_id,pc.root_message_id,pc.thread_id,pc.active,pc.started,pc.updated,pc.closed, + t.title,t.summary,t.status, + (SELECT COALESCE((SELECT pm.body FROM photon_messages pm WHERE pm.message_id=m.id ORDER BY pm.id DESC LIMIT 1),m.body) + FROM messages m WHERE m.photon_conversation_id=pc.id AND trim(m.body)<>'' 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); } -export async function sendPhoton(channelId: number, spaceId: string, text: string): Promise { - const mapping = q1("SELECT 1 FROM photon_channel_mappings WHERE channel_id=?", channelId); - if (!mapping) throw new Error("Photon is not mapped to this channel."); - const destination = String(spaceId || "").trim(); - if (!destination || !String(text || "").trim()) throw new Error("Photon destination and message text are required."); - const resident = agentForChannel(channelId); - const capability = resident?.id ? q1("SELECT config FROM agent_capabilities WHERE agent_id=? AND capability='photon'", resident.id) : undefined; - let permissions: Record = {}; - try { permissions = JSON.parse(String(capability?.config || "{}")); } catch { /* fail closed below */ } - const replying = Boolean(q1("SELECT 1 FROM photon_messages WHERE channel_id=? AND direction='inbound' AND space_id=?", channelId, destination)); - if (!permissions.can_send && !(permissions.can_reply && replying)) { - throw new Error("Photon can reply only to an authorized conversation already delivered to this channel. Skipper must grant new outbound sending for any other destination."); +export function photonConversation(ownerUserId: number, conversationId: number): Record | null { + const main = photonMainChannel(); + if (!main || Number(main.personal_main_owner_id) !== ownerUserId) return null; + const conversation = q1(`SELECT pc.*,t.title,t.summary,t.status FROM photon_conversations pc + JOIN threads t ON t.id=pc.thread_id WHERE pc.id=? AND pc.channel_id=?`, conversationId, main.id); + if (!conversation) return null; + const messages = q("SELECT id FROM messages WHERE photon_conversation_id=? ORDER BY id", conversationId).map((row) => { + const message = serializeMessage(Number(row.id)); + if (!message || String(message.body) === "_Working…_") return null; + const transport = q1("SELECT direction,body FROM photon_messages WHERE message_id=? ORDER BY id DESC LIMIT 1", row.id); + return { ...message, body: transport?.body ? String(transport.body) : message.body, transport: transport?.direction ? String(transport.direction) : "app" }; + }).filter(Boolean); + return { ...conversation, messages }; +} + +/** Continue any saved Texts thread from the desktop. It uses the same Skipper + * context but deliberately does not mirror the exchange back to the phone. */ +export async function continuePhotonConversation(ownerUserId: number, conversationId: number, text: string): Promise> { + const main = photonMainChannel(); + if (!main || Number(main.personal_main_owner_id) !== ownerUserId) throw new Error("Texts are available only in your private #main."); + const conversation = q1("SELECT * FROM photon_conversations WHERE id=? AND channel_id=?", conversationId, main.id); + if (!conversation) throw new Error("Text thread not found."); + const body = String(text || "").trim(); + if (!body) throw new Error("Write a message to Skipper."); + if (body.length > 50_000) throw new Error("Messages are limited to 50,000 characters."); + const timestamp = now(); + // Sending from a saved conversation resumes that exact context everywhere. + // Any previously current thread is retained in history but stops receiving + // phone messages, so the next phone turn continues the desktop selection. + run("UPDATE photon_conversations SET active=0,closed=COALESCE(closed,?) WHERE sender=? AND active=1 AND id<>?", timestamp, conversation.sender, conversationId); + run("UPDATE photon_conversations SET active=1,closed=NULL,updated=? WHERE id=?", timestamp, conversationId); + const messageId = createMessage({ channelId: Number(main.id), parentId: Number(conversation.root_message_id), userId: ownerUserId, body }); + run("UPDATE messages SET photon_conversation_id=? WHERE id=?", conversationId, messageId); + run("UPDATE threads SET status='open',updated_at=? WHERE id=?", timestamp, conversation.thread_id); + refreshThreadSummary(Number(conversation.root_message_id)); + sendToUsers([ownerUserId], { type: "photon_update", conversationId, message: serializeMessage(messageId) }); + const skipper = agentForChannel(Number(main.id)); + const bot = skipper?.bot_id ? q1("SELECT * FROM bots WHERE id=?", skipper.bot_id) : undefined; + if (bot && dispatchInbound) { + void dispatchInbound(bot, Number(main.id), messageId, Number(conversation.root_message_id)) + .then(() => sendToUsers([ownerUserId], { type: "photon_update", conversationId })) + .catch((error) => sendToUsers([ownerUserId], { type: "photon_update", conversationId, error: (error as Error).message })); } - const result = await sidecar("/send", { method: "POST", body: JSON.stringify({ space_id: destination, text: String(text).slice(0, 50_000) }) }); - run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at) VALUES (?,?,?,?, 'outbound',?,?)", channelId, String(result.message_id || ""), destination, "1Helm", String(text).slice(0, 50_000), now()); - return { status: "sent", destination, message_id: result.message_id || "" }; + return photonConversation(ownerUserId, conversationId)!; } function recoverInterruptedPhotonDeliveries(): void { @@ -163,16 +174,16 @@ function queuePhotonDelivery(channelId: number, destination: string, body: strin VALUES ('photon',?,?,?,?,?,'pending',?,?)`, key, channelId, destination, body.slice(0, 50_000), sourceMessageId, now(), now()); } -function activePhotonConversation(channelId: number, sender: string): Row | undefined { +function activePhotonConversation(sender: string): Row | undefined { return q1(`SELECT pc.* FROM photon_conversations pc JOIN messages root ON root.id=pc.root_message_id AND root.channel_id=pc.channel_id AND root.parent_id IS NULL JOIN threads t ON t.id=pc.thread_id AND t.root_message_id=pc.root_message_id AND t.channel_id=pc.channel_id - WHERE pc.channel_id=? AND pc.sender=? AND pc.active=1 - ORDER BY pc.updated DESC,pc.id DESC LIMIT 1`, channelId, sender); + WHERE pc.sender=? AND pc.active=1 + ORDER BY pc.updated DESC,pc.id DESC LIMIT 1`, sender); } function closePhotonConversation(channelId: number, sender: string, spaceId: string, event: PhotonEvent): number | null { - const conversation = activePhotonConversation(channelId, sender); + const conversation = activePhotonConversation(sender); const timestamp = Date.parse(event.timestamp) || now(); run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at,message_id) VALUES (?,?,?,?, 'inbound',?,?,NULL)", channelId, event.id, spaceId, sender, event.text.slice(0, 50_000), timestamp); @@ -181,9 +192,11 @@ function closePhotonConversation(channelId: number, sender: string, spaceId: str run("UPDATE threads SET status='resolved',updated_at=? WHERE id=?", timestamp, conversation.thread_id); const noteId = createMessage({ channelId, parentId: Number(conversation.root_message_id), botId: null, body: "Photon conversation closed with /new. The next text starts a new thread." }); run("UPDATE messages SET system_message=1 WHERE id=?", noteId); + run("UPDATE messages SET photon_conversation_id=? WHERE id=?", conversation.id, noteId); run("INSERT INTO channel_activity (channel_id,thread_id,kind,summary,actor_type,created) VALUES (?,?,'connector',?,'system',?)", channelId, conversation.thread_id, `Photon closed the active conversation for ${sender}; the next text will start a new thread.`, timestamp); broadcastToChannel(channelId, { type: "message", message: serializeMessage(noteId), parent: serializeMessage(Number(conversation.root_message_id)) }); + sendToUsers([Number(q1("SELECT personal_main_owner_id owner FROM channels WHERE id=?", channelId)?.owner || 0)], { type: "photon_update", conversationId: conversation.id }); refreshThreadSummary(Number(conversation.root_message_id)); return noteId; } @@ -215,14 +228,13 @@ export async function drainPhotonDeliveries(): Promise { export async function deliverPhotonEvent(event: PhotonEvent): Promise { if (!event.id || !event.space_id || !event.text) return false; if (q1("SELECT 1 FROM photon_messages WHERE external_id=?", event.id)) return false; - const mappings = q("SELECT * FROM photon_channel_mappings ORDER BY created"); - const mapping = mappings.find((row) => { - try { return (JSON.parse(String(row.allowed_users || "[]")) as string[]).includes(event.sender); } catch { return false; } - }); - if (!mapping) return false; - const channelId = Number(mapping.channel_id); + const main = photonMainChannel(); + if (!main) return false; + const configured = credentials(); + if (!configured?.operator_phone || event.sender !== configured.operator_phone) return false; + const channelId = Number(main.id); const resident = agentForChannel(channelId); - if (!resident?.bot_id) return false; + if (!resident?.bot_id || resident.kind !== "skipper") return false; const timestamp = Date.parse(event.timestamp) || now(); if (event.text.trim().toLowerCase() === "/new") { const noteId = closePhotonConversation(channelId, event.sender, event.space_id, event); @@ -232,7 +244,7 @@ export async function deliverPhotonEvent(event: PhotonEvent): Promise { await drainPhotonDeliveries(); return true; } - let conversation = activePhotonConversation(channelId, event.sender); + let conversation = activePhotonConversation(event.sender); let rootMessageId = Number(conversation?.root_message_id || 0); const body = `[Photon iMessage from ${event.sender}]\n${event.text}`; const messageId = createMessage({ channelId, parentId: rootMessageId || null, botId: null, body }); @@ -244,14 +256,17 @@ export async function deliverPhotonEvent(event: PhotonEvent): Promise { (channel_id,sender,space_id,root_message_id,thread_id,active,started,updated) VALUES (?,?,?,?,?,1,?,?)`, channelId, event.sender, event.space_id, rootMessageId, threadId, timestamp, timestamp).lastInsertRowid; conversation = q1("SELECT * FROM photon_conversations WHERE id=?", conversationId); + run("UPDATE messages SET photon_conversation_id=? WHERE id=?", conversationId, messageId); } else { run("UPDATE photon_conversations SET space_id=?,updated=? WHERE id=? AND active=1", event.space_id, timestamp, conversation!.id); + run("UPDATE messages SET photon_conversation_id=? WHERE id=?", conversation!.id, messageId); } const threadId = Number(conversation!.thread_id); run("UPDATE threads SET status='open',updated_at=? WHERE id=?", timestamp, threadId); run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at,message_id) VALUES (?,?,?,?, 'inbound',?,?,?)", channelId, event.id, event.space_id, event.sender, event.text.slice(0, 50_000), Date.parse(event.timestamp) || now(), messageId); run("INSERT INTO channel_activity (channel_id,thread_id,kind,summary,actor_type,created) VALUES (?,?,'connector',?,'system',?)", channelId, threadId, `Photon delivered an iMessage from ${event.sender}.`, now()); broadcastToChannel(channelId, { type: "message", message: serializeMessage(messageId), parent: rootMessageId === messageId ? null : serializeMessage(rootMessageId) }); + sendToUsers([Number(main.owner_user_id)], { type: "photon_update", conversationId: conversation!.id }); refreshThreadSummary(rootMessageId); const bot = q1("SELECT * FROM bots WHERE id=?", resident.bot_id); if (bot && dispatchInbound) { diff --git a/src/server/store.ts b/src/server/store.ts index 5160004..9444da6 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -13,6 +13,10 @@ export function createMessage(m: Msg): number { "INSERT INTO messages (channel_id, parent_id, user_id, bot_id, body, created) VALUES (?,?,?,?,?,?)", m.channelId, m.parentId, m.userId ?? null, m.botId ?? null, m.body, now(), ).lastInsertRowid; + if (m.parentId) { + const conversationId = q1("SELECT photon_conversation_id FROM messages WHERE id=?", m.parentId)?.photon_conversation_id; + if (conversationId) run("UPDATE messages SET photon_conversation_id=? WHERE id=?", conversationId, id); + } // Internal wake triggers still need a parent_id link for ordering, but must not // inflate the user-visible reply count. if (m.parentId && !isInternalMessageBody(m.body)) { diff --git a/test/brief-regressions-browser.mjs b/test/brief-regressions-browser.mjs index b0bdb9c..f739b6b 100644 --- a/test/brief-regressions-browser.mjs +++ b/test/brief-regressions-browser.mjs @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { createServer } from "node:net"; -import { rmSync } from "node:fs"; +import { rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import puppeteer from "puppeteer"; @@ -37,7 +37,7 @@ try { mock = spawn(process.execPath, ["test/mock-openai.mjs", String(mockPort)], { cwd: root, stdio: "ignore" }); await waitFor(async () => (await fetch(`http://127.0.0.1:${mockPort}/v1/models`).catch(() => null))?.ok, "mock provider"); app = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "src/server/index.ts"], { - cwd: root, env: { ...process.env, CTRL_DATA_DIR: dataDir, PORT: String(appPort), IMPROVEMENT_INTERVAL_MS: "600000", HELM_CHANNEL_COMPUTER_BACKEND: "native" }, stdio: "ignore", + cwd: root, env: { ...process.env, NODE_ENV: "test", CTRL_DATA_DIR: dataDir, PORT: String(appPort), IMPROVEMENT_INTERVAL_MS: "600000", HELM_CHANNEL_COMPUTER_BACKEND: "native" }, stdio: "ignore", }); await waitFor(async () => (await fetch(`${base}/api/setup/status`).catch(() => null))?.ok, "app"); const registration = await api("/api/auth/register", { body: { username: "captain", password: "secret-pass", display: "Captain" } }); @@ -45,6 +45,8 @@ try { const provider = await api("/api/providers", { body: { name: "Mock", base_url: `http://127.0.0.1:${mockPort}/v1`, api_key: "test" } }, token); await api("/api/setup/complete", { body: { name: "Browser Brief", terminals_enabled: true, provider_id: provider.provider.id, model: "mock-large" } }, token); const channel = (await api("/api/channels", { body: { name: "Visual", purpose: "Exercise the brief regressions." } }, token)).channel; + const extensionlessNote = await api(`/api/channels/${channel.id}/notes`, { body: { name: "Quiet refresh proof", content: "# Durable note\n\nStart here." } }, token); + ok(extensionlessNote.note.name === "Quiet refresh proof.md", "extensionless note titles are normalized to .md by the API"); const securityResponse = await fetch(base); ok(/(?:^|;)\s*frame-src 'self' blob:(?:;|$)/.test(securityResponse.headers.get("content-security-policy") || ""), "the web control plane permits only same-origin and blob frames for safe PDF preview"); ok(/(?:^|;)\s*media-src 'self' blob:(?:;|$)/.test(securityResponse.headers.get("content-security-policy") || ""), "the web control plane permits only same-origin and blob media for safe audio/video preview"); @@ -80,6 +82,13 @@ try { const audioUpload = await audioUploadResponse.json(); const audioMessage = (await api(`/api/channels/${channel.id}/messages`, { body: { body: "Safe audio preview fixture", uploads: [audioUpload] } }, token)).message; + // Photon is already configured by this point in a real workspace. Seed only + // the host-owned credential marker and one private conversation so browser + // acceptance can exercise the Texts UI without an external Photon account. + writeFileSync(join(dataDir, "photon-credentials.json"), JSON.stringify({ project_id: "browser-fixture", project_secret: "stored-outside-renderer", operator_phone: "+15551234567", assigned_phone: "+15557654321", configured_at: Date.now() }), { mode: 0o600 }); + await api("/api/testing/photon", { body: { event: { id: "browser-phone-1", space_id: "browser-space", space_type: "dm", sender: "+15551234567", text: "Phone hello", timestamp: new Date().toISOString() } } }, token); + const textConversation = (await api("/api/texts", {}, token)).conversations[0].id; + browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); @@ -110,6 +119,55 @@ 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.evaluate(() => { + const editor = document.querySelector('[aria-label="Note content"]'); + editor.value += "\n\nUnsaved words survive."; + editor.focus(); editor.setSelectionRange(4, 11); editor.dispatchEvent(new Event("input")); + window.__briefNoteEditor = editor; + }); + const themeBeforeNotesRefresh = await page.evaluate(() => document.documentElement.className); + 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 }; + }); + 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 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 }); + await page.goto(`${base}/c/${channel.slug}/chat`, { waitUntil: "networkidle0" }); + const compactHeader = await page.$eval("#hdr", (header) => { + const purpose = [...header.querySelectorAll('[title]')].find((element) => element.title.startsWith("A deliberately long channel description")); + const style = purpose ? getComputedStyle(purpose) : null; + return { headerHeight: header.getBoundingClientRect().height, purposeWidth: purpose?.getBoundingClientRect().width || 0, purposeHeight: purpose?.getBoundingClientRect().height || 0, lineHeight: style ? parseFloat(style.lineHeight) : 0, overflow: style?.overflow, whitespace: style?.whiteSpace, textOverflow: style?.textOverflow }; + }); + ok(compactHeader.headerHeight < 72 && (compactHeader.purposeWidth === 0 || (compactHeader.purposeHeight <= compactHeader.lineHeight + 1 && compactHeader.overflow === "hidden" && compactHeader.whitespace === "nowrap" && compactHeader.textOverflow === "ellipsis")), + `narrow desktop headers keep long channel descriptions to one truncated line instead of one letter per row (${JSON.stringify(compactHeader)})`); + + await page.goto(`${base}/c/main/texts`, { waitUntil: "networkidle0" }); + await page.waitForSelector(`[data-texts-messages="${textConversation}"]`); + ok(await page.$eval("body", (body) => body.innerText.includes("Phone hello") && body.innerText.includes("Phone") && body.innerText.includes("Texts")), "configured Photon exposes a private #main Texts inbox with phone-originated history"); + const outboundBeforeDesktop = (await api("/api/testing/photon", { body: {} }, token)).outbound; + await page.type(`[data-texts-composer="${textConversation}"]`, "Continue from desktop"); + await page.click(`[data-texts-composer="${textConversation}"] + button`); + await page.waitForFunction(() => document.body.innerText.includes("Continue from desktop")); + 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.setViewport({ width: 1440, height: 900 }); + await page.goto(`${base}/c/${channel.slug}/thread/${rootMessage.id}`, { waitUntil: "networkidle0" }); + await page.waitForSelector("#thread:not(.hidden)"); const initialWidth = await page.$eval("#thread", (element) => element.getBoundingClientRect().width); ok(initialWidth >= 500, "thread panel opens at the wider default size"); const handle = await page.$(".thread-resizer"); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 44b9572..b233c19 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.10"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.11"); 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 665deec..93a55e3 100644 --- a/test/channel-surfaces.mjs +++ b/test/channel-surfaces.mjs @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import test, { after } from "node:test"; const root = resolve(import.meta.dirname, ".."); +const agentsSource = readFileSync(join(root, "src", "server", "agents.ts"), "utf8"); const dataDir = mkdtempSync(join(tmpdir(), "1helm-channel-surfaces-")); process.env.CTRL_DATA_DIR = dataDir; process.env.NODE_ENV = "test"; @@ -28,10 +29,12 @@ after(() => { rmSync(dataDir, { recursive: true, force: true }); }); -test("note filenames are plain, bounded Markdown filenames", () => { +test("note filenames accept titles with or without .md and reject other extensions", () => { assert.equal(agents.validateNoteFilename("Launch Plan.md"), "Launch Plan.md"); - for (const unsafe of ["", ".md", "plan.txt", "../plan.md", "notes/plan.md", " plan.md", "plan.md ", "plan\\one.md", "bad\0.md", `${"a".repeat(158)}.md`]) { - assert.throws(() => agents.validateNoteFilename(unsafe), /plain \.md filename/); + assert.equal(agents.validateNoteFilename("Launch Plan"), "Launch Plan.md"); + assert.throws(() => agents.validateNoteFilename("plan.txt"), /Notes use \.md files.*plan\.md/); + for (const unsafe of ["", ".md", "../plan.md", "notes/plan.md", " plan.md", "plan.md ", "plan\\one.md", "bad\0.md", `${"a".repeat(158)}.md`]) { + assert.throws(() => agents.validateNoteFilename(unsafe), /plain Markdown filename/); } }); @@ -57,6 +60,10 @@ test("notes create, list, open, save, and rename in /workspace/notes with mirror ]); assert.throws(() => agents.createChannelNote(901, "Release.md", "duplicate"), /already exists/); assert.throws(() => agents.saveChannelNote(901, "Release.md", "x".repeat(1024 * 1024 + 1)), /limited to 1 MB/); + + const extensionless = agents.createChannelNote(901, "Meeting notes", "# Meeting\n"); + assert.equal(extensionless.name, "Meeting notes.md"); + assert.equal(readFileSync(join(dataDir, "channels", "901", "workspace", "notes", "Meeting notes.md"), "utf8"), "# Meeting\n"); }); test("notes reject a symlinked notes directory", () => { @@ -137,6 +144,9 @@ test("channel UI source exposes route-shaped Notes, folder navigation, audio pre const apiSource = readFileSync(join(root, "src", "client", "api.ts"), "utf8"); const appSource = readFileSync(join(root, "src", "client", "app.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"); + assert.match(channelSource, /role: "toolbar", "aria-label": "Note formatting"/, "Notes exposes formatting controls"); assert.match(channelSource, /\/api\/channels\/\$\{channelId\}\/notes/); assert.match(channelSource, /\/files\?path=\$\{encodeURIComponent\(requestedPath\)\}/); assert.match(channelSource, /\/files\/directories/); @@ -148,4 +158,10 @@ test("channel UI source exposes route-shaped Notes, folder navigation, audio pre 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.doesNotMatch(appSource, /controllerchange[\s\S]{0,300}location\.reload\(/, "service-worker updates never reload an active note draft"); +}); + +test("new residents choose an unused character and color combination while options remain", () => { + assert.match(agentsSource, /const used = new Set\(q\(`[\s\S]*a\.kind='channel'[\s\S]*a\.status<>'deleted'/, "active resident avatars define the used set"); + assert.match(agentsSource, /if \(!used\.has\(candidate\)\) return candidate;/, "provisioning skips an already-used avatar combination"); }); diff --git a/test/photon.mjs b/test/photon.mjs index 38f48dd..1debe85 100644 --- a/test/photon.mjs +++ b/test/photon.mjs @@ -41,13 +41,24 @@ const photon = await import("../src/server/photon.ts"); const photonAuth = await import("../src/server/photon-auth.ts"); const { createPhotonInboundQueue } = await import("../src/server/photon-queue.mjs"); -function residentFixture() { +function skipperFixture() { seed(); - const channelId = run("INSERT INTO channels (name,slug,kind,topic,purpose,status,created) VALUES ('messages','messages','channel','','','active',?)", now()).lastInsertRowid; - const botId = run("INSERT INTO bots (name,model,created) VALUES ('messages-agent','mock',?)", now()).lastInsertRowid; - const agentId = run("INSERT INTO agents (bot_id,kind,name,display_name,status,created) VALUES (?,'channel','messages-agent','Messages Agent','ready',?)", botId, now()).lastInsertRowid; - run("INSERT INTO agent_channels (agent_id,channel_id,bound_at) VALUES (?,?,?)", agentId, channelId, now()); - return { channelId, agentId }; + let owner = q1("SELECT * FROM users WHERE username='captain'"); + if (!owner) { + const ownerId = run("INSERT INTO users (username,display,pass,is_admin,created) VALUES ('captain','Captain','x',1,?)", now()).lastInsertRowid; + owner = q1("SELECT * FROM users WHERE id=?", ownerId); + } + const channel = q1("SELECT * FROM channels WHERE name='main'"); + run("UPDATE channels SET personal_main_owner_id=?,created_by=? WHERE id=?", owner.id, owner.id, channel.id); + run("INSERT OR IGNORE INTO members (channel_id,user_id) VALUES (?,?)", channel.id, owner.id); + let agent = q1("SELECT * FROM agents WHERE kind='skipper'"); + if (!agent) { + const botId = run("INSERT INTO bots (name,model,created) VALUES ('skipper','mock',?)", now()).lastInsertRowid; + const agentId = run("INSERT INTO agents (bot_id,kind,name,display_name,status,created) VALUES (?,'skipper','skipper','Skipper','ready',?)", botId, now()).lastInsertRowid; + agent = q1("SELECT * FROM agents WHERE id=?", agentId); + } + photon.photonStatus(); + return { channelId: Number(channel.id), agentId: Number(agent.id), ownerId: Number(owner.id) }; } test("Photon setup validates before saving and redacts credentials from status", async () => { @@ -61,20 +72,19 @@ test("Photon setup validates before saving and redacts credentials from status", assert.equal(spectrumRequests >= 2, true); }); -test("Photon mappings grant narrow reply but deny new outbound destinations", async () => { - const { channelId, agentId } = residentFixture(); - photon.mapPhotonChannel(channelId, ["+15551234567"]); - const config = JSON.parse(String(q1("SELECT config FROM agent_capabilities WHERE agent_id=? AND capability='photon'", agentId).config)); - assert.equal(config.can_reply, true); - assert.equal(config.can_send, false); - await assert.rejects(() => photon.sendPhoton(channelId, "+15550000000", "new conversation"), /new outbound sending/i); - run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at) VALUES (?,?,?,?, 'inbound',?,?)", channelId, "incoming-1", "space-1", "+15551234567", "hello", now()); - const sent = await photon.sendPhoton(channelId, "space-1", "authorized reply"); - assert.equal(sent.status, "sent"); +test("Photon is bound only to private #main Skipper and exposes no channel mappings", () => { + const { channelId } = skipperFixture(); + const coworkerId = run("INSERT INTO users (username,display,pass,is_admin,created) VALUES ('photon-coworker','Coworker','x',0,?)", now()).lastInsertRowid; + const coworkerMain = run("INSERT INTO channels (name,slug,kind,status,personal_main_owner_id,created_by,created) VALUES ('main','coworker-main','channel','active',?,?,?)", coworkerId, coworkerId, now()).lastInsertRowid; + migrate(); + assert.equal(q1("SELECT COUNT(*) n FROM photon_channel_mappings WHERE channel_id=?", channelId).n, 0); + assert.equal(q1("SELECT COUNT(*) n FROM photon_channel_mappings WHERE channel_id=?", coworkerMain).n, 0, "a coworker's private #main can never become the Captain's Texts inbox"); + assert.equal(photon.photonStatus().mappings, undefined); }); -test("Photon inbound delivery is allowlisted, deduplicated, and invokes the mapped resident", async () => { - const channel = q1("SELECT id FROM channels WHERE name='messages'"); +test("Photon inbound delivery accepts only the Captain and invokes Skipper", async () => { + skipperFixture(); + const channel = q1("SELECT id FROM channels WHERE name='main'"); const dispatched = []; photon.registerPhotonDispatcher((bot, channelId, triggerId, rootId) => dispatched.push({ bot: bot.name, channelId, triggerId, rootId })); assert.equal(await photon.deliverPhotonEvent({ id: "blocked-1", space_id: "space-blocked", space_type: "dm", sender: "+15550000000", text: "not allowed", timestamp: new Date().toISOString() }), false); @@ -88,8 +98,9 @@ test("Photon inbound delivery is allowlisted, deduplicated, and invokes the mapp assert.equal(dispatched[0].channelId, channel.id); }); -test("Photon keeps one durable sender thread until exact /new", async () => { - const channel = q1("SELECT id FROM channels WHERE name='messages'"); +test("Photon keeps one durable Skipper text thread until exact /new", async () => { + skipperFixture(); + const channel = q1("SELECT id FROM channels WHERE name='main'"); const dispatched = []; photon.registerPhotonDispatcher((_bot, channelId, triggerId, rootId) => dispatched.push({ channelId, triggerId, rootId })); const sender = "+15551234567"; @@ -121,35 +132,46 @@ test("Photon keeps one durable sender thread until exact /new", async () => { }); test("Photon accepts /new when no conversation has ever been opened", async () => { - const sender = "+15559876543"; - const channel = q1("SELECT id FROM channels WHERE name='messages'"); - photon.mapPhotonChannel(channel.id, ["+15551234567", sender]); + skipperFixture(); + const sender = "+15551234567"; + const channel = q1("SELECT id FROM channels WHERE name='main'"); + await photon.deliverPhotonEvent({ id: "close-before-first-ever", space_id: "space-close", space_type: "dm", sender, text: "/new", timestamp: new Date().toISOString() }); let dispatched = false; photon.registerPhotonDispatcher(() => { dispatched = true; }); assert.equal(await photon.deliverPhotonEvent({ id: "first-ever-new", space_id: "space-first-new", space_type: "dm", sender, text: "/NEW", timestamp: new Date().toISOString() }), true); assert.equal(dispatched, false); - assert.equal(q1("SELECT COUNT(*) n FROM photon_conversations WHERE channel_id=? AND sender=?", channel.id, sender).n, 0); + assert.equal(q1("SELECT COUNT(*) n FROM photon_conversations WHERE channel_id=? AND sender=? AND active=1", channel.id, sender).n, 0); assert.equal(q1("SELECT source_message_id FROM connector_deliveries WHERE idempotency_key='photon:event:first-ever-new:new'").source_message_id, null); }); -test("Photon upgrades the most recent existing sender thread without splitting it", () => { +test("Photon migrates an existing mapped-channel thread into Captain #main without splitting it", () => { const sender = "+15551112222"; - const channel = q1("SELECT id FROM channels WHERE name='messages'"); + const main = q1(`SELECT c.id FROM channels c JOIN users u ON u.id=c.personal_main_owner_id + WHERE c.name='main' AND u.is_admin=1`); + const channelId = run("INSERT INTO channels (name,slug,kind,status,created) VALUES ('legacy-photon','legacy-photon','channel','active',?)", now()).lastInsertRowid; const stamp = now(); - const rootId = run("INSERT INTO messages (channel_id,parent_id,body,system_message,created) VALUES (?,NULL,?,1,?)", channel.id, `[Photon iMessage from ${sender}]\nexisting conversation`, stamp).lastInsertRowid; - const threadId = run("INSERT INTO threads (root_message_id,channel_id,status,title,summary,opened_at,updated_at) VALUES (?,?,'open','Existing Photon conversation','',?,?)", rootId, channel.id, stamp, stamp).lastInsertRowid; - run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at,message_id) VALUES (?,?,?,?, 'inbound',?,?,?)", channel.id, "pre-upgrade", "space-pre-upgrade", sender, "existing conversation", stamp, rootId); + const rootId = run("INSERT INTO messages (channel_id,parent_id,body,system_message,created) VALUES (?,NULL,?,1,?)", channelId, `[Photon iMessage from ${sender}]\nexisting conversation`, stamp).lastInsertRowid; + const replyId = run("INSERT INTO messages (channel_id,parent_id,body,system_message,created) VALUES (?,?,?,1,?)", channelId, rootId, "existing reply", stamp + 1).lastInsertRowid; + const threadId = run("INSERT INTO threads (root_message_id,channel_id,status,title,summary,opened_at,updated_at) VALUES (?,?,'open','Existing Photon conversation','',?,?)", rootId, channelId, stamp, stamp).lastInsertRowid; + run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at,message_id) VALUES (?,?,?,?, 'inbound',?,?,?)", channelId, "pre-upgrade", "space-pre-upgrade", sender, "existing conversation", stamp, rootId); + run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at,message_id) VALUES (?,?,?,?, 'outbound',?,?,?)", channelId, "pre-upgrade-reply", "space-pre-upgrade", "1Helm", "existing reply", stamp + 1, replyId); + run("INSERT INTO photon_channel_mappings (channel_id,allowed_users,created,updated) VALUES (?,?,?,?)", channelId, JSON.stringify([sender]), stamp, stamp); migrate(); - const conversation = q1("SELECT * FROM photon_conversations WHERE channel_id=? AND sender=? AND active=1", channel.id, sender); + const conversation = q1("SELECT * FROM photon_conversations WHERE channel_id=? AND sender=? AND active=1", main.id, sender); assert.equal(conversation.root_message_id, rootId); assert.equal(conversation.thread_id, threadId); + assert.equal(q1("SELECT channel_id,photon_conversation_id FROM messages WHERE id=?", rootId).channel_id, main.id); + assert.equal(q1("SELECT channel_id,photon_conversation_id FROM messages WHERE id=?", replyId).photon_conversation_id, conversation.id); + assert.equal(q1("SELECT channel_id FROM threads WHERE id=?", threadId).channel_id, main.id); + assert.equal(q1("SELECT COUNT(*) n FROM photon_messages WHERE message_id IN (?,?) AND channel_id=?", rootId, replyId, main.id).n, 2); + assert.equal(q1("SELECT COUNT(*) n FROM photon_channel_mappings").n, 0, "legacy mapping rows are deleted after migration"); }); test("Photon returns the completed 1Helm reply exactly once to the inbound conversation", async () => { - const channel = q1("SELECT id FROM channels WHERE name='messages'"); - const bot = q1("SELECT b.id FROM bots b JOIN agents a ON a.bot_id=b.id JOIN agent_channels ac ON ac.agent_id=a.id WHERE ac.channel_id=?", channel.id); + const channel = q1("SELECT id FROM channels WHERE name='main'"); + const bot = q1("SELECT b.id FROM bots b JOIN agents a ON a.bot_id=b.id WHERE a.kind='skipper'"); photon.registerPhotonDispatcher(async (_bot, channelId, _triggerId, rootId) => { run("INSERT INTO messages (channel_id,parent_id,bot_id,body,created) VALUES (?,?,?,?,?)", channelId, rootId, bot.id, "Automatic Photon answer", now()); }); @@ -159,17 +181,29 @@ test("Photon returns the completed 1Helm reply exactly once to the inbound conve assert.equal(q1("SELECT COUNT(*) n FROM messages WHERE channel_id=? AND parent_id=(SELECT root_message_id FROM photon_conversations WHERE channel_id=? AND sender=? AND active=1) AND bot_id=?", channel.id, channel.id, "+15551234567", bot.id).n, 1, "the agent answer is retained in its 1Helm thread"); assert.equal(q1("SELECT COUNT(*) n FROM photon_messages WHERE channel_id=? AND space_id='space-reply' AND direction='outbound' AND body='Automatic Photon answer'", channel.id).n, 1, "the completed answer is sent back once"); + const active = q1("SELECT id FROM photon_conversations WHERE channel_id=? AND sender=? AND active=1", channel.id, "+15551234567"); + const before = q1("SELECT COUNT(*) n FROM photon_messages WHERE channel_id=? AND direction='outbound'", channel.id).n; photon.registerPhotonDispatcher(async (_bot, channelId, _triggerId, rootId) => { - run("INSERT INTO messages (channel_id,parent_id,bot_id,body,created) VALUES (?,?,?,?,?)", channelId, rootId, bot.id, "Tool-sent Photon answer", now()); - await photon.sendPhoton(channelId, "space-tool-reply", "Tool-sent Photon answer"); + const id = run("INSERT INTO messages (channel_id,parent_id,bot_id,body,created) VALUES (?,?,?,?,?)", channelId, rootId, bot.id, "Desktop-only answer", now()).lastInsertRowid; + run("UPDATE messages SET photon_conversation_id=? WHERE id=?", active.id, id); }); - const toolEvent = { id: "reply-2", space_id: "space-tool-reply", space_type: "dm", sender: "+15551234567", text: "reply with your tool", timestamp: new Date().toISOString() }; - assert.equal(await photon.deliverPhotonEvent(toolEvent), true); - assert.equal(q1("SELECT COUNT(*) n FROM photon_messages WHERE channel_id=? AND space_id='space-tool-reply' AND direction='outbound'", channel.id).n, 1, "an explicit photon_send suppresses the automatic duplicate"); + await photon.continuePhotonConversation(skipperFixture().ownerId, Number(active.id), "continue on desktop"); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(q1("SELECT COUNT(*) n FROM photon_messages WHERE channel_id=? AND direction='outbound'", channel.id).n, before, "desktop continuation does not mirror messages to the phone"); + assert.match(JSON.stringify(photon.photonConversation(skipperFixture().ownerId, Number(active.id))), /continue on desktop.*Desktop-only answer/); + + await photon.deliverPhotonEvent({ id: "resume-new-reset", space_id: "space-resume", space_type: "dm", sender: "+15551234567", text: "/new", timestamp: new Date().toISOString() }); + await photon.deliverPhotonEvent({ id: "resume-new-thread", space_id: "space-resume", space_type: "dm", sender: "+15551234567", text: "newer context", timestamp: new Date().toISOString() }); + await photon.continuePhotonConversation(skipperFixture().ownerId, Number(active.id), "resume the earlier context"); + const resumed = q("SELECT id,sender,active,closed FROM photon_conversations WHERE sender=? ORDER BY id", "+15551234567"); + assert.equal(resumed.find((conversation) => conversation.active)?.id, active.id, `continuing a saved desktop thread makes it the phone's current context: ${JSON.stringify(resumed)}`); + await photon.deliverPhotonEvent({ id: "phone-after-desktop-resume", space_id: "space-resume", space_type: "dm", sender: "+15551234567", text: "back on phone", timestamp: new Date().toISOString() }); + assert.equal(q1("SELECT parent_id FROM messages WHERE id=(SELECT message_id FROM photon_messages WHERE external_id='phone-after-desktop-resume')").parent_id, + q1("SELECT root_message_id FROM photon_conversations WHERE id=?", active.id).root_message_id, "returning to the phone continues the desktop-resumed thread"); }); test("Photon drains persisted replies and never blindly replays a crash-interrupted attempt", async () => { - const channel = q1("SELECT id FROM channels WHERE name='messages'"); + const channel = q1("SELECT id FROM channels WHERE name='main'"); const queued = run(`INSERT INTO connector_deliveries (connector,idempotency_key,channel_id,destination,body,state,created,updated) VALUES ('photon','test-pending',?,'space-durable','Durable reply','pending',?,?)`, channel.id, now(), now()).lastInsertRowid; diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs index 537cbe6..ffa4143 100644 --- a/test/workspace-interactions.mjs +++ b/test/workspace-interactions.mjs @@ -47,3 +47,14 @@ test("profile, naming, routing, and usage language match the visible product con assert.match(app, /Cumulative provider-reported usage for this thread/, "thread token totals are labeled as actual cumulative usage"); assert.doesNotMatch(app, /`Ctx /, "usage is not presented as context-window capacity"); }); + +test("service-worker updates never reload an active editor or conversation", () => { + assert.doesNotMatch(app, /controllerchange[\s\S]{0,300}location\.reload\(/, "service-worker takeover must not reload a live draft"); + assert.match(app, /destroy an editor draft/, "the no-reload update contract is documented at the decision point"); +}); + +test("Texts routes fail closed outside the Captain's private #main", () => { + assert.match(app, /function textsAvailable\(channel\?: Channel\): boolean \{[\s\S]*S\.me\.is_admin[\s\S]*S\.photonConfigured[\s\S]*channel\.name === "main"[\s\S]*channel\.personal_main/, "Texts eligibility is centralized on the Captain's configured private #main"); + assert.match(app, /if \(view === "texts" && !textsAvailable\(requestedChannel\)\) view = "chat";/, "direct Texts URLs fall back to chat outside private #main"); + assert.match(app, /if \(view === "texts" && !textsAvailable\(S\.channels\.find/, "programmatic Texts navigation uses the same guard"); +});