From a104c094b588560f955ff52dc7f949cf3c355f94 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 5 Sep 2026 00:34:45 +0900 Subject: feat(layout): plan 01 — tile engine + view registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.svelte | 1129 +++++++-------------------------- src/app/register-views.ts | 79 +++ src/app/view-ports.ts | 54 ++ src/app/views/CacheWarmingPane.svelte | 37 ++ src/app/views/ChatPane.svelte | 91 +++ src/app/views/CompactionPane.svelte | 48 ++ src/app/views/ComposerPane.svelte | 35 + src/app/views/ConcurrencyPane.svelte | 39 ++ src/app/views/ExtensionsPane.svelte | 78 +++ src/app/views/HeartbeatPane.svelte | 56 ++ src/app/views/LspPane.svelte | 24 + src/app/views/McpPane.svelte | 24 + src/app/views/MessageQueuePane.svelte | 40 ++ src/app/views/ModelPane.svelte | 131 ++++ src/app/views/NamedAgentsPane.svelte | 25 + src/app/views/SettingsPane.svelte | 34 + src/app/views/SystemPromptPane.svelte | 21 + src/app/views/TabsPane.svelte | 24 + src/app/views/TasksPane.svelte | 29 + src/app/views/VisionPane.svelte | 45 ++ 20 files changed, 1138 insertions(+), 905 deletions(-) create mode 100644 src/app/register-views.ts create mode 100644 src/app/view-ports.ts create mode 100644 src/app/views/CacheWarmingPane.svelte create mode 100644 src/app/views/ChatPane.svelte create mode 100644 src/app/views/CompactionPane.svelte create mode 100644 src/app/views/ComposerPane.svelte create mode 100644 src/app/views/ConcurrencyPane.svelte create mode 100644 src/app/views/ExtensionsPane.svelte create mode 100644 src/app/views/HeartbeatPane.svelte create mode 100644 src/app/views/LspPane.svelte create mode 100644 src/app/views/McpPane.svelte create mode 100644 src/app/views/MessageQueuePane.svelte create mode 100644 src/app/views/ModelPane.svelte create mode 100644 src/app/views/NamedAgentsPane.svelte create mode 100644 src/app/views/SettingsPane.svelte create mode 100644 src/app/views/SystemPromptPane.svelte create mode 100644 src/app/views/TabsPane.svelte create mode 100644 src/app/views/TasksPane.svelte create mode 100644 src/app/views/VisionPane.svelte (limited to 'src/app') diff --git a/src/app/App.svelte b/src/app/App.svelte index f2d7e9c..b7eb783 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,919 +1,238 @@ - -
- -
- -
- - {topBarTitle} - - { - e.preventDefault(); - onNavigate("/"); - }} - > - - - - build: {__APP_VERSION__} - - -
- - {#if store.lastError} - - {/if} - - {#if store.activeChat.error} - - {/if} - -
-
-
- {#key store.activeConversationId} - - {/key} -
-
- {#if store.activeChat.chunks.length === 0} - - {/if} - smartScroll.resume()} /> -
- - {#if hasQueuedMessages && messageQueueSpec !== null} - -
- -
- {/if} - - -
- - - + - - {#if sidebarOpen} - -
(sidebarOpen = false)} - onkeydown={(e) => { - if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false; - }} - >
- {/if} +
+ +
+ + {topBarTitle} + + { + e.preventDefault(); + handleInvokeHome(); + }} + > + + + + build: {__APP_VERSION__} + + {#if collapseKey !== null} + + {/if} +
+ + {#if store.lastError} + + {/if} + + {#if store.activeChat.error} + + {/if} + + +
+ +
{#if store.fatalError} - store.clearFatalError()} /> + store.clearFatalError()} /> {/if} {#if systemPromptModalOpen} - (systemPromptModalOpen = false)} - /> + store.loadSystemPrompt()} + savePrompt={(template) => store.setSystemPrompt(template)} + loadVariables={() => store.loadSystemPromptVariables()} + onClose={() => (systemPromptModalOpen = false)} + /> {/if} {#if heartbeatRun !== null} - - {#key heartbeatRun.id} - (heartbeatRun = null)} - apiBaseUrl={store.httpBase} - /> - {/key} + + {#key heartbeatRun.id} + store.watchConversation(conversationId)} + closeChat={(conversationId) => store.unwatchConversation(conversationId)} + stopRun={(runId) => store.stopHeartbeatRun(runId)} + onClose={() => (heartbeatRun = null)} + apiBaseUrl={store.httpBase} + /> + {/key} {/if} - -{#snippet viewContent(kind: string)} - {#if kind === "tabs"} - - {#key store.activeWorkspaceId} - store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> - {/key} - {:else if kind === "model"} -
- - - {#key store.currentConversationId} - - - - {/key} -
- {:else if kind === "named-agents"} - - - {:else if kind === "lsp"} - - {#key store.currentConversationId} - - {/key} - {:else if kind === "mcp"} - - {#key store.currentConversationId} - - {/key} - {:else if kind === "extensions"} -
-

Frontend modules

- - -
-

Surfaces

- {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} - - {/each} -
- {:else if kind === "cache-warming"} - - {#key store.activeConversationId} - - {/key} - {:else if kind === "tasks"} - - {#key store.activeConversationId} - - {/key} - {:else if kind === "compaction"} - - {#key store.currentConversationId} - - {/key} - {:else if kind === "vision"} - - - {:else if kind === "system-prompt"} - -
-

- Edit the global system prompt template with variable placeholders. Opens a full-page editor. -

- -
- {:else if kind === "settings"} - -
- -
- {:else if kind === "heartbeat"} - - (heartbeatRun = run)} - /> - {:else if kind === "concurrency"} - - - {/if} -{/snippet} diff --git a/src/app/register-views.ts b/src/app/register-views.ts new file mode 100644 index 0000000..5b462fe --- /dev/null +++ b/src/app/register-views.ts @@ -0,0 +1,79 @@ +/** + * Built-in view registration — the composition root's view vocabulary. + * + * One thin wrapper component per existing view (src/app/views/*.svelte), each + * closing over exactly the props/ports App.svelte's `viewContent` snippet used + * to pass — INCLUDING each view's `{#key}` remount semantics (moved INSIDE the + * wrappers). Registry ids match the old `viewKinds` ids exactly: no + * user-facing renaming, no persistence-key changes. + * + * `chat` / `composer` / `message-queue` are CONTENT views (decision 5): they + * register with `panelEligible: false` so they never appear in the panel + * dropdowns. All 14 sidebar views keep the eligible default. + * + * Imported ONCE by App.svelte's module script — before any tile renders. + */ + +import type { Component } from "svelte"; +import { registerView, type ViewComponent, type ViewRegistration } from "../features/layout"; +import type { AppViewPorts } from "./view-ports"; +import CacheWarmingPane from "./views/CacheWarmingPane.svelte"; +import ChatPane from "./views/ChatPane.svelte"; +import CompactionPane from "./views/CompactionPane.svelte"; +import ComposerPane from "./views/ComposerPane.svelte"; +import ConcurrencyPane from "./views/ConcurrencyPane.svelte"; +import ExtensionsPane from "./views/ExtensionsPane.svelte"; +import HeartbeatPane from "./views/HeartbeatPane.svelte"; +import LspPane from "./views/LspPane.svelte"; +import McpPane from "./views/McpPane.svelte"; +import MessageQueuePane from "./views/MessageQueuePane.svelte"; +import ModelPane from "./views/ModelPane.svelte"; +import NamedAgentsPane from "./views/NamedAgentsPane.svelte"; +import SettingsPane from "./views/SettingsPane.svelte"; +import SystemPromptPane from "./views/SystemPromptPane.svelte"; +import TabsPane from "./views/TabsPane.svelte"; +import TasksPane from "./views/TasksPane.svelte"; +import VisionPane from "./views/VisionPane.svelte"; + +/** + * Every wrapper takes `{ ports: AppViewPorts }` and TileLeaf supplies it — but + * the registry is a HETEROGENEOUS bag of components (generic `Component`), so + * the composition root bridges each wrapper's concrete props type to the + * registry's opaque one. ONE controlled cast at this seam (the renderer always + * passes the port bag; the wrappers re-narrow it) instead of `any` in the + * contract. + */ +type WrapperComponent = Component<{ ports: AppViewPorts }>; +function asView(component: WrapperComponent): ViewComponent { + return component as unknown as ViewComponent; +} + +export function registerBuiltinViews(): void { + const views: readonly ViewRegistration[] = [ + // --- sidebar views (panel-eligible by default — the 14 old viewKinds) + { id: "tabs", label: "Tabs", component: asView(TabsPane) }, + { id: "model", label: "Model", component: asView(ModelPane) }, + { id: "named-agents", label: "Named Agents", component: asView(NamedAgentsPane) }, + { id: "lsp", label: "Language Servers", component: asView(LspPane) }, + { id: "mcp", label: "MCP Servers", component: asView(McpPane) }, + { id: "extensions", label: "Extensions", component: asView(ExtensionsPane) }, + { id: "cache-warming", label: "Cache Warming", component: asView(CacheWarmingPane) }, + { id: "tasks", label: "Tasks", component: asView(TasksPane) }, + { id: "compaction", label: "Compaction", component: asView(CompactionPane) }, + { id: "vision", label: "Vision", component: asView(VisionPane) }, + { id: "heartbeat", label: "Heartbeat", component: asView(HeartbeatPane) }, + { id: "concurrency", label: "Concurrency", component: asView(ConcurrencyPane) }, + { id: "system-prompt", label: "System Prompt", component: asView(SystemPromptPane) }, + { id: "settings", label: "Settings", component: asView(SettingsPane) }, + // --- content views (never in the panel dropdowns — decision 5) + { id: "chat", label: "Chat", component: asView(ChatPane), panelEligible: false }, + { id: "composer", label: "Composer", component: asView(ComposerPane), panelEligible: false }, + { + id: "message-queue", + label: "Message Queue", + component: asView(MessageQueuePane), + panelEligible: false, + }, + ]; + for (const view of views) registerView(view); +} diff --git a/src/app/view-ports.ts b/src/app/view-ports.ts new file mode 100644 index 0000000..577fe11 --- /dev/null +++ b/src/app/view-ports.ts @@ -0,0 +1,54 @@ +/** + * The port bag the composition root hands to every registered view wrapper. + * + * This is the ONLY shape of app state the wrappers may touch: the app store + * plus the two chrome ports that live outside the tree (the system-prompt + * builder modal and the heartbeat run modal). Keeping it here is what makes + * `register-views.ts` possible — the wrappers take `{ ports }` and close over + * exactly what `viewContent` used to pass in App.svelte. + */ + +import { tick } from "svelte"; +import type { HeartbeatRunView } from "../features/heartbeat"; +import type { AppStore } from "./store.svelte"; + +/** + * The chat transcript's live scroll element, shared with views that must + * preserve the reader's viewport across a prepend (today: the settings view's + * chat-limit save — the same mechanism as "show earlier"). The chat pane writes + * it on mount via an effect; nothing else may. + */ +export interface ChatViewportBridge { + el: HTMLElement | null; +} + +export interface AppViewPorts { + readonly store: AppStore; + readonly chatViewport: ChatViewportBridge; + /** Chrome: open the system-prompt builder modal (app shell state, not a view). */ + readonly openSystemPromptBuilder: () => void; + /** Chrome: open the heartbeat run modal for a run (app shell state, not a view). */ + readonly openHeartbeatRun: (run: HeartbeatRunView) => void; +} + +/** + * Run an async action that PREPENDS transcript content while preserving the + * reader's viewport position: prepended content grows scrollHeight, so shift + * scrollTop by the growth (the manual analogue of CSS scroll anchoring, which + * not every engine applies here). Used by "show earlier" AND the chat-limit + * save (which refills older history on a raise). + */ +export async function preserveViewportAcross( + el: HTMLElement | null | undefined, + run: () => Promise, +): Promise { + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + const result = await run(); + await tick(); + if (el) { + const delta = el.scrollHeight - prevHeight; + if (delta > 0) el.scrollTop = prevTop + delta; + } + return result; +} diff --git a/src/app/views/CacheWarmingPane.svelte b/src/app/views/CacheWarmingPane.svelte new file mode 100644 index 0000000..d1d9b43 --- /dev/null +++ b/src/app/views/CacheWarmingPane.svelte @@ -0,0 +1,37 @@ + + +{#key store.activeConversationId} + store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + {warmNow} + /> +{/key} diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte new file mode 100644 index 0000000..1c04b9f --- /dev/null +++ b/src/app/views/ChatPane.svelte @@ -0,0 +1,91 @@ + + +
+
+
+ {#key store.activeConversationId} + + {/key} +
+
+ {#if store.activeChat.chunks.length === 0} + + {/if} + smartScroll.resume()} /> +
diff --git a/src/app/views/CompactionPane.svelte b/src/app/views/CompactionPane.svelte new file mode 100644 index 0000000..ff31f0b --- /dev/null +++ b/src/app/views/CompactionPane.svelte @@ -0,0 +1,48 @@ + + +{#key store.currentConversationId} + +{/key} diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte new file mode 100644 index 0000000..dcd5b47 --- /dev/null +++ b/src/app/views/ComposerPane.svelte @@ -0,0 +1,35 @@ + + + store.send(text, images)} + onQueue={(text) => store.queueMessage(text)} + onStop={() => store.stopGeneration()} + contextSize={store.activeChat.currentContextSize} + contextWindow={store.modelInfo[store.activeModel]?.contextWindow} + status={composerStatus} +/> diff --git a/src/app/views/ConcurrencyPane.svelte b/src/app/views/ConcurrencyPane.svelte new file mode 100644 index 0000000..d04307f --- /dev/null +++ b/src/app/views/ConcurrencyPane.svelte @@ -0,0 +1,39 @@ + + + diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte new file mode 100644 index 0000000..7a2343e --- /dev/null +++ b/src/app/views/ExtensionsPane.svelte @@ -0,0 +1,78 @@ + + +
+

Frontend modules

+
+ +
+

Surfaces

+ {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} + store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + /> + {/each} +
diff --git a/src/app/views/HeartbeatPane.svelte b/src/app/views/HeartbeatPane.svelte new file mode 100644 index 0000000..1bb2e86 --- /dev/null +++ b/src/app/views/HeartbeatPane.svelte @@ -0,0 +1,56 @@ + + + store.loadSystemPromptVariables()} + loadDefaultPrompt={() => store.loadSystemPrompt()} + loadNextRun={loadHeartbeatNextRun} + onOpenRun={(run) => ports.openHeartbeatRun(run)} +/> diff --git a/src/app/views/LspPane.svelte b/src/app/views/LspPane.svelte new file mode 100644 index 0000000..5645ace --- /dev/null +++ b/src/app/views/LspPane.svelte @@ -0,0 +1,24 @@ + + +{#key store.currentConversationId} + +{/key} diff --git a/src/app/views/McpPane.svelte b/src/app/views/McpPane.svelte new file mode 100644 index 0000000..82e139d --- /dev/null +++ b/src/app/views/McpPane.svelte @@ -0,0 +1,24 @@ + + +{#key store.currentConversationId} + +{/key} diff --git a/src/app/views/MessageQueuePane.svelte b/src/app/views/MessageQueuePane.svelte new file mode 100644 index 0000000..46b9a22 --- /dev/null +++ b/src/app/views/MessageQueuePane.svelte @@ -0,0 +1,40 @@ + + +{#if hasQueuedMessages && messageQueueSpec !== null} +
+ store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + onCancelQueuedMessage={(messageId) => store.cancelQueuedMessage(messageId)} + /> +
+{/if} diff --git a/src/app/views/ModelPane.svelte b/src/app/views/ModelPane.svelte new file mode 100644 index 0000000..0344e47 --- /dev/null +++ b/src/app/views/ModelPane.svelte @@ -0,0 +1,131 @@ + + +
+ + {#key store.currentConversationId} + + + + {/key} +
diff --git a/src/app/views/NamedAgentsPane.svelte b/src/app/views/NamedAgentsPane.svelte new file mode 100644 index 0000000..413141a --- /dev/null +++ b/src/app/views/NamedAgentsPane.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte new file mode 100644 index 0000000..67a88ec --- /dev/null +++ b/src/app/views/SettingsPane.svelte @@ -0,0 +1,34 @@ + + +
+ +
diff --git a/src/app/views/SystemPromptPane.svelte b/src/app/views/SystemPromptPane.svelte new file mode 100644 index 0000000..0fb5a11 --- /dev/null +++ b/src/app/views/SystemPromptPane.svelte @@ -0,0 +1,21 @@ + + +
+

+ Edit the global system prompt template with variable placeholders. Opens a full-page editor. +

+ +
diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte new file mode 100644 index 0000000..f87b086 --- /dev/null +++ b/src/app/views/TabsPane.svelte @@ -0,0 +1,24 @@ + + +{#key store.activeWorkspaceId} + store.conversationStatus(id)} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} + /> +{/key} diff --git a/src/app/views/TasksPane.svelte b/src/app/views/TasksPane.svelte new file mode 100644 index 0000000..6a375b3 --- /dev/null +++ b/src/app/views/TasksPane.svelte @@ -0,0 +1,29 @@ + + +{#key store.activeConversationId} + +{/key} diff --git a/src/app/views/VisionPane.svelte b/src/app/views/VisionPane.svelte new file mode 100644 index 0000000..5d53545 --- /dev/null +++ b/src/app/views/VisionPane.svelte @@ -0,0 +1,45 @@ + + + -- cgit v1.2.3