diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8705efe1..dad2b994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,6 @@ jobs: - name: Check GSV package run: npm run gsv:check - - name: Check builtin packages - run: npm run builtins:check - - name: Check web shell run: npm run check --workspace web diff --git a/.gitignore b/.gitignore index 8b564e95..01023c29 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ docs/.vitepress/cache/ # Alchemy state .alchemy/ + +# OS junk +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index 36bdf8d8..ba2a3730 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,6 @@ gsv/ │ │ └── protocol/ # WS and RPC frame types │ ├── wrangler.jsonc │ └── package.json -├── builtin-packages/ # builtin apps synced from root/gsv ├── packages/gsv/ # public GSV SDK, client, host bridge, and protocol types ├── web/ │ ├── src/ # desktop shell, host bridge, setup/login UI @@ -100,7 +99,7 @@ The web shell is the desktop UI. It owns: - login and setup flows - the desktop frame -- the iframe host bridge for builtin apps +- the app frame host bridge for package/app surfaces - app/window orchestration Key files: @@ -111,20 +110,11 @@ Key files: - `web/src/app/services/gateway/GatewayProvider.tsx` - `web/src/app/features/desktop/runtime/host/hostBridge.ts` -### Builtin apps +### Packages -Builtin apps live under `builtin-packages/*`. +Core GSV UI surfaces now live in the web shell, not in local builtin packages. -Examples: -- `chat` -- `gsv` -- `files` -- `shell` -- `wiki` - -Runtime operations for processes, devices, message adapters, access, settings, packages, and source repositories belong in the consolidated `gsv` builtin app, not separate standalone builtin apps. - -They are synced from `root/gsv` into the running system. A builtin app change is not applied by redeploying the gateway worker alone. +The package system still exists for installable and user-authored packages. Package source resolution, permissions, app frames, package agents, and package-backed commands are kernel/runtime concerns, but package code is no longer bootstrapped from a local `builtin-packages` workspace. ### Adapter workers @@ -180,18 +170,6 @@ cd web npm run dev ``` -### `builtin-packages/*` - -You changed a builtin app. - -Use: -```bash -git push HEAD:main -cargo run -- -u root packages sync -``` - -If the package is a new builtin, the running gateway code must already know about that builtin package. - ### `adapters/*` You changed an adapter worker. @@ -212,16 +190,10 @@ npm run deploy If a change spans multiple layers, update each one explicitly. -Examples: -- `gateway/src/*` + `builtin-packages/*` - - redeploy gateway - - sync builtins +Example: - `gateway/src/*` + `web/src/*` - redeploy gateway - rebuild/redeploy web shell -- `builtin-packages/*` + `adapters/*` - - sync builtins - - redeploy that adapter ## Development commands @@ -294,7 +266,6 @@ cargo fmt Useful commands: ```bash -cargo run -- -u root packages sync cargo run -- node install --id --workspace ~/projects cargo run -- deploy up --wizard --all ``` @@ -308,8 +279,6 @@ Examples: - `cd gateway && npx tsc --noEmit && npm run test:run` - web shell changes: - `cd web && npm run check && npm run build` -- builtin app changes: - - sync the package and exercise it through the desktop shell - WhatsApp changes: - `cd adapters/whatsapp && npx tsc --noEmit` - Discord/Test adapter changes: @@ -412,10 +381,10 @@ When you change something: ## App product and UX decision-making -Builtin apps are operational desktop tools, not generic dashboards. +GSV web shell surfaces are operational desktop tools, not generic dashboards. Start from the app's product job and the user decisions it must support. -Before coding a builtin app, be able to answer: +Before coding an app surface, be able to answer: - what job the app owns - what state should be visible at a glance - what the top actions are @@ -433,7 +402,7 @@ The consolidated `GSV` system console contract lives in `docs/gsv-system-console ## Package frontend architecture and refactoring -Builtin packages are examples for future user-authored packages. Keep frontend structure understandable as apps grow. +User-authored packages should keep frontend structure understandable as apps grow. Once a package has more than one real surface, prefer feature-oriented structure: - `app.tsx` for composition and cross-feature wiring diff --git a/assembler/src/pipeline.rs b/assembler/src/pipeline.rs index de076e52..4ab21975 100644 --- a/assembler/src/pipeline.rs +++ b/assembler/src/pipeline.rs @@ -716,8 +716,8 @@ mod tests { #[test] fn plans_registry_install_for_versioned_workspace_dependency() { let root_package = WorkspacePackage { - root: "builtin-packages/gsv".to_string(), - manifest_path: "builtin-packages/gsv/package.json".to_string(), + root: "packages/gsv-console".to_string(), + manifest_path: "packages/gsv-console/package.json".to_string(), manifest: WorkspacePackageManifest { name: "@gsv/gsv".to_string(), version: Some("0.2.6".to_string()), diff --git a/builtin-packages/chat/icon.svg b/builtin-packages/chat/icon.svg deleted file mode 100644 index d06f7f2d..00000000 --- a/builtin-packages/chat/icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/builtin-packages/chat/package-lock.json b/builtin-packages/chat/package-lock.json deleted file mode 100644 index 71317ff6..00000000 --- a/builtin-packages/chat/package-lock.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "name": "@gsv/chat", - "version": "0.2.9", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@gsv/chat", - "version": "0.2.9", - "dependencies": { - "@chenglou/pretext": "^0.0.7", - "@dicebear/bottts-neutral": "^9.4.2", - "@dicebear/core": "^9.4.2", - "@humansandmachines/gsv": "0.0.4", - "dompurify": "^3.4.2", - "marked": "^16.4.2", - "preact": "^10.27.2" - } - }, - "node_modules/@chenglou/pretext": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.7.tgz", - "integrity": "sha512-FV5hj3fGqpBlzbANUbR+s+6XuNRrghVVyuNs33zdH2SzU7MvK+7GlW6xREjDCreixKbexEn7OEkDgAFeWuu5Hg==", - "license": "MIT" - }, - "node_modules/@dicebear/bottts-neutral": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.4.2.tgz", - "integrity": "sha512-kFNwWt6j+gzZ5n5Pz7WVwePubREAQOF8ZwWA9ztwVYDVMLnOChWbAofy5FED4j5md2MXFH2EgLCFCMr5K2BmIA==", - "license": "See LICENSE file", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "node_modules/@dicebear/core": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz", - "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@humansandmachines/gsv": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@humansandmachines/gsv/-/gsv-0.0.4.tgz", - "integrity": "sha512-zJ5lHRZIAjvqA2uMhZi6ynBN/njp1dypceY58RwTKAPzFlRpyx+tLHXOnNw86QXIyftTQSYjlGaMnNStuOsQfQ==", - "dependencies": { - "marked": "^16.4.2" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/dompurify": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", - "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/marked": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/preact": { - "version": "10.29.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", - "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - } - } -} diff --git a/builtin-packages/chat/package.json b/builtin-packages/chat/package.json deleted file mode 100644 index d0663fc2..00000000 --- a/builtin-packages/chat/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@gsv/chat", - "version": "0.2.9", - "type": "module", - "dependencies": { - "@chenglou/pretext": "^0.0.7", - "@dicebear/bottts-neutral": "^9.4.2", - "@dicebear/core": "^9.4.2", - "@humansandmachines/gsv": "0.0.4", - "dompurify": "^3.4.2", - "marked": "^16.4.2", - "preact": "^10.27.2" - } -} diff --git a/builtin-packages/chat/src/app/app.tsx b/builtin-packages/chat/src/app/app.tsx deleted file mode 100644 index 45dac531..00000000 --- a/builtin-packages/chat/src/app/app.tsx +++ /dev/null @@ -1,1038 +0,0 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; -import type { - Attachment, - ChatBackend, - CompactDialogState, - ContextState, - ConversationRecord, - HilRequest, - LogRow, - PendingAssistantState, - ThreadContext, - StageView, -} from "./types"; -import { - ArchiveWorkspace, - CompactDialog, - Composer, - ConversationBar, - ProcessControlHeader, - Transcript, -} from "./components"; -import { - cleanupAttachmentPreview, - revokeAttachmentPreview, - stripAttachmentPreview, -} from "./domain/attachment-previews"; -import { useArchive } from "./hooks/useArchive"; -import { useChatCatalog } from "./hooks/useChatCatalog"; -import { useMediaSources } from "./hooks/useMediaSources"; -import { useProcessAiConfig } from "./hooks/useProcessAiConfig"; -import { useProcessCatalogSignals } from "./hooks/useProcessCatalogSignals"; -import { useProcessSignals } from "./hooks/useProcessSignals"; -import { useTargetProcessEvent } from "./hooks/useTargetProcessEvent"; -import { useTranscriptScroll } from "./hooks/useTranscriptScroll"; -import { useVoiceRecorder } from "./hooks/useVoiceRecorder"; -import { - asNumber, - asRecord, - asString, - activeMeta, - closeChatMenus, - copyTextToClipboard, - deriveThreadLabel, - dropEmptyPlaceholder, - draftConversationMeta, - draftConversationTitle, - flattenHistory, - formatError, - getStatusText, - getStoredThreadContext, - isInsideChatMenu, - normalizeContextState, - normalizeHilRequest, - normalizeThreadContext, - personalProfileLabel, - readAttachmentFile, - safeText, - setStoredThreadContext, - systemRow, - systemRows, - suggestKeepLast, - titleForActive, -} from "./view-helpers"; - -const HISTORY_PAGE_SIZE = 50; -const DEFAULT_COMPOSER_DOCK_HEIGHT = 104; - -function historyTargetKey(target: Pick): string { - return `${target.pid}\n${target.conversationId || "default"}`; -} - -type HistoryWindow = { - targetKey: string; - oldestMessageId: number | null; - newestMessageId: number | null; - hasMoreBefore: boolean; - loadingOlder: boolean; -}; - -const EMPTY_HISTORY_WINDOW: HistoryWindow = { - targetKey: "", - oldestMessageId: null, - newestMessageId: null, - hasMoreBefore: false, - loadingOlder: false, -}; - -function historyMessageIds(messages: unknown[]): { first: number | null; last: number | null } { - const ids = messages - .map((message) => asNumber(asRecord(message)?.id)) - .filter((id): id is number => typeof id === "number"); - return { - first: ids[0] ?? null, - last: ids[ids.length - 1] ?? null, - }; -} - -export function App({ backend }: { backend: ChatBackend }) { - const [active, setActiveState] = useState(() => getStoredThreadContext()); - const [stageView, setStageView] = useState("chat"); - const [rows, setRows] = useState(() => systemRows("Connecting chat backend.")); - const [messageCount, setMessageCount] = useState(0); - const [historyWindow, setHistoryWindow] = useState(EMPTY_HISTORY_WINDOW); - const [contextState, setContextState] = useState(null); - const [contextStatesByConversation, setContextStatesByConversation] = useState>({}); - const [activeRunId, setActiveRunId] = useState(null); - const [pendingAssistant, setPendingAssistant] = useState(null); - const [pendingHil, setPendingHil] = useState(null); - const [messageBusy, setMessageBusy] = useState(false); - const [abortBusy, setAbortBusy] = useState(false); - const [hilBusy, setHilBusy] = useState(false); - const [forceNewProcess, setForceNewProcess] = useState(false); - const [compactBusy, setCompactBusy] = useState(false); - const [branchBusy, setBranchBusy] = useState(false); - const [composerDockNode, setComposerDockNode] = useState(null); - const [composerDockHeight, setComposerDockHeight] = useState(DEFAULT_COMPOSER_DOCK_HEIGHT); - const [hostError, setHostError] = useState(""); - const [composeText, setComposeText] = useState(""); - const [attachments, setAttachments] = useState([]); - const [compactDialog, setCompactDialog] = useState(null); - const [notice, setNotice] = useState(""); - const [suppressNextAbortedComplete, setSuppressNextAbortedComplete] = useState(false); - const activeRef = useRef(active); - const mountedRef = useRef(true); - const attachmentsRef = useRef(attachments); - const previewUrlsRef = useRef>(new Set()); - const skipNextHistoryLoadRef = useRef(null); - const autoHomeOpenRef = useRef(false); - const historyWindowRef = useRef(EMPTY_HISTORY_WINDOW); - const { - transcriptRef, - setTranscriptContentNode, - hasNewMessages, - stickToBottomRef, - clearNewMessages, - prepareForLiveTranscriptActivity, - handleTranscriptScroll, - scrollTranscript, - jumpToLatest, - } = useTranscriptScroll(); - const { - applyProcessCatalogSignal, - conversations, - conversationProfiles, - draftProfile, - draftProfileId, - homeThread, - loadConversations, - loadThreads, - setDraftProfileId, - threads, - viewerUsername, - } = useChatCatalog(backend); - - useEffect(() => { - activeRef.current = active; - }, [active]); - - useEffect(() => { - attachmentsRef.current = attachments; - }, [attachments]); - - useLayoutEffect(() => { - const node = composerDockNode; - if (!node) { - return undefined; - } - const updateHeight = () => { - const nextHeight = Math.ceil(node.getBoundingClientRect().height); - if (nextHeight > 0) { - setComposerDockHeight((current) => current === nextHeight ? current : nextHeight); - } - }; - updateHeight(); - if (typeof ResizeObserver === "undefined") { - window.addEventListener("resize", updateHeight); - return () => window.removeEventListener("resize", updateHeight); - } - const observer = new ResizeObserver(updateHeight); - observer.observe(node); - return () => observer.disconnect(); - }, [composerDockNode]); - - useLayoutEffect(() => { - scrollTranscript("near-bottom"); - }, [composerDockHeight, scrollTranscript]); - - const activeConversationId = active?.conversationId || "default"; - const activeConversation = conversations.find((conversation) => conversation.id === activeConversationId) ?? null; - const homeProfileLabel = personalProfileLabel(conversationProfiles); - const homeProfile = conversationProfiles.find((profile) => - profile.spawnMode === "default" || - profile.id === "personal" || - profile.kind === "personal-agent" - ) ?? null; - const activeThread = active ? threads.find((thread) => thread.pid === active.pid) ?? null : null; - const activeTitle = active ? titleForActive(active, activeConversation, threads, homeProfileLabel) : draftConversationTitle(draftProfile); - const assistantLabel = active - ? active.isHome - ? homeProfile?.newProcessRunAs || homeProfile?.runAs || homeProfile?.displayName || homeProfileLabel - : activeThread?.username || activeThread?.profile || activeThread?.label || activeTitle - : draftProfile.runAs || draftProfile.newProcessRunAs || draftProfile.displayName; - const stageAgentLabel = assistantLabel || activeTitle; - const stageAgentSeed = active - ? active.isHome - ? homeProfile?.newProcessRunAs || homeProfile?.runAs || homeProfile?.id || stageAgentLabel - : activeThread?.username || activeThread?.profile || active.pid - : draftProfile.newProcessRunAs || draftProfile.runAs || draftProfile.id || stageAgentLabel; - const stageProcessLabel = active ? activeMeta(active, activeConversation) : draftConversationMeta(draftProfile); - const statusText = getStatusText({ - active, - draftProfile, - hostError, - pendingAssistant, - pendingHil, - messageBusy, - abortBusy, - hilBusy, - }); - const interactive = !hostError; - const addVoiceAttachment = useCallback((attachment: Attachment) => { - setAttachments((current) => current.concat(attachment)); - }, []); - const { - voice, - startVoiceRecording, - stopVoiceRecording, - cancelVoiceRecording, - clearVoiceError, - } = useVoiceRecorder({ - interactive, - messageBusy, - previewUrlsRef, - onAttachment: addVoiceAttachment, - }); - const hasDraft = composeText.trim().length > 0 || attachments.length > 0; - const voiceActive = voice.status !== "idle"; - const runActive = activeRunId !== null || pendingAssistant !== null || pendingHil !== null; - const runStateClass = hostError ? "is-error" : pendingHil ? "is-waiting" : runActive ? "is-running" : "is-ready"; - const runStateLabel = hostError ? "Error" : pendingHil ? "Approval" : runActive ? "Running" : "Ready"; - const canSend = interactive && !messageBusy && hasDraft && !voiceActive; - const canStop = interactive && Boolean(active?.pid) && !abortBusy && runActive && !hasDraft && !voiceActive; - const canActOnConversation = interactive && Boolean(active?.pid) && !messageBusy && pendingAssistant === null; - - const updateHistoryWindow = useCallback((next: HistoryWindow) => { - historyWindowRef.current = next; - setHistoryWindow(next); - }, []); - - const updateNewestHistoryMessageId = useCallback((target: ThreadContext, newestMessageId: number) => { - setHistoryWindow((current) => { - if (current.targetKey !== historyTargetKey(target)) { - return current; - } - const updated = { ...current, newestMessageId }; - historyWindowRef.current = updated; - return updated; - }); - }, []); - - const { - archive, - loadArchiveSegments, - readArchiveSegment, - resetArchive, - } = useArchive({ backend, activeRef }); - - const setActive = useCallback((next: ThreadContext | null) => { - const previous = activeRef.current; - const normalized = setStoredThreadContext(next); - const processChanged = previous?.pid !== normalized?.pid; - activeRef.current = normalized; - setActiveState(normalized); - if (normalized) { - setForceNewProcess(false); - } - if (!normalized) { - setContextState(null); - setPendingHil(null); - setPendingAssistant(null); - setActiveRunId(null); - setMessageCount(0); - updateHistoryWindow(EMPTY_HISTORY_WINDOW); - clearNewMessages(); - resetArchive(); - } else if (processChanged) { - setContextState(null); - setContextStatesByConversation({}); - setPendingHil(null); - setPendingAssistant(null); - setActiveRunId(null); - setMessageCount(0); - updateHistoryWindow(EMPTY_HISTORY_WINDOW); - clearNewMessages(); - resetArchive(); - } else { - setContextState((current) => { - const cached = contextStatesByConversation[normalized.conversationId] ?? null; - return cached ?? current; - }); - } - setStageView("chat"); - setNotice(""); - }, [clearNewMessages, contextStatesByConversation, resetArchive, updateHistoryWindow]); - - const appendSystem = useCallback((text: string) => { - setRows((current) => dropEmptyPlaceholder(current).concat(systemRow(text))); - }, []); - - const { - mediaSources, - mediaSourceErrors, - loadMediaSource, - retryMediaSource, - } = useMediaSources({ backend, activeRef, mountedRef, appendSystem }); - const { - processAiState, - processAiLoading, - processAiPendingAction, - processAiError, - reloadProcessAiConfig, - applyProcessAiProfile, - clearProcessAiOverride, - setProcessAiReasoning, - } = useProcessAiConfig({ backend, active }); - - useEffect(() => { - return () => { - mountedRef.current = false; - attachmentsRef.current.forEach(revokeAttachmentPreview); - previewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); - previewUrlsRef.current.clear(); - }; - }, []); - - const loadHistory = useCallback(async (target = activeRef.current) => { - if (!target?.pid) { - setContextState(null); - setContextStatesByConversation({}); - setMessageCount(0); - updateHistoryWindow(EMPTY_HISTORY_WINDOW); - clearNewMessages(); - setRows(systemRows("No thread selected. Send a message to start a new thread.")); - return; - } - - try { - const targetKey = historyTargetKey(target); - updateHistoryWindow({ ...EMPTY_HISTORY_WINDOW, targetKey }); - const result = await backend.getHistory({ - pid: target.pid, - conversationId: target.conversationId || "default", - limit: HISTORY_PAGE_SIZE, - tail: true, - }); - const activeTarget = activeRef.current; - if (!activeTarget || historyTargetKey(activeTarget) !== targetKey) { - return; - } - const record = asRecord(result); - if (!record?.ok) { - setRows(systemRows("history error: " + safeText(record?.error || "unknown error"))); - return; - } - const messages = Array.isArray(record.messages) ? record.messages : []; - const flattened = flattenHistory(messages); - const ids = historyMessageIds(messages); - const total = asNumber(record.messageCount) ?? messages.length; - updateHistoryWindow({ - targetKey, - oldestMessageId: ids.first, - newestMessageId: ids.last, - hasMoreBefore: record.hasMoreBefore === true, - loadingOlder: false, - }); - setMessageCount(total); - const nextContext = normalizeContextState(record.context); - setContextState(nextContext); - setContextStatesByConversation((current) => { - const conversationId = target.conversationId || "default"; - if (!nextContext) { - if (!(conversationId in current)) { - return current; - } - const nextStates = { ...current }; - delete nextStates[conversationId]; - return nextStates; - } - return { ...current, [conversationId]: nextContext }; - }); - const targetConversationId = target.conversationId || "default"; - const nextHil = normalizeHilRequest(record.pendingHil); - const targetHil = nextHil?.conversationId === targetConversationId ? nextHil : null; - const historyActiveRunId = asString(record.activeRunId); - const historyActiveConversationId = asString(record.activeConversationId) || "default"; - const activeRunMatchesTarget = Boolean(historyActiveRunId) - && historyActiveConversationId === targetConversationId; - setPendingHil(targetHil); - setActiveRunId(targetHil?.runId ?? (activeRunMatchesTarget ? historyActiveRunId : null)); - setPendingAssistant(targetHil ? null : activeRunMatchesTarget ? "thinking" : null); - setRows(flattened); - clearNewMessages(); - requestAnimationFrame(() => scrollTranscript("bottom")); - } catch (error) { - setRows(systemRows("history error: " + formatError(error))); - } - }, [backend, clearNewMessages, updateHistoryWindow]); - - const loadOlderHistory = useCallback(async () => { - const target = activeRef.current; - if (!target?.pid) { - return; - } - const currentWindow = historyWindowRef.current; - if (!currentWindow.hasMoreBefore || currentWindow.loadingOlder || currentWindow.oldestMessageId === null) { - return; - } - const targetKey = historyTargetKey(target); - if (currentWindow.targetKey !== targetKey) { - return; - } - - const node = transcriptRef.current; - const previousScrollHeight = node?.scrollHeight ?? 0; - const previousScrollTop = node?.scrollTop ?? 0; - stickToBottomRef.current = false; - updateHistoryWindow({ ...currentWindow, loadingOlder: true }); - - try { - const result = await backend.getHistory({ - pid: target.pid, - conversationId: target.conversationId || "default", - limit: HISTORY_PAGE_SIZE, - beforeMessageId: currentWindow.oldestMessageId, - }); - const activeTarget = activeRef.current; - if (!activeTarget || historyTargetKey(activeTarget) !== targetKey) { - return; - } - const record = asRecord(result); - if (!record?.ok) { - appendSystem("history error: " + safeText(record?.error || "unknown error")); - updateHistoryWindow({ ...historyWindowRef.current, loadingOlder: false }); - return; - } - const messages = Array.isArray(record.messages) ? record.messages : []; - const ids = historyMessageIds(messages); - const olderRows = messages.length > 0 ? flattenHistory(messages) : []; - setRows((current) => olderRows.concat(dropEmptyPlaceholder(current))); - updateHistoryWindow({ - targetKey, - oldestMessageId: ids.first ?? currentWindow.oldestMessageId, - newestMessageId: currentWindow.newestMessageId, - hasMoreBefore: record.hasMoreBefore === true, - loadingOlder: false, - }); - setMessageCount((current) => asNumber(record.messageCount) ?? current); - requestAnimationFrame(() => { - const nextNode = transcriptRef.current; - if (!nextNode) { - return; - } - nextNode.scrollTop = nextNode.scrollHeight - previousScrollHeight + previousScrollTop; - }); - } catch (error) { - const activeTarget = activeRef.current; - if (!activeTarget || historyTargetKey(activeTarget) !== targetKey) { - return; - } - appendSystem("history error: " + formatError(error)); - updateHistoryWindow({ ...historyWindowRef.current, loadingOlder: false }); - } - }, [appendSystem, backend, updateHistoryWindow]); - - useEffect(() => { - if (active?.pid) { - const pid = active.pid; - void backend.watchProcessSignals({ pid }).catch((error) => setHostError(formatError(error))); - void loadConversations(active.pid); - const historyKey = historyTargetKey(active); - if (skipNextHistoryLoadRef.current === historyKey) { - skipNextHistoryLoadRef.current = null; - } else { - void loadHistory(active); - } - return () => { - void backend.unwatchProcessSignals({ pid }).catch(() => {}); - }; - } - void backend.unwatchProcessSignals({ pid: "" }).catch(() => {}); - void loadConversations(""); - setRows(systemRows(draftConversationMeta(draftProfile))); - return undefined; - }, [active?.pid, active?.conversationId, backend, draftProfile, loadConversations, loadHistory]); - - useEffect(() => { - function handlePointerDown(event: PointerEvent): void { - if (!isInsideChatMenu(event.target)) { - closeChatMenus(); - } - } - document.addEventListener("pointerdown", handlePointerDown); - return () => document.removeEventListener("pointerdown", handlePointerDown); - }, []); - - useTargetProcessEvent(setActive); - useProcessCatalogSignals({ - backend, - applySignal: applyProcessCatalogSignal, - loadThreads, - onError: setHostError, - }); - - useEffect(() => { - if (active || forceNewProcess || hostError || autoHomeOpenRef.current) { - return undefined; - } - const timer = window.setTimeout(() => { - if (activeRef.current || forceNewProcess || autoHomeOpenRef.current) { - return; - } - autoHomeOpenRef.current = true; - void openHome(); - }, 0); - return () => window.clearTimeout(timer); - }, [active, forceNewProcess, hostError, homeProfileLabel]); - - useProcessSignals({ - activeRef, - appendSystem, - loadArchiveSegments, - loadConversations, - loadHistory, - onAiConfigChanged: () => void reloadProcessAiConfig(), - onContextMessageId: updateNewestHistoryMessageId, - prepareForLiveTranscriptActivity, - setContextState, - setContextStatesByConversation, - setMessageCount, - setActiveRunId, - setPendingAssistant, - setPendingHil, - setRows, - setSuppressNextAbortedComplete, - suppressNextAbortedComplete, - stageView, - }); - - useLayoutEffect(() => { - scrollTranscript("near-bottom"); - }, [rows, pendingAssistant, pendingHil, scrollTranscript]); - - async function openHome(): Promise { - setNotice(""); - try { - const result = await backend.spawnProcess({ - label: homeProfileLabel, - }); - const record = asRecord(result); - if (!record?.ok) { - appendSystem("home open failed: " + safeText(record?.error || "unknown error")); - return; - } - setActive(normalizeThreadContext({ - pid: record.pid, - cwd: record.cwd, - conversationId: "default", - isHome: true, - })); - } catch (error) { - appendSystem("home open failed: " + formatError(error)); - } - } - - async function openThread(pid: string): Promise { - const entry = threads.find((candidate) => candidate.pid === pid); - if (!entry) { - appendSystem("process not found: " + pid); - return; - } - setActive(normalizeThreadContext({ - pid: entry.pid, - cwd: entry.cwd, - conversationId: "default", - })); - } - - function resetToNewThread(): void { - cancelVoiceRecording(); - setForceNewProcess(true); - setActive(null); - setComposeText(""); - setAttachments((current) => { - current.forEach((attachment) => cleanupAttachmentPreview(attachment, previewUrlsRef.current)); - return []; - }); - setStageView("chat"); - } - - async function switchConversation(conversation: ConversationRecord): Promise { - if (!active) { - return; - } - setActive({ - ...active, - conversationId: conversation.id, - conversationTitle: conversation.title, - }); - setStageView("chat"); - } - - async function sendMessage(): Promise { - if (voice.status !== "idle") { - return; - } - const message = composeText.trim(); - const media = attachments.map(stripAttachmentPreview); - if (!message && media.length === 0) { - return; - } - setMessageBusy(true); - setNotice(""); - try { - let target = activeRef.current; - if (!target?.pid) { - const runAs = draftProfile.runAs ?? (forceNewProcess ? draftProfile.newProcessRunAs : undefined); - // Default personal-agent drafts use the default conversation. Explicit - // New Process can still start a fresh personal-agent process. - const spawnResult = await backend.spawnProcess( - runAs - ? { - runAs, - label: deriveThreadLabel(message) || draftProfile.displayName, - } - : { label: draftProfile.displayName }, - ); - const record = asRecord(spawnResult); - if (!record?.ok) { - appendSystem("thread start failed: " + safeText(record?.error || "unknown error")); - return; - } - target = normalizeThreadContext({ - pid: record.pid, - cwd: record.cwd, - conversationId: "default", - isHome: !runAs, - }); - if (!target) { - appendSystem("thread start failed: invalid process target"); - return; - } - skipNextHistoryLoadRef.current = historyTargetKey(target); - setActive(target); - await backend.watchProcessSignals({ pid: target.pid }).catch((error) => setHostError(formatError(error))); - void loadThreads(); - } - if (!target?.pid) { - appendSystem("thread start failed: missing process id"); - return; - } - const optimisticTimestamp = Date.now(); - setRows((current) => dropEmptyPlaceholder(current).concat({ - kind: "message", - role: "user", - text: message, - media, - timestamp: optimisticTimestamp, - })); - setComposeText(""); - setAttachments((current) => { - current.forEach((attachment) => cleanupAttachmentPreview(attachment, previewUrlsRef.current)); - return []; - }); - const result = await backend.sendMessage({ - message, - pid: target.pid, - conversationId: target.conversationId || "default", - ...(media.length > 0 ? { media } : {}), - }); - const record = asRecord(result); - if (!record?.ok) { - appendSystem("send failed: " + safeText(record?.error || "unknown error")); - return; - } - const runId = asString(record.runId); - if (runId) { - setRows((current) => current.map((row) => ( - row.kind === "message" - && row.role === "user" - && row.timestamp === optimisticTimestamp - && !row.runId - ? { ...row, runId } - : row - ))); - if (record.queued !== true) { - setActiveRunId(runId); - } - } - if (record.queued !== true) { - setPendingAssistant("thinking"); - } - if (record.queued === true) { - appendSystem("message queued while process is busy"); - } - } catch (error) { - appendSystem("send failed: " + formatError(error)); - } finally { - setMessageBusy(false); - } - } - - async function abortActiveRun(): Promise { - const target = activeRef.current; - if (!target?.pid || abortBusy) { - return; - } - setAbortBusy(true); - try { - const result = await backend.abortRun({ pid: target.pid }); - const record = asRecord(result); - if (!record?.ok) { - appendSystem("stop failed"); - return; - } - if (record.aborted === true) { - setPendingHil(null); - if (record.continuedQueuedRunId) { - setSuppressNextAbortedComplete(true); - setActiveRunId(asString(record.continuedQueuedRunId)); - setPendingAssistant("thinking"); - } else { - setActiveRunId(null); - setPendingAssistant(null); - appendSystem("run interrupted"); - } - } - } catch (error) { - appendSystem("stop failed: " + formatError(error)); - } finally { - setAbortBusy(false); - } - } - - async function decidePendingHil(requestId: string, decision: "approve" | "deny", remember = false): Promise { - const target = activeRef.current; - if (!target?.pid || !pendingHil || pendingHil.requestId !== requestId || hilBusy) { - return; - } - setHilBusy(true); - try { - const result = await backend.decideHil({ pid: target.pid, requestId, decision, remember }); - const record = asRecord(result); - if (!record?.ok) { - appendSystem("tool confirmation failed"); - return; - } - const nextHil = normalizeHilRequest(record.pendingHil); - setPendingHil(nextHil); - setActiveRunId(nextHil?.runId ?? activeRunId); - if (!nextHil) { - setPendingAssistant("thinking"); - } - } catch (error) { - appendSystem("tool confirmation failed: " + formatError(error)); - } finally { - setHilBusy(false); - } - } - - function openCompactDialog(): void { - const suggested = suggestKeepLast(messageCount, contextState); - setCompactDialog({ keepLast: String(suggested), suggested }); - } - - async function compactActiveConversation(): Promise { - const target = activeRef.current; - if (!target?.pid || !compactDialog) { - return; - } - const keepLast = Number.parseInt(compactDialog.keepLast.trim(), 10); - if (!Number.isInteger(keepLast) || keepLast < 0) { - setNotice("Keep-last must be a non-negative integer."); - return; - } - setCompactBusy(true); - setNotice(""); - try { - const result = await backend.compactConversation({ - pid: target.pid, - conversationId: target.conversationId || "default", - keepLast, - }); - const record = asRecord(result); - if (!record?.ok) { - appendSystem("compact failed: " + safeText(record?.error || "unknown error")); - return; - } - setCompactDialog(null); - appendSystem("conversation compacted: " + safeText(record.archivedMessages) + " messages archived"); - await loadHistory(target); - await loadConversations(target.pid); - await loadArchiveSegments(true); - } catch (error) { - appendSystem("compact failed: " + formatError(error)); - } finally { - setCompactBusy(false); - } - } - - async function branchFromMessage(messageId: number): Promise { - const target = activeRef.current; - if (!target?.pid || !messageId) { - return; - } - setBranchBusy(true); - setNotice(""); - try { - const title = "Branch from message " + messageId; - const result = await backend.forkConversation({ - pid: target.pid, - conversationId: target.conversationId || "default", - throughMessageId: messageId, - targetConversationId: "branch-" + Date.now().toString(36), - title, - }); - const record = asRecord(result); - if (!record?.ok) { - appendSystem("branch failed: " + safeText(record?.error || "unknown error")); - return; - } - const targetConversation = asRecord(record.targetConversation); - const nextConversationId = asString(targetConversation?.id) || "default"; - const nextTitle = asString(targetConversation?.title) || title; - setActive({ - ...target, - conversationId: nextConversationId, - conversationTitle: nextTitle, - }); - setStageView("chat"); - setNotice("Created and opened " + nextTitle + " from message " + messageId + "."); - await loadConversations(target.pid); - } catch (error) { - appendSystem("branch failed: " + formatError(error)); - } finally { - setBranchBusy(false); - } - } - - function toggleArchiveView(): void { - if (stageView === "archive") { - setStageView("chat"); - return; - } - setStageView("archive"); - void loadArchiveSegments(true); - } - - async function toggleFullscreen(): Promise { - try { - if (document.fullscreenElement) { - await document.exitFullscreen(); - return; - } - await document.documentElement.requestFullscreen(); - } catch (error) { - appendSystem("fullscreen failed: " + formatError(error)); - } - } - - async function copyText(label: string, text: string): Promise { - try { - await copyTextToClipboard(text); - setNotice("Copied " + label + "."); - } catch (error) { - appendSystem("copy failed: " + formatError(error)); - } - } - - async function readAttachments(files: FileList | null): Promise { - const selected = Array.from(files || []); - if (selected.length === 0) { - return; - } - try { - const next = await Promise.all(selected.map(readAttachmentFile)); - setAttachments((current) => current.concat(next)); - } catch (error) { - appendSystem("attachment read failed: " + formatError(error)); - } - } - - function removeAttachment(index: number): void { - setAttachments((current) => { - const removed = current[index]; - if (removed) { - cleanupAttachmentPreview(removed, previewUrlsRef.current); - } - return current.filter((_, itemIndex) => itemIndex !== index); - }); - } - - return ( -
-
-
- void switchConversation(conversation)} - /> - )} - processAiState={processAiState} - processAiLoading={processAiLoading} - processAiPendingAction={processAiPendingAction} - processAiError={processAiError} - canFreeContext={canActOnConversation} - compactBusy={compactBusy} - onHome={() => void openHome()} - onOpenThread={(pid) => void openThread(pid)} - onNewTask={resetToNewThread} - onCopyTaskId={() => { - if (active) void copyText("task id", active.pid); - }} - onSetReasoning={(reasoning) => void setProcessAiReasoning(reasoning)} - onApplyProfile={(profileId) => void applyProcessAiProfile(profileId)} - onClearModelOverride={() => void clearProcessAiOverride()} - onFreeContext={openCompactDialog} - onOpenArchive={toggleArchiveView} - onDraftProfileChange={setDraftProfileId} - onToggleFullscreen={() => void toggleFullscreen()} - /> -
- -
- {notice ? {notice} : null} -
- - {stageView === "archive" ? ( - void loadArchiveSegments(true)} - onSelect={(segmentId) => void readArchiveSegment(segmentId)} - onLoadMediaSource={loadMediaSource} - onRetryMediaSource={retryMediaSource} - /> - ) : ( -
- void copyText("message", text)} - onBranch={(messageId) => void branchFromMessage(messageId)} - onHilDecision={(requestId, decision, remember) => void decidePendingHil(requestId, decision, remember)} - onLoadOlderHistory={() => void loadOlderHistory()} - onJumpToLatest={jumpToLatest} - onViewedLatest={handleTranscriptScroll} - onLoadMediaSource={loadMediaSource} - onRetryMediaSource={retryMediaSource} - /> - -
- void sendMessage()} - onStop={() => void abortActiveRun()} - onFiles={(files) => void readAttachments(files)} - onRemoveAttachment={removeAttachment} - onStartVoice={() => void startVoiceRecording()} - onStopVoice={stopVoiceRecording} - onCancelVoice={cancelVoiceRecording} - onClearVoiceError={clearVoiceError} - /> -
-
- )} -
- - {compactDialog ? ( - setCompactDialog({ ...compactDialog, keepLast })} - onCancel={() => setCompactDialog(null)} - onConfirm={() => void compactActiveConversation()} - /> - ) : null} -
- ); -} diff --git a/builtin-packages/chat/src/app/components.tsx b/builtin-packages/chat/src/app/components.tsx deleted file mode 100644 index ebf588e1..00000000 --- a/builtin-packages/chat/src/app/components.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export { ArchiveWorkspace } from "./components/archive/ArchiveWorkspace"; -export { Composer } from "./components/composer/Composer"; -export { CompactDialog } from "./components/conversation/CompactDialog"; -export { ContextMeter, ConversationCost } from "./components/conversation/ContextMeter"; -export { ConversationBar } from "./components/conversation/ConversationBar"; -export { ProcessControlHeader } from "./components/conversation/ProcessControlHeader"; -export { ChatNavigator, MobileProcessNav } from "./components/navigation/ChatNavigator"; -export { Transcript } from "./components/transcript/Transcript"; diff --git a/builtin-packages/chat/src/app/components/archive/ArchiveWorkspace.tsx b/builtin-packages/chat/src/app/components/archive/ArchiveWorkspace.tsx deleted file mode 100644 index c980190f..00000000 --- a/builtin-packages/chat/src/app/components/archive/ArchiveWorkspace.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { ArchiveState, LogRow, MessageRow } from "../../types"; -import { ArchiveIcon, RefreshIcon } from "../../icons"; -import { copyTextToClipboard, flattenHistory, formatRelativeTime, formatTimestamp, shortId } from "../../view-helpers"; -import { MessageBubble } from "../transcript/MessageBubble"; -import { isHiddenInternalToolRow, ToolCard } from "../transcript/ToolCard"; - -export function ArchiveWorkspace(props: { - archive: ArchiveState; - userLabel: string; - assistantLabel: string; - mediaSources: Record; - mediaSourceErrors: Record; - onRefresh(): void; - onSelect(segmentId: string): void; - onLoadMediaSource(media: unknown): void; - onRetryMediaSource(media: unknown): void; -}) { - const { archive } = props; - const selected = archive.segments.find((segment) => segment.id === archive.selectedSegmentId) ?? null; - const archiveRows = selected ? flattenHistory(archive.messages) : []; - return ( -
-
-
- Conversation archive -

{selected ? `Messages ${selected.fromMessageId}-${selected.toMessageId}` : "Archived segments"}

-

{selected ? `${shortId(selected.id)} - ${formatTimestamp(selected.createdAt)}` : archive.loading ? "Loading..." : archive.error || "Read-only compacted history"}

-
- -
-
-
- {archive.segments.length === 0 ? ( -
{archive.loading ? "Loading archive..." : archive.error || "No compacted segments."}
- ) : archive.segments.map((segment) => ( - - ))} -
-
- {selected ? ( - <> -
{archive.messages.length}/{archive.messageCount}{archive.truncated ? " shown" : ""}
- {archiveRows.map((row, index) => ( - - ))} - - ) : ( -
- -

No archived segment selected

-

Select a segment to read compacted history for this conversation.

-
- )} -
-
-
- ); -} - -function ArchiveRow({ - row, - userLabel, - assistantLabel, - mediaSources, - mediaSourceErrors, - onLoadMediaSource, - onRetryMediaSource, -}: { - row: LogRow; - userLabel: string; - assistantLabel: string; - mediaSources: Record; - mediaSourceErrors: Record; - onLoadMediaSource(media: unknown): void; - onRetryMediaSource(media: unknown): void; -}) { - if (row.kind === "toolCall" || row.kind === "toolResult") { - if (isHiddenInternalToolRow(row, null)) { - return null; - } - return ; - } - return ( - { void copyTextToClipboard(text).catch(() => {}); }} - onBranch={() => {}} - onLoadMediaSource={onLoadMediaSource} - onRetryMediaSource={onRetryMediaSource} - /> - ); -} diff --git a/builtin-packages/chat/src/app/components/composer/Composer.tsx b/builtin-packages/chat/src/app/components/composer/Composer.tsx deleted file mode 100644 index 95e6c570..00000000 --- a/builtin-packages/chat/src/app/components/composer/Composer.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useEffect, useRef } from "preact/hooks"; -import type { Attachment, VoiceRecordingState } from "../../types"; -import { MicIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "../../icons"; -import { formatAttachmentDuration } from "../../view-helpers"; -import { VoiceAudioPlayer } from "../media/VoiceMessage"; - -export function Composer(props: { - value: string; - attachments: Attachment[]; - disabled: boolean; - canSend: boolean; - canStop: boolean; - stopBusy: boolean; - voice: VoiceRecordingState; - canRecord: boolean; - onValueChange(value: string): void; - onSubmit(): void; - onStop(): void; - onFiles(files: FileList | null): void; - onRemoveAttachment(index: number): void; - onStartVoice(): void; - onStopVoice(): void; - onCancelVoice(): void; - onClearVoiceError(): void; -}) { - const textareaRef = useRef(null); - const actionLabel = props.canStop ? (props.stopBusy ? "Stopping..." : "Stop") : "Send"; - const voiceActive = props.voice.status !== "idle"; - const showVoicePanel = voiceActive || Boolean(props.voice.error); - const voiceLabel = labelForVoiceState(props.voice); - const voiceElapsed = formatAttachmentDuration(props.voice.elapsedMs / 1000) || "0:00"; - useEffect(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - const maxHeight = 176; - textarea.style.height = "auto"; - const nextHeight = Math.min(maxHeight, Math.max(42, textarea.scrollHeight)); - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden"; - }, [props.value]); - return ( -
{ event.preventDefault(); props.onSubmit(); }}> - {props.attachments.length > 0 ? ( -
- {props.attachments.map((attachment, index) => ( - attachment.type === "audio" ? ( - props.onRemoveAttachment(index)} - /> - ) : ( -
- {attachment.filename || "attachment"} - {attachment.duration ? {formatAttachmentDuration(attachment.duration)} : null} - -
- ) - ))} -
- ) : null} - {showVoicePanel ? ( -
-
-
- {props.voice.error ? {props.voice.error} : null} -
- {props.voice.status === "recording" ? ( - - ) : null} - {props.voice.status === "requesting" || props.voice.status === "recording" ? ( - - ) : null} - {props.voice.status === "idle" && props.voice.error ? ( - - ) : null} -
-
- ) : null} -
- - -