feat(chat): add workbench detail overlay with streaming AI

Register a detail view for the chat module so clicking a conversation
in the workbench opens an inline overlay with the full message thread
and input area. Reuses the shared sendAndStream() completion service.

- ListView: decrypt conversations + messages, add "Neuer Chat" button,
  click opens detail overlay with sibling navigation, context menu
- DetailView: message bubbles, streaming indicator, auto-scroll,
  Enter to send / Shift+Enter for newline
- App registry: add detail view loader + paramKey 'conversationId'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 17:52:17 +02:00
parent 6cc40242e9
commit fa099145eb
3 changed files with 458 additions and 27 deletions

View file

@ -449,7 +449,10 @@ registerApp({
icon: ChatCircle,
views: {
list: { load: () => import('$lib/modules/chat/ListView.svelte') },
detail: { load: () => import('$lib/modules/chat/views/DetailView.svelte') },
},
collection: 'conversations',
paramKey: 'conversationId',
});
registerApp({

View file

@ -1,22 +1,34 @@
<!--
Chat — Workbench ListView
Recent conversations list.
Recent conversations with click-to-open detail overlay + new chat button.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import type { LocalConversation, LocalMessage } from './types';
import { Plus, PushPin, Trash } from '@mana/shared-icons';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { conversationsStore } from './stores/conversations.svelte';
import type { LocalConversation, LocalMessage, Conversation } from './types';
import { toConversation } from './queries';
import type { ViewProps } from '$lib/app-registry';
let { navigate }: ViewProps = $props();
const conversationsQuery = useLiveQueryWithDefault(async () => {
const convs = await db.table<LocalConversation>('conversations').toArray();
return convs.filter((c) => !c.deletedAt && !c.isArchived);
}, [] as LocalConversation[]);
const visible = convs.filter((c) => !c.deletedAt && !c.isArchived);
const decrypted = await decryptRecords('conversations', visible);
return decrypted.map(toConversation);
}, [] as Conversation[]);
const lastMessagesQuery = useLiveQueryWithDefault(async () => {
const msgs = await db.table<LocalMessage>('messages').toArray();
const decrypted = await decryptRecords('messages', msgs);
const map = new Map<string, LocalMessage>();
for (const msg of msgs) {
for (const msg of decrypted) {
if (msg.deletedAt) continue;
const existing = map.get(msg.conversationId);
if (!existing || (msg.createdAt ?? '') > (existing.createdAt ?? '')) {
@ -36,29 +48,175 @@
function truncate(text: string, max = 60): string {
return text.length > max ? text.slice(0, max) + '...' : text;
}
function openConversation(id: string) {
navigate('detail', {
conversationId: id,
_siblingIds: sorted.map((c) => c.id),
_siblingKey: 'conversationId',
});
}
async function handleNewChat() {
const conv = await conversationsStore.create({});
openConversation(conv.id);
}
const ctxMenu = useItemContextMenu<Conversation>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.state.target
? [
{
id: 'pin',
label: ctxMenu.state.target.isPinned ? 'Loslösen' : 'Anpinnen',
icon: PushPin,
action: () => {
const t = ctxMenu.state.target;
if (t) t.isPinned ? conversationsStore.unpin(t.id) : conversationsStore.pin(t.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
const t = ctxMenu.state.target;
if (t) conversationsStore.delete(t.id);
},
},
]
: []
);
</script>
<BaseListView items={sorted} getKey={(c) => c.id} emptyTitle="Keine Unterhaltungen">
{#snippet header()}
<span>{conversations.length} Unterhaltungen</span>
{/snippet}
<div class="chat-list-view">
<button class="new-chat-btn" onclick={handleNewChat}>
<Plus size={14} />
<span>Neuer Chat</span>
</button>
{#snippet item(conv)}
{@const lastMsg = lastMessages.get(conv.id)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-3 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{conv.title || 'Neue Unterhaltung'}
</p>
{#if conv.isPinned}
<span class="text-[10px] text-white/30">&#128204;</span>
<BaseListView items={sorted} getKey={(c) => c.id} emptyTitle="Keine Unterhaltungen">
{#snippet header()}
<span>{conversations.length} Unterhaltungen</span>
{/snippet}
{#snippet item(conv)}
{@const lastMsg = lastMessages.get(conv.id)}
<button
class="conv-item"
onclick={() => openConversation(conv.id)}
oncontextmenu={(e) => ctxMenu.open(e, conv)}
>
<div class="conv-top">
<span class="conv-title">
{conv.title || 'Neue Unterhaltung'}
</span>
{#if conv.isPinned}
<span class="pin-badge">&#128204;</span>
{/if}
</div>
{#if lastMsg}
<p class="conv-preview">
{lastMsg.sender === 'user' ? 'Du: ' : ''}{truncate(lastMsg.messageText)}
</p>
{/if}
</div>
{#if lastMsg}
<p class="mt-0.5 truncate text-xs text-white/40">
{lastMsg.sender === 'user' ? 'Du: ' : ''}{truncate(lastMsg.messageText)}
</p>
{/if}
</div>
{/snippet}
</BaseListView>
</button>
{/snippet}
</BaseListView>
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={ctxMenu.close}
/>
</div>
<style>
.chat-list-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
margin: 0.5rem 0.5rem 0;
padding: 0.5rem;
border-radius: 0.5rem;
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.new-chat-btn:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.conv-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
text-align: left;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
min-height: 44px;
}
.conv-item:hover {
background: hsl(var(--color-surface-hover));
}
.conv-top {
display: flex;
align-items: center;
gap: 0.375rem;
}
.conv-title {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.pin-badge {
font-size: 0.625rem;
flex-shrink: 0;
}
.conv-preview {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
@media (max-width: 640px) {
.conv-item {
padding: 0.625rem;
}
}
</style>

View file

@ -0,0 +1,270 @@
<!--
Chat — Workbench Detail View (overlay)
Conversation thread with streaming AI responses.
-->
<script lang="ts">
import { useConversationMessages, useAllConversations } from '../queries';
import { conversationsStore } from '../stores/conversations.svelte';
import { sendAndStream } from '../services/completion';
import { PaperPlaneRight } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
const conversationId = $derived((params.conversationId as string) ?? '');
const allConversations = useAllConversations();
const conversation = $derived(allConversations.value.find((c) => c.id === conversationId));
const messagesQuery = $derived(useConversationMessages(conversationId));
let messages = $derived(messagesQuery.value);
let inputText = $state('');
let isSending = $state(false);
let streamingText = $state('');
let messagesEl: HTMLDivElement;
function scrollToBottom() {
if (messagesEl) {
requestAnimationFrame(() => {
messagesEl.scrollTop = messagesEl.scrollHeight;
});
}
}
// Scroll when messages change or streaming updates
$effect(() => {
// Touch reactive dependencies
void messages.length;
void streamingText;
scrollToBottom();
});
async function handleSend() {
const text = inputText.trim();
if (!text || isSending) return;
isSending = true;
streamingText = '';
inputText = '';
try {
await sendAndStream(
{
conversationId,
text,
history: messages,
currentTitle: conversation?.title,
},
(accumulated) => {
streamingText = accumulated;
}
);
} catch (e) {
console.error('Chat send failed:', e);
} finally {
isSending = false;
streamingText = '';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
</script>
<div class="chat-detail">
<!-- Messages -->
<div class="messages" bind:this={messagesEl}>
{#if messages.length === 0 && !isSending}
<div class="empty">Schreibe eine Nachricht</div>
{/if}
{#each messages as msg (msg.id)}
<div class="msg" class:user={msg.sender === 'user'}>
<div class="bubble" class:user-bubble={msg.sender === 'user'}>
<p class="msg-text">{msg.messageText}</p>
<span class="msg-time">
{new Date(msg.createdAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
{/each}
{#if isSending && streamingText}
<div class="msg">
<div class="bubble">
<p class="msg-text">{streamingText}</p>
<span class="msg-time">...</span>
</div>
</div>
{:else if isSending}
<div class="msg">
<div class="bubble">
<span class="thinking-dots">●●●</span>
</div>
</div>
{/if}
</div>
<!-- Input -->
<div class="input-bar">
<textarea
bind:value={inputText}
onkeydown={handleKeydown}
placeholder="Nachricht..."
rows="1"
class="input-area"
></textarea>
<button class="send-btn" onclick={handleSend} disabled={!inputText.trim() || isSending}>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
<style>
.chat-detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* ── Messages ──────────────────────────────── */
.messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.msg {
display: flex;
justify-content: flex-start;
}
.msg.user {
justify-content: flex-end;
}
.bubble {
max-width: 85%;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
font-size: 0.8125rem;
line-height: 1.4;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.user-bubble {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.msg-text {
white-space: pre-wrap;
margin: 0;
word-break: break-word;
}
.msg-time {
display: block;
margin-top: 0.125rem;
font-size: 0.5625rem;
opacity: 0.5;
}
.thinking-dots {
font-size: 0.625rem;
letter-spacing: 0.125rem;
color: hsl(var(--color-muted-foreground));
animation: dots-pulse 1.4s ease-in-out infinite;
}
/* ── Input ─────────────────────────────────── */
.input-bar {
display: flex;
align-items: flex-end;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid hsl(var(--color-border));
}
.input-area {
flex: 1;
resize: none;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
font-family: inherit;
background: transparent;
color: hsl(var(--color-foreground));
outline: none;
}
.input-area:focus {
border-color: hsl(var(--color-primary));
}
.input-area::placeholder {
color: hsl(var(--color-muted-foreground));
}
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
}
.send-btn:hover:not(:disabled) {
opacity: 0.85;
}
.send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@keyframes dots-pulse {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 1;
}
}
@media (max-width: 640px) {
.messages {
padding: 0.5rem;
}
.input-bar {
padding: 0.5rem;
}
}
</style>