summaryrefslogtreecommitdiffhomepage
path: root/src/app/App.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 02:20:51 +0900
committerAdam Malczewski <[email protected]>2026-06-07 02:20:51 +0900
commit5f867c6711ed693aa2a029ae1fb07eb1106ee32c (patch)
tree3d2942b455454d8c4e241b6d3fe22bb3526e7ed8 /src/app/App.svelte
parent529c6a2bb56447fe93796111df3d4cc5a05fdd93 (diff)
downloaddispatch-web-5f867c6711ed693aa2a029ae1fb07eb1106ee32c.tar.gz
dispatch-web-5f867c6711ed693aa2a029ae1fb07eb1106ee32c.zip
Slice 3 wave B: tabbed multi-conversation app + model selector (DaisyUI)
- store.svelte.ts: tabs store over injected localStorage; one chat store per conversation (Map); single WS routes chat.delta/error by conversationId; draft (null active) mints a conversationId and becomes a tab on first send (title from deriveTitle); GET /models catalog; default model flash; close tab = dispose + cache.delete (local forget) + neighbour activation; restore tabs from storage + load() on construct - App.svelte: DaisyUI tab strip (+ / close), model selector, chat, surfaces - AppStore: tabs/activeConversationId/activeChat/models/activeModel + send/selectModel/newDraft/selectTab/closeTab; +localStorage inject opt Verified: svelte-check 0/0, vitest 281 (stable x2), biome clean, build ok.
Diffstat (limited to 'src/app/App.svelte')
-rw-r--r--src/app/App.svelte111
1 files changed, 80 insertions, 31 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 92939c2..811dc75 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import type { InvokeMessage } from "@dispatch/ui-contract";
+ import { ChatView, Composer, ModelSelector } from "../features/chat";
import { SurfaceView } from "../features/surface-host";
- import { ChatView, Composer } from "../features/chat";
import type { AppStore } from "./store.svelte";
let { store }: { store: AppStore } = $props();
@@ -15,56 +15,105 @@
}
function handleSend(text: string) {
- store.chat.send(text);
+ store.send(text);
+ }
+
+ function handleSelectModel(model: string) {
+ store.selectModel(model);
}
</script>
-<main>
- <h1>Dispatch</h1>
+<main class="flex h-screen flex-col">
+ <div class="flex items-center justify-between border-b border-base-300 px-4 py-2">
+ <h1 class="text-lg font-bold">Dispatch</h1>
+ </div>
{#if store.lastError}
- <div role="alert">
+ <div role="alert" class="alert alert-error mx-4 mt-2">
<strong>Error:</strong>
{store.lastError.message}
</div>
{/if}
- {#if store.chat.error}
- <div role="alert">
+ {#if store.activeChat.error}
+ <div role="alert" class="alert alert-warning mx-4 mt-2">
<strong>Chat error:</strong>
- {store.chat.error}
+ {store.activeChat.error}
</div>
{/if}
- <section>
- <h2>Chat</h2>
- <ChatView chunks={store.chat.chunks} />
+ <div class="flex items-center gap-2 border-b border-base-300 px-4">
+ <div class="tabs tabs-border flex-1">
+ {#each store.tabs as tab (tab.conversationId)}
+ <div
+ class="tab"
+ class:tab-active={tab.conversationId === store.activeConversationId}
+ role="tab"
+ tabindex="0"
+ onclick={() => store.selectTab(tab.conversationId)}
+ onkeydown={(e) => { if (e.key === "Enter") store.selectTab(tab.conversationId); }}
+ >
+ <span class="max-w-[120px] truncate">{tab.title}</span>
+ <button
+ class="btn btn-ghost btn-xs ml-1"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ store.closeTab(tab.conversationId);
+ }}
+ >
+ &times;
+ </button>
+ </div>
+ {/each}
+ <button
+ class="tab"
+ class:tab-active={store.activeConversationId === null}
+ onclick={() => store.newDraft()}
+ aria-label="New chat"
+ >
+ +
+ </button>
+ </div>
+ </div>
+
+ <div class="flex flex-1 flex-col overflow-hidden">
+ <div class="flex items-center gap-2 px-4 py-2">
+ <ModelSelector
+ models={store.models}
+ selected={store.activeModel}
+ onSelect={handleSelectModel}
+ />
+ </div>
+
+ <div class="flex-1 overflow-y-auto">
+ <ChatView chunks={store.activeChat.chunks} />
+ </div>
+
<Composer onSend={handleSend} />
- </section>
+ </div>
- <section>
- <h2>Surfaces</h2>
- {#if store.catalog.length === 0}
- <p>No surfaces available</p>
- {:else}
- <ul>
+ {#if store.catalog.length > 0}
+ <section class="border-t border-base-300 p-4">
+ <h2 class="mb-2 text-sm font-semibold">Surfaces</h2>
+ <div class="flex flex-wrap gap-2">
{#each store.catalog as entry (entry.id)}
- <li>
- <button
- aria-current={entry.id === store.selectedId ? "true" : undefined}
- onclick={() => handleSelect(entry.id)}
- >
- {entry.title}
- <span>({entry.region})</span>
- </button>
- </li>
+ <button
+ class="btn btn-sm"
+ class:btn-active={entry.id === store.selectedId}
+ aria-current={entry.id === store.selectedId ? "true" : undefined}
+ onclick={() => handleSelect(entry.id)}
+ >
+ {entry.title}
+ <span class="text-xs opacity-60">({entry.region})</span>
+ </button>
{/each}
- </ul>
- {/if}
- </section>
+ </div>
+ </section>
+ {/if}
{#if store.selectedSpec}
- <section>
+ <section class="border-t border-base-300 p-4">
<SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} />
</section>
{/if}