Fullfør oppgave 5.3: chat-visning med sanntid via STDB
Chat-visning i frontend som viser noder med belongs_to-edge til en kommunikasjonsnode, sortert på tid, med sanntidsoppdatering via SpacetimeDB. Nye filer: - frontend/src/routes/chat/[id]/+page.svelte — Chat-side som viser meldinger (noder med belongs_to-edge), deltakere, auto-scroll, og avsender-info. Bruker edgeStore.byTarget() for reaktive oppdateringer når nye meldinger kommer via STDB. - frontend/src/lib/components/ChatInput.svelte — Enkel meldings-input med Enter-for-send, auto-resize textarea. Endringer: - frontend/src/lib/api.ts — Lagt til createCommunication()-funksjon for å opprette kommunikasjonsnoder fra frontend. - frontend/src/routes/+page.svelte — Kommunikasjonsnoder i mottaket er nå klikkbare lenker til chat-visningen. "Ny samtale"-knapp. - tasks.md — Oppgave 5.3 markert som ferdig. Arkitektur: Chat-visningen bruker context_id-parameteren i create_node-intensjonen (implementert i 5.2) for automatisk belongs_to-edge. Meldinger hentes reaktivt fra STDB-stores — ingen polling, ingen ekstra API-kall. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9e3f5de2bd
commit
2af06111bb
5 changed files with 384 additions and 34 deletions
|
|
@ -65,3 +65,21 @@ export function createEdge(
|
||||||
): Promise<CreateEdgeResponse> {
|
): Promise<CreateEdgeResponse> {
|
||||||
return post(accessToken, '/intentions/create_edge', data);
|
return post(accessToken, '/intentions/create_edge', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCommunicationRequest {
|
||||||
|
title?: string;
|
||||||
|
participant_ids?: string[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommunicationResponse {
|
||||||
|
node_id: string;
|
||||||
|
edge_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommunication(
|
||||||
|
accessToken: string,
|
||||||
|
data: CreateCommunicationRequest
|
||||||
|
): Promise<CreateCommunicationResponse> {
|
||||||
|
return post(accessToken, '/intentions/create_communication', data);
|
||||||
|
}
|
||||||
|
|
|
||||||
82
frontend/src/lib/components/ChatInput.svelte
Normal file
82
frontend/src/lib/components/ChatInput.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
onsubmit: (content: string) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onsubmit, disabled = false }: Props = $props();
|
||||||
|
|
||||||
|
let content = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||||
|
|
||||||
|
const isEmpty = $derived(!content.trim());
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (isEmpty || submitting || disabled) return;
|
||||||
|
|
||||||
|
submitting = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onsubmit(content.trim());
|
||||||
|
content = '';
|
||||||
|
// Reset textarea height
|
||||||
|
if (textareaEl) textareaEl.style.height = 'auto';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Noe gikk galt';
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
textareaEl?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(e: Event) {
|
||||||
|
const el = e.target as HTMLTextAreaElement;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<textarea
|
||||||
|
bind:this={textareaEl}
|
||||||
|
bind:value={content}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
oninput={autoResize}
|
||||||
|
placeholder="Skriv en melding…"
|
||||||
|
disabled={disabled || submitting}
|
||||||
|
rows={1}
|
||||||
|
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-blue-300 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-300"
|
||||||
|
></textarea>
|
||||||
|
{#if error}
|
||||||
|
<p class="absolute -top-6 left-0 text-xs text-red-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={handleSubmit}
|
||||||
|
disabled={isEmpty || submitting || disabled}
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
aria-label="Send melding"
|
||||||
|
>
|
||||||
|
{#if submitting}
|
||||||
|
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/spacetime';
|
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/spacetime';
|
||||||
import type { Node } from '$lib/spacetime';
|
import type { Node } from '$lib/spacetime';
|
||||||
import NodeEditor from '$lib/components/NodeEditor.svelte';
|
import NodeEditor from '$lib/components/NodeEditor.svelte';
|
||||||
import { createNode, createEdge } from '$lib/api';
|
import { createNode, createEdge, createCommunication } from '$lib/api';
|
||||||
|
|
||||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
const nodeId = $derived(session?.nodeId as string | undefined);
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
||||||
|
|
@ -102,6 +102,19 @@
|
||||||
return edges.filter((e) => !e.system).map((e) => e.edgeType);
|
return edges.filter((e) => !e.system).map((e) => e.edgeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a new communication node and navigate to it */
|
||||||
|
async function handleNewChat() {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
const { node_id } = await createCommunication(accessToken, {
|
||||||
|
title: 'Ny samtale',
|
||||||
|
});
|
||||||
|
window.location.href = `/chat/${node_id}`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Feil ved oppretting av samtale:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Submit a new node via maskinrommet */
|
/** Submit a new node via maskinrommet */
|
||||||
async function handleCreateNode(data: { title: string; content: string; html: string }) {
|
async function handleCreateNode(data: { title: string; content: string; html: string }) {
|
||||||
if (!accessToken) throw new Error('Ikke innlogget');
|
if (!accessToken) throw new Error('Ikke innlogget');
|
||||||
|
|
@ -156,7 +169,17 @@
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="mx-auto max-w-3xl px-4 py-6">
|
<main class="mx-auto max-w-3xl px-4 py-6">
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-800">Mottak</h2>
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
||||||
|
{#if connected && accessToken}
|
||||||
|
<button
|
||||||
|
onclick={handleNewChat}
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Ny samtale
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if connected && accessToken}
|
{#if connected && accessToken}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
|
|
@ -179,6 +202,35 @@
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{#each mottaksnoder as node (node.id)}
|
{#each mottaksnoder as node (node.id)}
|
||||||
{@const vis = nodeVisibility(node, nodeId)}
|
{@const vis = nodeVisibility(node, nodeId)}
|
||||||
|
{#if node.nodeKind === 'communication'}
|
||||||
|
<li class="rounded-lg border border-gray-200 bg-white shadow-sm transition-colors hover:border-blue-300">
|
||||||
|
<a href="/chat/{node.id}" class="block p-4">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="font-medium text-gray-900">
|
||||||
|
{node.title || 'Uten tittel'}
|
||||||
|
<span class="ml-1 text-xs text-blue-500">→ Åpne chat</span>
|
||||||
|
</h3>
|
||||||
|
{#if vis === 'full' && node.content}
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{excerpt(node.content)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||||
|
{kindLabel(node.nodeKind)}
|
||||||
|
</span>
|
||||||
|
{#each edgeTypes(node.id) as et}
|
||||||
|
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
|
||||||
|
{et}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
|
@ -196,21 +248,18 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||||
<span
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||||
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500"
|
|
||||||
>
|
|
||||||
{kindLabel(node.nodeKind)}
|
{kindLabel(node.nodeKind)}
|
||||||
</span>
|
</span>
|
||||||
{#each edgeTypes(node.id) as et}
|
{#each edgeTypes(node.id) as et}
|
||||||
<span
|
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
|
||||||
class="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600"
|
|
||||||
>
|
|
||||||
{et}
|
{et}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
202
frontend/src/routes/chat/[id]/+page.svelte
Normal file
202
frontend/src/routes/chat/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import { createNode } from '$lib/api';
|
||||||
|
import ChatInput from '$lib/components/ChatInput.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
||||||
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||||
|
const connected = $derived(connectionState.current === 'connected');
|
||||||
|
const communicationId = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
|
/** The communication node itself */
|
||||||
|
const communicationNode = $derived(connected ? nodeStore.get(communicationId) : undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages in this chat: all nodes with a belongs_to edge pointing
|
||||||
|
* to this communication node, sorted by created_at ascending.
|
||||||
|
*
|
||||||
|
* Edge direction: message (source) --belongs_to--> communication (target)
|
||||||
|
* So we look at edges targeting the communication node with type belongs_to.
|
||||||
|
*/
|
||||||
|
const messages = $derived.by(() => {
|
||||||
|
if (!connected || !communicationId) return [];
|
||||||
|
|
||||||
|
// Find all belongs_to edges pointing to this communication node
|
||||||
|
const belongsToEdges = edgeStore.byTarget(communicationId)
|
||||||
|
.filter(e => e.edgeType === 'belongs_to');
|
||||||
|
|
||||||
|
// Resolve source nodes (the messages)
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const edge of belongsToEdges) {
|
||||||
|
const node = nodeStore.get(edge.sourceId);
|
||||||
|
if (node && nodeVisibility(node, nodeId) !== 'hidden') {
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by created_at ascending (oldest first, like a chat)
|
||||||
|
nodes.sort((a, b) => {
|
||||||
|
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
|
||||||
|
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
|
||||||
|
return ta > tb ? 1 : ta < tb ? -1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Participants: nodes with owner/member_of edges to this communication node */
|
||||||
|
const participants = $derived.by(() => {
|
||||||
|
if (!connected || !communicationId) return [];
|
||||||
|
|
||||||
|
const edges = edgeStore.byTarget(communicationId)
|
||||||
|
.filter(e => e.edgeType === 'owner' || e.edgeType === 'member_of');
|
||||||
|
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const edge of edges) {
|
||||||
|
const node = nodeStore.get(edge.sourceId);
|
||||||
|
if (node) nodes.push(node);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new messages arrive
|
||||||
|
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||||
|
let prevMessageCount = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const count = messages.length;
|
||||||
|
if (count > prevMessageCount && messagesContainer) {
|
||||||
|
tick().then(() => {
|
||||||
|
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevMessageCount = count;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Send a message in this communication node */
|
||||||
|
async function handleSendMessage(content: string) {
|
||||||
|
if (!accessToken) throw new Error('Ikke innlogget');
|
||||||
|
|
||||||
|
await createNode(accessToken, {
|
||||||
|
node_kind: 'content',
|
||||||
|
content,
|
||||||
|
visibility: 'hidden',
|
||||||
|
context_id: communicationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format timestamp for display */
|
||||||
|
function formatTime(node: Node): string {
|
||||||
|
if (!node.createdAt?.microsSinceUnixEpoch) return '';
|
||||||
|
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
|
||||||
|
const date = new Date(ms);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
if (isToday) {
|
||||||
|
return date.toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('nb-NO', {
|
||||||
|
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get display name for message sender */
|
||||||
|
function senderName(node: Node): string {
|
||||||
|
if (!node.createdBy) return 'Ukjent';
|
||||||
|
const sender = nodeStore.get(node.createdBy);
|
||||||
|
return sender?.title || sender?.nodeKind || 'Ukjent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if this message is from the current user */
|
||||||
|
function isOwnMessage(node: Node): boolean {
|
||||||
|
return !!nodeId && node.createdBy === nodeId;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen flex-col bg-gray-50">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="shrink-0 border-b border-gray-200 bg-white">
|
||||||
|
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-3">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
aria-label="Tilbake"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h1 class="truncate text-lg font-semibold text-gray-900">
|
||||||
|
{communicationNode?.title || 'Samtale'}
|
||||||
|
</h1>
|
||||||
|
{#if participants.length > 0}
|
||||||
|
<p class="truncate text-xs text-gray-500">
|
||||||
|
{participants.map(p => p.title || 'Ukjent').join(', ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if connected}
|
||||||
|
<span class="text-xs text-green-600">Tilkoblet</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<div
|
||||||
|
bind:this={messagesContainer}
|
||||||
|
class="flex-1 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="mx-auto max-w-3xl px-4 py-4">
|
||||||
|
{#if !connected}
|
||||||
|
<p class="text-center text-sm text-gray-400">Venter på tilkobling…</p>
|
||||||
|
{:else if !communicationNode}
|
||||||
|
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
|
||||||
|
<p class="font-medium">Samtale ikke funnet</p>
|
||||||
|
<p class="mt-1">Kommunikasjonsnoden med ID {communicationId} finnes ikke eller er ikke tilgjengelig.</p>
|
||||||
|
<a href="/" class="mt-2 inline-block text-blue-600 hover:underline">Tilbake til mottak</a>
|
||||||
|
</div>
|
||||||
|
{:else if messages.length === 0}
|
||||||
|
<p class="text-center text-sm text-gray-400">
|
||||||
|
Ingen meldinger ennå. Skriv den første!
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each messages as msg (msg.id)}
|
||||||
|
{@const own = isOwnMessage(msg)}
|
||||||
|
<div class="flex {own ? 'justify-end' : 'justify-start'}">
|
||||||
|
<div class="max-w-[75%] {own ? 'bg-blue-600 text-white' : 'bg-white border border-gray-200 text-gray-900'} rounded-2xl px-4 py-2 shadow-sm">
|
||||||
|
{#if !own}
|
||||||
|
<p class="mb-0.5 text-xs font-medium {own ? 'text-blue-200' : 'text-blue-600'}">
|
||||||
|
{senderName(msg)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-sm whitespace-pre-wrap break-words">
|
||||||
|
{msg.content || ''}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-right text-[10px] {own ? 'text-blue-200' : 'text-gray-400'}">
|
||||||
|
{formatTime(msg)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
{#if connected && accessToken && communicationNode}
|
||||||
|
<div class="shrink-0 border-t border-gray-200 bg-white">
|
||||||
|
<div class="mx-auto max-w-3xl px-4 py-3">
|
||||||
|
<ChatInput onsubmit={handleSendMessage} disabled={!connected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -81,8 +81,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
|
|
||||||
- [x] 5.1 Opprett kommunikasjonsnode: intensjon `create_communication` → node med `node_kind='communication'`, deltaker-edges, metadata (started_at).
|
- [x] 5.1 Opprett kommunikasjonsnode: intensjon `create_communication` → node med `node_kind='communication'`, deltaker-edges, metadata (started_at).
|
||||||
- [x] 5.2 Kontekst-arv: input i kommunikasjonsnode → automatisk `belongs_to`-edge.
|
- [x] 5.2 Kontekst-arv: input i kommunikasjonsnode → automatisk `belongs_to`-edge.
|
||||||
- [~] 5.3 Chat-visning i frontend: noder med `belongs_to`-edge til kommunikasjonsnode, sortert på tid, sanntid via STDB.
|
- [x] 5.3 Chat-visning i frontend: noder med `belongs_to`-edge til kommunikasjonsnode, sortert på tid, sanntid via STDB.
|
||||||
> Påbegynt: 2026-03-17T16:08
|
|
||||||
- [ ] 5.4 Én-til-én chat: opprett kommunikasjonsnode med to deltakere. Full loop: skriv melding → vis i sanntid hos begge.
|
- [ ] 5.4 Én-til-én chat: opprett kommunikasjonsnode med to deltakere. Full loop: skriv melding → vis i sanntid hos begge.
|
||||||
|
|
||||||
## Fase 6: CAS og mediefiler
|
## Fase 6: CAS og mediefiler
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue