diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/App.svelte | 90 | ||||
| -rw-r--r-- | src/app/App.test.ts | 8 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 113 | ||||
| -rw-r--r-- | src/features/smart-scroll/index.ts | 25 | ||||
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.test.ts | 103 | ||||
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.ts | 93 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/ScrollToBottom.svelte | 36 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.svelte.ts | 130 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.test.ts | 172 | ||||
| -rw-r--r-- | src/features/workspace/index.ts | 14 | ||||
| -rw-r--r-- | src/features/workspace/logic/view-model.test.ts | 101 | ||||
| -rw-r--r-- | src/features/workspace/logic/view-model.ts | 130 | ||||
| -rw-r--r-- | src/features/workspace/ui/CwdField.svelte | 96 | ||||
| -rw-r--r-- | src/features/workspace/ui/LspStatusView.svelte | 127 |
14 files changed, 1231 insertions, 7 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index dae6177..daab953 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -9,9 +9,21 @@ import { ChatView, Composer, manifest as chatManifest, ModelSelector } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; + import { + createSmartScrollController, + manifest as smartScrollManifest, + ScrollToBottom, + } from "../features/smart-scroll"; import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host"; import { manifest as tabsManifest, TabBar } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; + import { + CwdField, + type CwdSaveResult, + LspStatusView, + type LspStatusResult, + manifest as workspaceManifest, + } from "../features/workspace"; import type { AppStore } from "./store.svelte"; let { store }: { store: AppStore } = $props(); @@ -26,12 +38,13 @@ // `viewContent` snippet below maps each kind id to its renderer. const viewKinds = [ { id: "model", label: "Model" }, + { id: "lsp", label: "Language Servers" }, { id: "extensions", label: "Extensions" }, { id: "cache-warming", label: "Cache Warming" }, ] as const; - // Default sidebar layout: a Model panel on top, then Extensions, then Cache Warming. - const initialViews = ["model", "extensions", "cache-warming"] as const; + // Default sidebar layout: Model panel on top, then Language Servers, Extensions, Cache Warming. + const initialViews = ["model", "lsp", "extensions", "cache-warming"] as const; // Frontend module list for the "Loaded Modules" view, AGGREGATED from each // feature's public `manifest` export so it can't drift from what's actually @@ -47,8 +60,38 @@ conversationCacheManifest, markdownManifest, cacheWarmingManifest, + workspaceManifest, + smartScrollManifest, ].map((m) => [m.name, m.description] as const); + // Smart-scroll: keep the transcript pinned to the bottom while it streams, + // unless the reader has scrolled up (then show a "scroll to bottom" button). + // One controller owns the chat scroll region; effects below feed it the edges. + const smartScroll = createSmartScrollController(); + let transcriptEl = $state<HTMLElement | undefined>(); + let transcriptContentEl = $state<HTMLElement | undefined>(); + + // Attach/detach the controller to the live scroll element + content (disposed on + // unmount). The content element is observed (ResizeObserver) so the view follows + // height changes that aren't a transcript append. + $effect(() => { + if (!transcriptEl) return; + return smartScroll.attach(transcriptEl, transcriptContentEl); + }); + + // New transcript content streamed in (or messages loaded) → follow the bottom + // while stuck. Reads `chunks.length` so the effect re-runs on every append. + $effect(() => { + void store.activeChat.chunks.length; + smartScroll.contentChanged(); + }); + + // Conversation/tab switch → snap to the bottom of the new transcript. + $effect(() => { + void store.activeConversationId; + smartScroll.reset(); + }); + // Right sidebar: open by default on wide screens (pushes the chat aside), // closed by default on narrow screens (overlays the chat). Initial state is // derived from the viewport width once; the hamburger toggles it thereafter. @@ -79,6 +122,21 @@ } : { ok: false, error: result.error }; } + + // Adapt the store's cwd/LSP results to the workspace feature's ports. + async function saveCwd(cwd: string): Promise<CwdSaveResult | null> { + const result = await store.setCwd(cwd); + if (result === null) return null; + return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error }; + } + + async function loadLspStatus(): Promise<LspStatusResult | null> { + const result = await store.lspStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + } </script> <main class="relative flex h-screen overflow-hidden"> @@ -134,10 +192,14 @@ </div> {/if} - <div class="relative min-w-0 flex-1 overflow-y-auto"> - {#key store.activeConversationId} - <ChatView chunks={store.activeChat.chunks} turnMetrics={store.activeChat.turnMetrics} /> - {/key} + <div class="relative min-h-0 min-w-0 flex-1"> + <div bind:this={transcriptEl} class="h-full overflow-y-auto"> + <div bind:this={transcriptContentEl}> + {#key store.activeConversationId} + <ChatView chunks={store.activeChat.chunks} turnMetrics={store.activeChat.turnMetrics} /> + {/key} + </div> + </div> {#if store.activeChat.chunks.length === 0} <div class="pointer-events-none absolute inset-0 flex items-center justify-center" @@ -146,6 +208,7 @@ <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> </div> {/if} + <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> </div> <Composer onSend={handleSend} /> @@ -185,7 +248,20 @@ {#snippet viewContent(kind: string)} {#if kind === "model"} - <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> + <div class="flex flex-col gap-3"> + <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> + <!-- Keyed on the workspace conversation (active tab OR draft) so the input + re-mounts per conversation — incl. switching between drafts — and can't + bleed across tabs. Editable for a draft too (cwd applies from turn 1). --> + {#key store.currentConversationId} + <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> + {/key} + </div> + {:else if kind === "lsp"} + <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. --> + {#key store.currentConversationId} + <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} /> + {/key} {:else if kind === "extensions"} <section> <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 1534d1c..d22f84b 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -62,6 +62,14 @@ function fakeFetchImpl(): typeof fetch { status: 200, }); } + if (url.endsWith("/cwd")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); + } + if (url.endsWith("/lsp")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { + status: 200, + }); + } return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); }; } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index c242d77..6991530 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -3,7 +3,10 @@ import type { ChatErrorMessage, ConversationHistoryResponse, ConversationMetricsResponse, + CwdResponse, + LspStatusResponse, ModelsResponse, + SetCwdRequest, WarmRequest, WarmResponse, } from "@dispatch/transport-contract"; @@ -38,6 +41,16 @@ export type WarmResult = | { readonly ok: true; readonly response: WarmResponse } | { readonly ok: false; readonly error: string }; +/** Outcome of `PUT /conversations/:id/cwd`. */ +export type CwdResult = + | { readonly ok: true; readonly cwd: string | null } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /conversations/:id/lsp`. */ +export type LspResult = + | { readonly ok: true; readonly response: LspStatusResponse } + | { readonly ok: false; readonly error: string }; + export interface AppStore { readonly tabs: readonly Tab[]; readonly activeConversationId: string | null; @@ -61,6 +74,20 @@ export interface AppStore { * Returns null when no conversation is focused (a draft has nothing to warm). */ warmNow(): Promise<WarmResult | null>; + /** The workspace conversation's persisted working directory, or null when unset. */ + readonly cwd: string | null; + /** The conversation workspace settings target: the active tab, or the pending draft's id. */ + readonly currentConversationId: string; + /** + * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). + * Works for a draft too (its id survives promotion), so the first turn runs in it. + */ + setCwd(cwd: string): Promise<CwdResult | null>; + /** + * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). + * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. + */ + lspStatus(): Promise<LspResult | null>; dispose(): void; } @@ -160,6 +187,24 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let activeChat = $state<ChatStore>(draftStore as ChatStore); + // The active conversation's persisted working directory (per-tab). Seeded from + // the backend on focus change; null for a draft / when unset. + let cwd = $state<string | null>(null); + + /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ + async function refreshCwd(): Promise<void> { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); + if (!res.ok) return; + const data = (await res.json()) as CwdResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) cwd = data.cwd ?? null; + } catch { + // Non-fatal: a cwd fetch failure just leaves the prior value. + } + } + function getActiveChat(): ChatStore { const activeId = tabsStore.activeConversationId; if (activeId === null) { @@ -199,6 +244,16 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return tabsStore.activeConversationId ?? undefined; } + /** + * The conversation id workspace settings (cwd / LSP) target: the active tab, or + * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this + * is NEVER undefined — the draft has a stable client-minted id that survives + * promotion (first send), so a cwd set on a draft carries into the real turn. + */ + function workspaceConversationId(): string { + return tabsStore.activeConversationId ?? draftConversationId; + } + function handleServerMessage(msg: SurfaceServerMessage): void { protocol = applyServerMessage(protocol, msg); // Surfaces are auto-expanded: whenever the catalog changes, subscribe to @@ -298,6 +353,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } refreshActiveChat(); + void refreshCwd(); return { get tabs(): readonly Tab[] { @@ -329,6 +385,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get lastError() { return protocol.lastError; }, + get cwd(): string | null { + return cwd; + }, + get currentConversationId(): string { + return workspaceConversationId(); + }, surface(surfaceId: string): SurfaceSpec | null { return getSurfaceSpec(protocol, surfaceId); @@ -356,6 +418,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // The draft became a real conversation: re-scope conversation-scoped // surfaces (e.g. cache-warming) to its id. syncSubscriptions(); + void refreshCwd(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { @@ -381,6 +444,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftConversationId = nextDraftId; refreshActiveChat(); syncSubscriptions(); + void refreshCwd(); }, selectTab(conversationId: string): void { @@ -391,6 +455,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } refreshActiveChat(); syncSubscriptions(); + void refreshCwd(); }, closeTab(conversationId: string): void { @@ -403,6 +468,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void cache.delete(conversationId); refreshActiveChat(); syncSubscriptions(); + void refreshCwd(); }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { @@ -438,6 +504,53 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; } }, + + async setCwd(value: string): Promise<CwdResult | null> { + const id = workspaceConversationId(); + const body: SetCwdRequest = { cwd: value }; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` }; + } + const data = (await res.json()) as CwdResponse; + const next = data.cwd ?? null; + if (workspaceConversationId() === id) cwd = next; + return { ok: true, cwd: next }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; + } + }, + + async lspStatus(): Promise<LspResult | null> { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` }; + } + // Normalize the untyped body at this network seam so a malformed/partial + // response can never crash the renderer (servers is guaranteed an array). + const data = (await res.json()) as Partial<LspStatusResponse>; + const response: LspStatusResponse = { + conversationId: data.conversationId ?? id, + cwd: data.cwd ?? null, + servers: Array.isArray(data.servers) ? data.servers : [], + }; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "LSP status request failed", + }; + } + }, dispose(): void { for (const store of chatStores.values()) { store.dispose(); diff --git a/src/features/smart-scroll/index.ts b/src/features/smart-scroll/index.ts new file mode 100644 index 0000000..0d30257 --- /dev/null +++ b/src/features/smart-scroll/index.ts @@ -0,0 +1,25 @@ +export type { + ScrollCommand, + ScrollGeometry, + SmartScrollResult, + SmartScrollState, +} from "./logic/smart-scroll"; +export { + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, +} from "./logic/smart-scroll"; +export type { SmartScrollController } from "./ui/controller.svelte"; +export { createSmartScrollController } from "./ui/controller.svelte"; +export { default as ScrollToBottom } from "./ui/ScrollToBottom.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "smart-scroll", + description: + "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up", +} as const; diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts new file mode 100644 index 0000000..fc3e3d1 --- /dev/null +++ b/src/features/smart-scroll/logic/smart-scroll.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollGeometry, +} from "./smart-scroll"; + +// A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom. +const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }; +const nearBottom: ScrollGeometry = { + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, + scrollHeight: 1000, + clientHeight: 100, +}; +const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 }; + +describe("isNearBottom", () => { + it("is true exactly at the bottom", () => { + expect(isNearBottom(atBottom)).toBe(true); + }); + + it("is true within the threshold of the bottom", () => { + expect(isNearBottom(nearBottom)).toBe(true); + }); + + it("is false just beyond the threshold", () => { + expect( + isNearBottom({ + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, + scrollHeight: 1000, + clientHeight: 100, + }), + ).toBe(false); + }); + + it("is false when scrolled well up", () => { + expect(isNearBottom(scrolledUp)).toBe(false); + }); + + it("honours a custom threshold", () => { + const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; + expect(isNearBottom(geom, 50)).toBe(false); + expect(isNearBottom(geom, 150)).toBe(true); + }); +}); + +describe("smart-scroll reducer", () => { + it("starts stuck and hides the button", () => { + const s = createSmartScrollState(); + expect(s.stuck).toBe(true); + }); + + it("onScroll up unsticks and shows the button, with no command", () => { + const r = onScroll(createSmartScrollState(), scrolledUp); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + expect(r.command).toBeNull(); + }); + + it("onScroll back to the bottom re-sticks and hides the button", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onScroll(up, atBottom); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toBeNull(); + }); + + it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { + const r = onContentChange(createSmartScrollState(), atBottom); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.state.stuck).toBe(true); + }); + + it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onContentChange(up, scrolledUp); + expect(r.command).toBeNull(); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + }); + + it("onResume re-sticks and emits an ANIMATED scroll", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onResume(up); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); + }); + + it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onReset(); + void up; + expect(r.state.stuck).toBe(true); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.showButton).toBe(false); + }); +}); diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts new file mode 100644 index 0000000..021b3fe --- /dev/null +++ b/src/features/smart-scroll/logic/smart-scroll.ts @@ -0,0 +1,93 @@ +// Pure smart-scroll reducer — "stick the transcript to the bottom while it grows, +// unless the user has scrolled up". Zero DOM, zero Svelte: it takes scroll +// GEOMETRY snapshots in and returns the next state plus an optional scroll +// COMMAND for the shell to execute. The injected shell (the Svelte action) reads +// the geometry off a real element and runs the commands. + +/** A snapshot of a scroll container's vertical geometry (in CSS pixels). */ +export interface ScrollGeometry { + /** Current scroll offset from the top. */ + readonly scrollTop: number; + /** Total scrollable content height. */ + readonly scrollHeight: number; + /** Visible viewport height. */ + readonly clientHeight: number; +} + +/** Distance (px) from the bottom within which we still consider the view "at bottom". */ +export const NEAR_BOTTOM_THRESHOLD = 64; + +/** True when the viewport is within `threshold` px of the content's bottom edge. */ +export function isNearBottom( + geom: ScrollGeometry, + threshold: number = NEAR_BOTTOM_THRESHOLD, +): boolean { + return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; +} + +/** A scroll the shell should perform on the real element. */ +export interface ScrollCommand { + readonly kind: "scroll-to-bottom"; + /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ + readonly animate: boolean; +} + +export interface SmartScrollState { + /** + * Whether the view is currently following the bottom. While `stuck`, new + * content keeps the view pinned to the bottom; once the user scrolls up it + * goes false and stays false until they return to the bottom (or resume). + */ + readonly stuck: boolean; +} + +/** A reducer step's result: the next state, an optional command, and whether to show the button. */ +export interface SmartScrollResult { + readonly state: SmartScrollState; + readonly command: ScrollCommand | null; + /** Show the "scroll to bottom" affordance exactly when not stuck. */ + readonly showButton: boolean; +} + +/** Initial state — start stuck so the first content snaps to the bottom. */ +export function createSmartScrollState(): SmartScrollState { + return { stuck: true }; +} + +function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult { + return { state, command, showButton: !state.stuck }; +} + +/** + * The user scrolled (or the viewport resized). Re-derive `stuck` purely from + * geometry: near the bottom ⇒ stuck (follow), otherwise unstuck. Never emits a + * command — reacting to the user's own scroll with a scroll would fight them. + */ +export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult { + return result({ stuck: isNearBottom(geom) }, null); +} + +/** + * Content changed (a streamed delta, a new message, history loaded). If we're + * stuck, emit a non-animated scroll to keep up; otherwise leave the user where + * they are. State is unchanged — content growth alone never flips `stuck`. + */ +export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult { + return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); +} + +/** + * The user asked to return to the bottom (clicked the button). Force-stick and + * emit an animated scroll. + */ +export function onResume(_state: SmartScrollState): SmartScrollResult { + return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); +} + +/** + * The transcript context changed entirely (e.g. a conversation/tab switch). + * Reset to stuck and snap (non-animated) to the bottom of the new content. + */ +export function onReset(): SmartScrollResult { + return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); +} diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte new file mode 100644 index 0000000..6fbd326 --- /dev/null +++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + // Thin affordance: a floating "scroll to bottom" button shown while the reader + // has scrolled up. Holds no logic — `show` and `onResume` come from the + // smart-scroll controller. + let { + show, + onResume, + }: { + show: boolean; + onResume: () => void; + } = $props(); +</script> + +<button + type="button" + class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200" + class:opacity-0={!show} + class:pointer-events-none={!show} + class:opacity-100={show} + onclick={onResume} + aria-label="Scroll to bottom" + aria-hidden={!show} + tabindex={show ? 0 : -1} +> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + class="size-4" + aria-hidden="true" + > + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> + </svg> +</button> diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts new file mode 100644 index 0000000..99d53ca --- /dev/null +++ b/src/features/smart-scroll/ui/controller.svelte.ts @@ -0,0 +1,130 @@ +// Injected shell for smart-scroll: binds a real scrollable element to the pure +// reducer (logic/smart-scroll). It owns the reactive `showButton` flag (a thin +// rune wrapper over the reducer state), runs the scroll COMMANDS the reducer +// emits against the element, and listens at the outermost edges (the element's +// `scroll`/`scrollend` events + a ResizeObserver on the content). No ambient +// state: the consumer instantiates ONE controller per scroll region and disposes +// it on unmount. + +import { + createSmartScrollState, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollCommand, + type ScrollGeometry, + type SmartScrollResult, + type SmartScrollState, +} from "../logic/smart-scroll"; + +export interface SmartScrollController { + /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ + readonly showButton: boolean; + /** + * Attach to the scroll container; returns a teardown to call on unmount. + * Pass the inner CONTENT element to also follow height changes that aren't a + * transcript update (async markdown/highlight, image loads, a collapse toggling, + * viewport reflow) via a ResizeObserver. + */ + attach(el: HTMLElement, content?: HTMLElement): () => void; + /** + * Notify that the transcript content changed (a streamed delta / new message). + * While stuck, keeps the view pinned to the bottom. + */ + contentChanged(): void; + /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ + reset(): void; + /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ + resume(): void; +} + +function geometryOf(el: HTMLElement): ScrollGeometry { + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }; +} + +export function createSmartScrollController(): SmartScrollController { + let state: SmartScrollState = createSmartScrollState(); + let showButton = $state(false); + let el: HTMLElement | null = null; + // True while WE drive a programmatic scroll, so the resulting `scroll` event + // doesn't get misread as the user scrolling up. Cleared on `scrollend`. + let selfScrolling = false; + + function run(command: ScrollCommand | null): void { + if (!command || !el) return; + selfScrolling = true; + el.scrollTo({ + top: el.scrollHeight, + behavior: command.animate ? "smooth" : "instant", + }); + } + + function apply(r: SmartScrollResult): void { + state = r.state; + showButton = r.showButton; + run(r.command); + } + + function handleScroll(): void { + if (!el || selfScrolling) return; + apply(onScroll(state, geometryOf(el))); + } + + function handleScrollEnd(): void { + selfScrolling = false; + } + + return { + get showButton(): boolean { + return showButton; + }, + + attach(node: HTMLElement, content?: HTMLElement): () => void { + el = node; + node.addEventListener("scroll", handleScroll, { passive: true }); + node.addEventListener("scrollend", handleScrollEnd); + + // A ResizeObserver keeps the view pinned through height changes that are + // NOT a transcript update — async markdown/syntax-highlight, image loads, a + // collapse toggling, font swaps, viewport reflow — which a content-count + // signal can't see. Observe the CONTENT (it grows) and the container (it + // changes on viewport resize). Routed through `onContentChange`, so it only + // scrolls while stuck and never fights the reader. The `selfScrolling` guard + // (and the fact that scrolling doesn't resize content) prevents any loop. + let ro: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => { + if (!el || selfScrolling) return; + apply(onContentChange(state, geometryOf(el))); + }); + if (content) ro.observe(content); + ro.observe(node); + } + + return () => { + node.removeEventListener("scroll", handleScroll); + node.removeEventListener("scrollend", handleScrollEnd); + ro?.disconnect(); + if (el === node) el = null; + }; + }, + + contentChanged(): void { + if (!el) return; + apply(onContentChange(state, geometryOf(el))); + }, + + reset(): void { + apply(onReset()); + }, + + resume(): void { + apply(onResume(state)); + }, + }; +} diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts new file mode 100644 index 0000000..614f4b0 --- /dev/null +++ b/src/features/smart-scroll/ui/controller.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSmartScrollController } from "./controller.svelte"; + +// A minimal fake of the only DOM surface the controller touches: scroll +// geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend". +// Faking this outermost edge is the sanctioned mock (no internal modules mocked). +function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) { + const listeners = new Map<string, Set<EventListener>>(); + const el = { + scrollTop: 0, + scrollHeight: opts?.scrollHeight ?? 1000, + clientHeight: opts?.clientHeight ?? 100, + scrollTo: vi.fn((arg: ScrollToOptions) => { + // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. + el.scrollTop = (arg.top ?? 0) - 0; + if (arg.behavior !== "smooth") { + fire("scroll"); + fire("scrollend"); + } + }), + addEventListener: (type: string, fn: EventListener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)?.add(fn); + }, + removeEventListener: (type: string, fn: EventListener) => { + listeners.get(type)?.delete(fn); + }, + }; + function fire(type: string): void { + for (const fn of listeners.get(type) ?? []) fn(new Event(type)); + } + // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). + function userScrollTo(top: number): void { + el.scrollTop = top; + fire("scroll"); + } + return { + el: el as unknown as HTMLElement, + scrollTo: el.scrollTo, + fire, + userScrollTo, + listenerCount: () => listeners, + }; +} + +describe("smart-scroll controller", () => { + it("starts with the button hidden", () => { + const c = createSmartScrollController(); + expect(c.showButton).toBe(false); + }); + + it("contentChanged while stuck scrolls to the bottom instantly", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + c.contentChanged(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); + + it("a user scroll up shows the button and stops auto-following", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); // far from the bottom + expect(c.showButton).toBe(true); + + const scrollTo = fake.scrollTo; + scrollTo.mockClear(); + c.contentChanged(); // streaming more content... + expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down + expect(c.showButton).toBe(true); + }); + + it("self-driven scrolls are not misread as the user scrolling up", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + // contentChanged drives an instant scrollTo, whose synthetic scroll event + // must NOT flip us to unstuck (selfScrolling guard). + c.contentChanged(); + expect(c.showButton).toBe(false); + }); + + it("resume re-sticks and smooth-scrolls to the bottom", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); + + c.resume(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "smooth", + }); + expect(c.showButton).toBe(false); + }); + + it("reset snaps to the bottom and hides the button", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); + c.reset(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); + + it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { + const holder: { cb: ResizeObserverCallback | null } = { cb: null }; + const observed: unknown[] = []; + const disconnect = vi.fn(); + class FakeResizeObserver { + constructor(cb: ResizeObserverCallback) { + holder.cb = cb; + } + observe(target: Element): void { + observed.push(target); + } + unobserve(): void {} + disconnect = disconnect; + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + try { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const content = { id: "content" } as unknown as HTMLElement; + const teardown = c.attach(fake.el, content); + + // Observes both the content (it grows) and the scroll container (viewport resize). + expect(observed).toContain(content); + expect(observed).toContain(fake.el); + + // Stuck → a resize keeps us pinned to the bottom. + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); + + // Reader scrolls up → a later resize must NOT yank them down. + fake.userScrollTo(200); + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).not.toHaveBeenCalled(); + + // Teardown disconnects the observer. + teardown(); + expect(disconnect).toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("attach returns a teardown that removes both listeners", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const teardown = c.attach(fake.el); + const before = fake.listenerCount(); + expect(before.get("scroll")?.size).toBe(1); + expect(before.get("scrollend")?.size).toBe(1); + teardown(); + expect(before.get("scroll")?.size).toBe(0); + expect(before.get("scrollend")?.size).toBe(0); + }); +}); diff --git a/src/features/workspace/index.ts b/src/features/workspace/index.ts new file mode 100644 index 0000000..9acf994 --- /dev/null +++ b/src/features/workspace/index.ts @@ -0,0 +1,14 @@ +export type { + CwdSaveResult, + LoadLspStatus, + LspStatusResult, + SaveCwd, +} from "./logic/view-model"; +export { default as CwdField } from "./ui/CwdField.svelte"; +export { default as LspStatusView } from "./ui/LspStatusView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "workspace", + description: "Per-conversation working directory + language-server status", +} as const; diff --git a/src/features/workspace/logic/view-model.test.ts b/src/features/workspace/logic/view-model.test.ts new file mode 100644 index 0000000..a06edeb --- /dev/null +++ b/src/features/workspace/logic/view-model.test.ts @@ -0,0 +1,101 @@ +import type { LspServerInfo } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + cwdChanged, + isSubmittableCwd, + normalizeCwd, + summarizeServers, + viewLspServer, + viewLspServers, +} from "./view-model"; + +const server = (over: Partial<LspServerInfo> = {}): LspServerInfo => ({ + id: "typescript", + name: "TypeScript", + root: "/home/me/project", + extensions: [".ts", ".tsx"], + state: "connected", + ...over, +}); + +describe("cwd helpers", () => { + it("normalizeCwd trims surrounding whitespace", () => { + expect(normalizeCwd(" /a/b ")).toBe("/a/b"); + expect(normalizeCwd("\t/x\n")).toBe("/x"); + }); + + it("isSubmittableCwd is false for empty / whitespace-only", () => { + expect(isSubmittableCwd("")).toBe(false); + expect(isSubmittableCwd(" ")).toBe(false); + expect(isSubmittableCwd("/a")).toBe(true); + }); + + it("cwdChanged: true only when a non-empty trimmed value differs from current", () => { + expect(cwdChanged("/a/b", null)).toBe(true); + expect(cwdChanged("/a/b", "/a/b")).toBe(false); + expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change + expect(cwdChanged("/a/c", "/a/b")).toBe(true); + expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear) + expect(cwdChanged(" ", null)).toBe(false); + }); +}); + +describe("viewLspServer", () => { + it("connected → success badge, not busy, no error", () => { + const v = viewLspServer(server({ state: "connected" })); + expect(v.badge).toBe("success"); + expect(v.statusLabel).toBe("Connected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + expect(v.extensionsLabel).toBe(".ts .tsx"); + }); + + it("starting / not-started → busy (spinner) with warning / neutral badge", () => { + const starting = viewLspServer(server({ state: "starting" })); + expect(starting.badge).toBe("warning"); + expect(starting.busy).toBe(true); + + const notStarted = viewLspServer(server({ state: "not-started" })); + expect(notStarted.badge).toBe("neutral"); + expect(notStarted.busy).toBe(true); + }); + + it("error → error badge + surfaces the reason (with a fallback)", () => { + const withReason = viewLspServer(server({ state: "error", error: "ENOENT" })); + expect(withReason.badge).toBe("error"); + expect(withReason.busy).toBe(false); + expect(withReason.error).toBe("ENOENT"); + + const noReason = viewLspServer(server({ state: "error" })); + expect(noReason.error).toBe("Failed to start"); + }); + + it("viewLspServers maps a list preserving order", () => { + const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); +}); + +describe("summarizeServers", () => { + it("empty list", () => { + expect(summarizeServers([])).toBe("No language servers"); + }); + + it("counts connected / starting / errors", () => { + expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected"); + expect( + summarizeServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "error" }), + ]), + ).toBe("1 connected, 1 error"); + expect( + summarizeServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "starting" }), + server({ id: "c", state: "error" }), + server({ id: "d", state: "error" }), + ]), + ).toBe("1 connected, 1 starting, 2 errors"); + }); +}); diff --git a/src/features/workspace/logic/view-model.ts b/src/features/workspace/logic/view-model.ts new file mode 100644 index 0000000..bc9b30b --- /dev/null +++ b/src/features/workspace/logic/view-model.ts @@ -0,0 +1,130 @@ +import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract"; + +/** + * Pure core for the workspace feature — zero DOM, zero effects, zero Svelte. + * + * The workspace feature exposes a conversation's per-tab working directory (cwd) + * and the live status of the language servers configured for that cwd. This + * module holds the pure logic: cwd normalization/validation, the mapping of a + * backend `LspServerState` to a display badge, and a one-line server summary. + * The effects (the HTTP get/set cwd + get LSP status) are INJECTED via the ports + * below; the composition root implements them. + */ + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +/** Outcome of `PUT /conversations/:id/cwd`; `null` when no real conversation is focused. */ +export type CwdSaveResult = + | { readonly ok: true; readonly cwd: string | null } + | { readonly ok: false; readonly error: string }; + +export type SaveCwd = (cwd: string) => Promise<CwdSaveResult | null>; + +/** Outcome of `GET /conversations/:id/lsp`; `null` when no real conversation is focused. */ +export type LspStatusResult = + | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] } + | { readonly ok: false; readonly error: string }; + +export type LoadLspStatus = () => Promise<LspStatusResult | null>; + +// ── cwd helpers ─────────────────────────────────────────────────────────────── + +/** Trim surrounding whitespace; the backend rejects an empty cwd. */ +export function normalizeCwd(raw: string): string { + return raw.trim(); +} + +/** Whether a typed cwd is submittable (non-empty after trim). */ +export function isSubmittableCwd(raw: string): boolean { + return normalizeCwd(raw).length > 0; +} + +/** + * Whether saving `typed` would change the persisted `current` cwd. A no-op save + * (unchanged, or empty) should be disabled. + */ +export function cwdChanged(typed: string, current: string | null): boolean { + const next = normalizeCwd(typed); + if (next.length === 0) return false; + return next !== (current ?? ""); +} + +// ── LSP server status → display view ────────────────────────────────────────── + +export type Badge = "success" | "warning" | "error" | "neutral"; + +export interface LspServerView { + readonly id: string; + readonly name: string; + readonly root: string; + /** Space-joined extension list, e.g. ".ts .tsx". */ + readonly extensionsLabel: string; + readonly state: LspServerState; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; +} + +/** Map a server's state to a display label + badge severity + busy flag. */ +export function viewLspServer(server: LspServerInfo): LspServerView { + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (server.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "starting": + statusLabel = "Starting…"; + badge = "warning"; + busy = true; + break; + case "not-started": + statusLabel = "Not started"; + badge = "neutral"; + busy = true; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + id: server.id, + name: server.name, + root: server.root, + extensionsLabel: server.extensions.join(" "), + state: server.state, + statusLabel, + badge, + busy, + error: server.state === "error" ? (server.error ?? "Failed to start") : null, + }; +} + +export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] { + return servers.map(viewLspServer); +} + +/** A short one-line summary, e.g. "2 connected" / "1 connected, 1 error". */ +export function summarizeServers(servers: readonly LspServerInfo[]): string { + if (servers.length === 0) return "No language servers"; + let connected = 0; + let errored = 0; + let pending = 0; + for (const s of servers) { + if (s.state === "connected") connected++; + else if (s.state === "error") errored++; + else pending++; + } + const parts: string[] = []; + if (connected > 0) parts.push(`${connected} connected`); + if (pending > 0) parts.push(`${pending} starting`); + if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); + return parts.join(", "); +} diff --git a/src/features/workspace/ui/CwdField.svelte b/src/features/workspace/ui/CwdField.svelte new file mode 100644 index 0000000..bd8b870 --- /dev/null +++ b/src/features/workspace/ui/CwdField.svelte @@ -0,0 +1,96 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model"; + + let { + cwd, + canEdit, + save, + }: { + /** The active conversation's persisted cwd, or null when unset. */ + cwd: string | null; + /** Whether a real conversation is focused (a draft can't persist a cwd yet). */ + canEdit: boolean; + save: SaveCwd; + } = $props(); + + // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading + // the prop directly into initial $state would only capture its first value.) + let value = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + + // Seed the input from the persisted cwd (it loads async). Only reseed while the + // field is untouched, so an in-flight load can't clobber what the user typed. + // Re-mounted per conversation, so there is no cross-tab bleed. + $effect(() => { + const incoming = cwd ?? ""; + untrack(() => { + if (value === lastSeed) value = incoming; + lastSeed = incoming; + }); + }); + + const dirty = $derived(cwdChanged(value, cwd)); + + async function handleSave() { + if (saving || !canEdit || !dirty) return; + saving = true; + error = null; + justSaved = false; + const result = await save(normalizeCwd(value)); + saving = false; + if (result === null) return; + if (result.ok) { + justSaved = true; + } else { + error = result.error; + } + } + + function onInput() { + justSaved = false; + error = null; + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Working directory</span> + <div class="flex items-center gap-2"> + <input + type="text" + class="input input-bordered input-sm w-full font-mono text-xs" + placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"} + bind:value + disabled={!canEdit || saving} + oninput={onInput} + onkeydown={(e) => { + if (e.key === "Enter") handleSave(); + }} + aria-label="Working directory" + /> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={!canEdit || saving || !dirty} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + {#if !canEdit} + <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved && !dirty} + <p class="text-xs text-success">Saved.</p> + {:else} + <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p> + {/if} +</div> diff --git a/src/features/workspace/ui/LspStatusView.svelte b/src/features/workspace/ui/LspStatusView.svelte new file mode 100644 index 0000000..77603a1 --- /dev/null +++ b/src/features/workspace/ui/LspStatusView.svelte @@ -0,0 +1,127 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { + type Badge, + type LoadLspStatus, + type LspServerView, + summarizeServers, + viewLspServers, + } from "../logic/view-model"; + + let { + cwd, + canView, + load, + }: { + /** The active conversation's cwd — the trigger to (re)load when it changes. */ + cwd: string | null; + /** Whether a real conversation is focused. */ + canView: boolean; + load: LoadLspStatus; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + let servers = $state<readonly LspServerView[]>([]); + let loading = $state(false); + let error = $state<string | null>(null); + let loadedCwd = $state<string | null>(null); + let hasLoaded = $state(false); + let summary = $state(""); + + async function refresh() { + if (!canView) return; + loading = true; + error = null; + const result = await load(); + loading = false; + if (result === null) return; + hasLoaded = true; + if (result.ok) { + servers = viewLspServers(result.servers); + summary = summarizeServers(result.servers); + loadedCwd = result.cwd; + } else { + error = result.error; + } + } + + // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET + // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to + // the value we already loaded for. + $effect(() => { + const target = cwd; + const can = canView; + untrack(() => { + if (!can) return; + if (!hasLoaded || target !== loadedCwd) void refresh(); + }); + }); +</script> + +<div class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs opacity-70"> + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + Language servers + {/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!canView || loading} + onclick={() => refresh()} + aria-label="Refresh language server status" + > + {#if loading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + + {#if !canView} + <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if hasLoaded && loadedCwd === null} + <p class="text-xs opacity-60"> + Set a working directory in the Model panel to enable language servers. + </p> + {:else if hasLoaded && servers.length === 0 && !loading} + <p class="text-xs opacity-60">No language servers configured for this directory.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each servers as server (server.id)} + <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center justify-between gap-2"> + <span class="font-medium">{server.name}</span> + <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> + {#if server.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {server.statusLabel} + </span> + </div> + {#if server.extensionsLabel} + <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span> + {/if} + <span class="truncate font-mono text-xs opacity-50" title={server.root}>{server.root}</span> + {#if server.error} + <span class="font-mono text-xs text-error">{server.error}</span> + {/if} + </li> + {/each} + </ul> + {/if} +</div> |
