diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index 4ab637f4..d36341f4 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -1,4 +1,5 @@ import type { Thread } from "@llm-space/core"; +import type { EditorCommitScopeHandle } from "@llm-space/ui/components/code-editor/editor-commit-scope"; import { FirecrawlLimitDialog } from "@llm-space/ui/components/firecrawl-limit-dialog"; import { useModels, @@ -299,6 +300,27 @@ function PageWorkspace({ ); }, []); const tabs = useThreadTabs({ canPruneRestoredTab }); + const threadViewCommitHandlesRef = useRef( + new Map() + ); + const commitThreadView = useCallback((paneId: string) => { + threadViewCommitHandlesRef.current.get(paneId)?.commitAll(); + }, []); + const commitThreadViews = useCallback( + (closingTabs: AppTab[]) => { + for (const tab of closingTabs) { + if (tab.type === "thread") commitThreadView(tab.paneId); + } + }, + [commitThreadView] + ); + const handleViewCommitScopeReady = useCallback( + (paneId: string, handle: EditorCommitScopeHandle | null) => { + if (handle) threadViewCommitHandlesRef.current.set(paneId, handle); + else threadViewCommitHandlesRef.current.delete(paneId); + }, + [] + ); const { executeCommand } = useCommands(); const models = useModels(); const refreshModels = useRefreshModels(); @@ -748,6 +770,7 @@ function PageWorkspace({ tabs: tabs.tabs, targetId: target, onBlocked: () => showRuntimeRunBlocked("closing this tab"), + commitViews: commitThreadViews, close, }); }, @@ -763,6 +786,7 @@ function PageWorkspace({ keepId: target, runtimeId: targetRuntimeId, onBlocked: () => showRuntimeRunBlocked("closing other tabs"), + commitViews: commitThreadViews, closeOthers: closeOthersInRuntime, }); }, @@ -773,6 +797,7 @@ function PageWorkspace({ tabs: tabs.tabs, runtimeId, onBlocked: () => showRuntimeRunBlocked("closing all tabs"), + commitViews: commitThreadViews, closeAll: closeAllInRuntime, }); }, @@ -1144,6 +1169,8 @@ function PageWorkspace({ onToggleSidebar={handleToggleSidebar} lifecycleHost={paneLifecycleHost} mutationRevision={mutationRevision} + commitThreadView={commitThreadView} + onViewCommitScopeReady={handleViewCommitScopeReady} onThreadStateChange={handleThreadStateChange} toolbarSlot={} /> diff --git a/apps/desktop/src/app/workspace-model-scope.test.tsx b/apps/desktop/src/app/workspace-model-scope.test.tsx index 6b1428c0..449f4982 100644 --- a/apps/desktop/src/app/workspace-model-scope.test.tsx +++ b/apps/desktop/src/app/workspace-model-scope.test.tsx @@ -1,14 +1,39 @@ /* eslint-disable @typescript-eslint/await-thenable, @typescript-eslint/no-empty-function, @typescript-eslint/unbound-method */ import { afterEach, describe, expect, test } from "bun:test"; -import type { AgentEvent, AgentTransport, Thread } from "@llm-space/core"; -import type { ModelClient } from "@llm-space/ui/host"; +import type { + AgentEvent, + AgentTransport, + ModelProviderGroup, + Thread, +} from "@llm-space/core"; +import { + EditorCommitScope, + useRegisterEditorCommit, + type EditorCommitScopeHandle, +} from "@llm-space/ui/components/code-editor/editor-commit-scope"; +import { + ThreadPlaygroundSession, + ThreadPlaygroundView, +} from "@llm-space/ui/components/thread-playground"; +import { + HostServicesProvider, + type HostServices, + type ModelClient, +} from "@llm-space/ui/host"; import { QueryClient, QueryClientProvider, useQuery, } from "@tanstack/react-query"; -import { act, useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { + act, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { createRoot, type Root } from "react-dom/client"; import { runRemoteRuntimeActionIfAllowed } from "@/components/remote-runtime-actions"; @@ -18,11 +43,15 @@ import { switchWorkspaceRuntimeIfAllowed } from "@/components/thread-tabs/runtim import { SerializedPersistence } from "@/components/thread-tabs/serialized-persistence"; import { settleStreamingPane } from "@/components/thread-tabs/settle-streaming-pane"; import { usePaneRefreshAcknowledgement } from "@/components/thread-tabs/use-pane-refresh-ack"; +import type { AppTab } from "@/components/thread-tabs/use-thread-tabs"; +import { useThreadViewLru } from "@/components/thread-tabs/use-thread-view-lru"; import type { RuntimeId } from "@/shared/runtime"; import { createThreadStore, type ThreadStore, + useThreadStore, + useThreadStoreApi, } from "../../../../packages/ui/src/components/thread-playground/stores"; import { useThreadPlaygroundEvents, @@ -346,6 +375,156 @@ function _StoreEventBridge({ return null; } +function _ThreadSessionProbe({ + onStore, + onUnmount, +}: { + onStore(store: ThreadStore): void; + onUnmount(): void; +}) { + const store = useThreadStoreApi(); + useEffect(() => { + onStore(store); + return onUnmount; + }, [onStore, onUnmount, store]); + return null; +} + +function _ThreadViewProbe({ + onMessageCount, + onUnmount, +}: { + onMessageCount(count: number): void; + onUnmount(): void; +}) { + const count = useThreadStore( + (state) => state.thread.context?.messages?.length ?? 0 + ); + useEffect(() => { + onMessageCount(count); + }, [count, onMessageCount]); + useEffect(() => onUnmount, [onUnmount]); + return null; +} + +function _ThreadSessionHarness({ + host, + initialValue, + onMessageCount, + onSessionStore, + onSessionUnmount, + onViewUnmount, + showView, + transport, +}: { + host: HostServices; + initialValue: Thread; + onMessageCount(count: number): void; + onSessionStore(store: ThreadStore): void; + onSessionUnmount(): void; + onViewUnmount(): void; + showView: boolean; + transport?: AgentTransport; +}) { + return ( + + + <_ThreadSessionProbe + onStore={onSessionStore} + onUnmount={onSessionUnmount} + /> + {showView ? ( + <_ThreadViewProbe + onMessageCount={onMessageCount} + onUnmount={onViewUnmount} + /> + ) : null} + + + ); +} + +function _EditorCommitProbe({ + editorId, + events, +}: { + editorId: string; + events: string[]; +}) { + const commit = useCallback(() => { + events.push(`commit:${editorId}`); + }, [editorId, events]); + useRegisterEditorCommit(commit); + return null; +} + +function _CommitScopeView({ + events, + handles, + paneId, +}: { + events: string[]; + handles: Map; + paneId: string; +}) { + const handleReady = useCallback( + (handle: EditorCommitScopeHandle | null) => { + if (handle) handles.set(paneId, handle); + else handles.delete(paneId); + }, + [handles, paneId] + ); + useEffect( + () => () => { + events.push(`unmount:${paneId}`); + }, + [events, paneId] + ); + return ( + + <_EditorCommitProbe editorId={`${paneId}:message`} events={events} /> + <_EditorCommitProbe editorId={`${paneId}:tool-result`} events={events} /> + + ); +} + +function _ThreadViewLruHarness({ + activeId, + capacity, + events, + tabs, +}: { + activeId: string; + capacity: number; + events: string[]; + tabs: AppTab[]; +}) { + const handlesRef = useRef(new Map()); + const commitPane = useCallback((paneId: string) => { + handlesRef.current.get(paneId)?.commitAll(); + }, []); + const retained = useThreadViewLru({ + tabs, + activeId, + capacity, + commitPane, + }); + return tabs.map((tab) => + tab.type === "thread" && retained.has(tab.paneId) ? ( + <_CommitScopeView + key={tab.paneId} + events={events} + handles={handlesRef.current} + paneId={tab.paneId} + /> + ) : null + ); +} + describe("WorkspaceModelScope", () => { test("runtime changes preserve the mounted workspace identity", async () => { const clients = new Map([ @@ -410,6 +589,199 @@ describe("WorkspaceModelScope", () => { }); }); +describe("ThreadPlayground Session/View lifecycle", () => { + test("releasing and remounting a view preserves the exact session store", async () => { + const modelProvider = { + id: "openai", + name: "OpenAI", + profiles: [{ id: "default", name: "Default" }], + models: [ + { + id: "test-model", + name: "Test Model", + api: "openai-responses", + provider: "openai", + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + ], + } as ModelProviderGroup; + const client: ModelClient = { + ..._client(), + availableModels: async () => [modelProvider], + }; + const createClient = () => client; + const streamStarted = _deferred(); + const releaseStream = _deferred(); + const transport: AgentTransport = async function* () { + streamStarted.resolve(); + await releaseStream.promise; + yield* [ + _event({ + type: "message_start", + message: { role: "assistant" }, + }), + _event({ + type: "message_update", + assistantMessageEvent: { type: "text_start", contentIndex: 0 }, + }), + _event({ + type: "message_update", + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "Completed while hidden", + }, + }), + _event({ + type: "message_end", + message: { role: "assistant" }, + }), + ]; + }; + const host = { + executeTool: null, + files: {}, + skills: {}, + } as HostServices; + const initialValue: Thread = { + model: { provider: "openai", id: "test-model" }, + context: { + messages: [ + { + id: "user-1", + role: "user", + content: [{ type: "text", text: "Run" }], + }, + ], + }, + }; + let sessionStore: ThreadStore | null = null; + let firstStore: ThreadStore | null = null; + let sessionUnmounts = 0; + let viewUnmounts = 0; + const messageCounts: number[] = []; + const onSessionStore = (store: ThreadStore) => { + sessionStore = store; + firstStore ??= store; + }; + const onSessionUnmount = () => { + sessionUnmounts += 1; + }; + const onViewUnmount = () => { + viewUnmounts += 1; + }; + const onMessageCount = (count: number) => { + messageCounts.push(count); + }; + const render = (showView: boolean) => ( + + <_ThreadSessionHarness + host={host} + initialValue={initialValue} + onMessageCount={onMessageCount} + onSessionStore={onSessionStore} + onSessionUnmount={onSessionUnmount} + onViewUnmount={onViewUnmount} + showView={showView} + transport={transport} + /> + + ); + activeRoot = _createRoot(); + + await act(async () => { + activeRoot?.render(render(true)); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(typeof ThreadPlaygroundView).toBe("function"); + expect(firstStore).not.toBeNull(); + const runningStore = sessionStore as ThreadStore | null; + if (!runningStore) throw new Error("Thread session did not mount"); + + const runPromise = runningStore.getState().run(); + await streamStarted.promise; + + await act(async () => activeRoot?.render(render(false))); + expect({ sessionUnmounts, viewUnmounts }).toEqual({ + sessionUnmounts: 0, + viewUnmounts: 1, + }); + + await act(async () => { + releaseStream.resolve(); + await runPromise; + }); + expect(runningStore.getState().status).toBe("idle"); + + await act(async () => activeRoot?.render(render(true))); + expect(sessionStore).toBe(firstStore); + expect(messageCounts.at(-1)).toBe(2); + expect( + runningStore.getState().thread.context?.messages?.at(-1)?.content + ).toContainEqual({ type: "text", text: "Completed while hidden" }); + expect(sessionUnmounts).toBe(0); + }); +}); + +describe("Thread View LRU draft commits", () => { + const tabs: AppTab[] = ["a", "b"].map((id) => ({ + id: `thread:${id}`, + type: "thread", + path: `/${id}.json`, + runtimeId: "local", + paneId: `pane:${id}`, + })); + + test("commits every registered editor before an evicted view unmounts", async () => { + const events: string[] = []; + const render = (activeId: string) => ( + <_ThreadViewLruHarness + activeId={activeId} + capacity={1} + events={events} + tabs={tabs} + /> + ); + activeRoot = _createRoot(); + + await act(async () => activeRoot?.render(render("thread:a"))); + await act(async () => activeRoot?.render(render("thread:b"))); + + expect(events).toEqual([ + "commit:pane:a:message", + "commit:pane:a:tool-result", + "unmount:pane:a", + ]); + }); + + test("does not request a commit when no view is evicted", async () => { + const events: string[] = []; + const render = (activeId: string) => ( + <_ThreadViewLruHarness + activeId={activeId} + capacity={2} + events={events} + tabs={tabs} + /> + ); + activeRoot = _createRoot(); + + await act(async () => activeRoot?.render(render("thread:a"))); + await act(async () => activeRoot?.render(render("thread:b"))); + + expect(events).toEqual([]); + }); +}); + describe("RuntimePaneHost", () => { test("switching active panes does not rerender an unrelated hidden pane", async () => { const panes: TestPane[] = [ diff --git a/apps/desktop/src/components/code-editor/on-demand-code-editor.test.tsx b/apps/desktop/src/components/code-editor/on-demand-code-editor.test.tsx new file mode 100644 index 00000000..bc6130bc --- /dev/null +++ b/apps/desktop/src/components/code-editor/on-demand-code-editor.test.tsx @@ -0,0 +1,302 @@ +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import type { + CodeEditorHandle, + CodeEditorProps, +} from "@llm-space/ui/components/code-editor"; +import { createRegexHighlightEnhancement } from "@llm-space/ui/components/code-editor"; +import { + EditorCommitScope, + type EditorCommitScopeHandle, +} from "@llm-space/ui/components/code-editor/editor-commit-scope"; +import { + OnDemandCodeEditor, + OnDemandEditorScope, +} from "@llm-space/ui/components/code-editor/on-demand-code-editor"; +import { ThemeProvider } from "@llm-space/ui/components/theme-provider"; +import { + act, + forwardRef, + useImperativeHandle, + useLayoutEffect, + useRef, + type FormEvent, +} from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { + installReactTestDom, + TestElement, + TestEvent, +} from "@/test/react-test-dom"; + +const TEST_DOM = installReactTestDom(); +let root: Root | null = null; +let container: TestElement | null = null; + +const PROMPT_HIGHLIGHTS = [ + createRegexHighlightEnhancement({ + id: "prompt-variable-highlight", + pattern: String.raw`\{\{\s*[A-Za-z_][A-Za-z0-9_]*\s*\}\}`, + className: "cm-prompt-variable", + style: { color: "var(--cm-variable)", fontWeight: "500" }, + priority: 10, + }), + createRegexHighlightEnhancement({ + id: "prompt-template-tag-highlight", + pattern: String.raw`\{%[-+]?[\s\S]*?[-+]?%\}`, + className: "cm-template-tag", + style: { color: "var(--cm-template-tag)", fontWeight: "500" }, + priority: 20, + }), +] as const; + +const FakeFullEditor = forwardRef( + function FakeFullEditor( + { autoFocus, enhancements, onBlur, onChange, value }, + forwardedRef + ) { + const elementRef = useRef(null); + const draftRef = useRef(value); + const commit = () => onChange?.(draftRef.current); + useImperativeHandle(forwardedRef, () => ({ + commit, + getValue: () => draftRef.current, + insertText: (text) => { + draftRef.current += text; + }, + })); + useLayoutEffect(() => { + if (autoFocus) elementRef.current?.focus(); + }, [autoFocus]); + return ( +