Gjør ChatTrait til fullverdig BlockShell-panel med inline chat (oppgave 20.5)

ChatTrait viser nå meldinger, input og taleopptak direkte i panelet i
stedet for bare lenker til /chat/[id]. Ved flere kanaler vises kanalliste
med klikk-navigasjon; ved én kanal åpnes chatten direkte.

Endringer:
- ChatTrait: inline meldingsvisning, ChatInput, AudioPlayer, auto-scroll
- BlockReceiver peker nå til aktiv kanal (ikke bare collection)
- Meldingsbobler er draggable ut til andre paneler
- Responsivt flex-layout som tilpasser seg container-størrelse
- accessToken-prop lagt til (trengs for meldingssending)
- Forelder-sider oppdatert til å sende accessToken
- /chat/[id]-ruten beholdes som frittstående fullside-visning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 08:28:49 +00:00
parent 26170c193c
commit 9484f831ce
5 changed files with 625 additions and 39 deletions

View file

@ -130,9 +130,18 @@ Channels med `config.ttl_days` satt til et tall får sine meldinger automatisk s
- **Tråder:** Komplett trådvisning med datogruppering, autoscroll og visuell skillelinje mellom tråder. - **Tråder:** Komplett trådvisning med datogruppering, autoscroll og visuell skillelinje mellom tråder.
- **Reaksjoner:** Via SpacetimeDB-reducers, synket til PG. - **Reaksjoner:** Via SpacetimeDB-reducers, synket til PG.
- **Meldingskollaps:** Lange meldinger begrenses til 2 linjer med "Vis mer"/"Vis mindre". - **Meldingskollaps:** Lange meldinger begrenses til 2 linjer med "Vis mer"/"Vis mindre".
- **AI-behandling:** Meldinger kan AI-behandles (✨-knapp). Revisjons-toggle viser original vs. AI-versjon. Markdown-rendering for AI-output. - **AI-behandling:** Meldinger kan AI-behandles (✨-knapp, eldre modell). Revisjons-toggle viser original vs. AI-versjon. Markdown-rendering for AI-output. NB: Erstattes av frittstående AI-verktøy på arbeidsflaten — se `docs/features/ai_verktoy.md`.
- **Konvertering:** Meldinger kan opprettes som kanban-kort eller kalenderhendelse (dialog sier "Opprett", ikke "Konverter" — meldingen beholdes i chatten). - **Konvertering:** Meldinger kan opprettes som kanban-kort eller kalenderhendelse (dialog sier "Opprett", ikke "Konverter" — meldingen beholdes i chatten).
### ChatTrait panel (oppgave 20.5, mars 2026)
- **Inline panel:** ChatTrait er nå et fullverdig BlockShell-panel som viser meldinger, input og taleopptak direkte i panelet — ikke bare lenker til `/chat/[id]`.
- **Kanalliste → chatvisning:** Ved flere kanaler vises kanalliste, klikk åpner inline chat. Ved én kanal åpnes chatten direkte.
- **BlockReceiver:** Aksepterer drops fra alle andre paneler (`lettvekts-triage`-modus). Droppet innhold knyttes til aktiv kanal.
- **Drag-out:** Meldingsbobler er draggable — kan dras til andre paneler (kanban, editor, etc.).
- **Responsivt:** Tilpasser seg container-størrelse via flex-layout. Fungerer i både BlockShell-panel (desktop) og mobilfane.
- **Fullskjerm-toggle:** Via BlockShell-wrapperen (forelder-side wrapper ChatTrait i BlockShell).
- **`/chat/[id]`-ruten beholdes** som frittstående fullside-visning for direktelenker og deling.
### Gjenstår ### Gjenstår
- **Vedlegg, TTL** — avventer implementering. - **Vedlegg, TTL** — avventer implementering.
- **Tilgangsfiltrering:** SpacetimeDB-laget må filtrere basert på `node_access`-matrisen. - **Tilgangsfiltrering:** SpacetimeDB-laget må filtrere basert på `node_access`-matrisen.

View file

@ -1,19 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore } from '$lib/spacetime'; import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
import { createNode, casUrl } from '$lib/api';
import { setDragPayload, checkChatCompat, type DragPayload } from '$lib/transfer'; import { setDragPayload, checkChatCompat, type DragPayload } from '$lib/transfer';
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types'; import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
import TraitPanel from './TraitPanel.svelte'; import ChatInput from '$lib/components/ChatInput.svelte';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import { tick } from 'svelte';
interface Props { interface Props {
collection: Node; collection: Node;
config: Record<string, unknown>; config: Record<string, unknown>;
userId?: string; userId?: string;
accessToken?: string;
/** Called when a drop is received on this panel */ /** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void; onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
} }
let { collection, config, userId, onReceiveDrop }: Props = $props(); let { collection, config, userId, accessToken, onReceiveDrop }: Props = $props();
/** /**
* BlockReceiver implementation for Chat. * BlockReceiver implementation for Chat.
@ -27,7 +31,7 @@
receive(payload: DragPayload) { receive(payload: DragPayload) {
const intent: PlacementIntent = { const intent: PlacementIntent = {
mode: 'lettvekts-triage', mode: 'lettvekts-triage',
contextId: collection?.id ?? '', contextId: activeChannelId ?? collection?.id ?? '',
contextType: 'chat', contextType: 'chat',
}; };
onReceiveDrop?.(payload, intent); onReceiveDrop?.(payload, intent);
@ -35,27 +39,215 @@
} }
}; };
/** Communication nodes linked to this collection */ // =========================================================================
// Channel list — communication nodes linked to this collection
// =========================================================================
const chatNodes = $derived.by(() => { const chatNodes = $derived.by(() => {
const nodes: Node[] = []; const nodes: Node[] = [];
const seen = new Set<string>();
for (const edge of edgeStore.byTarget(collection.id)) { for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue; if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId); const node = nodeStore.get(edge.sourceId);
if (node && node.nodeKind === 'communication') { if (node && node.nodeKind === 'communication' && !seen.has(node.id)) {
seen.add(node.id);
nodes.push(node); nodes.push(node);
} }
} }
// Also check source edges (collection --has_channel--> communication)
for (const edge of edgeStore.bySource(collection.id)) { for (const edge of edgeStore.bySource(collection.id)) {
if (edge.edgeType !== 'has_channel') continue; if (edge.edgeType !== 'has_channel') continue;
const node = nodeStore.get(edge.targetId); const node = nodeStore.get(edge.targetId);
if (node && node.nodeKind === 'communication') { if (node && node.nodeKind === 'communication' && !seen.has(node.id)) {
seen.add(node.id);
nodes.push(node); nodes.push(node);
} }
} }
return nodes; return nodes;
}); });
// =========================================================================
// Active channel — auto-select single channel, otherwise user picks
// =========================================================================
let selectedChannelId = $state<string | null>(null);
/** Auto-select when there's exactly one channel */
const activeChannelId = $derived(
chatNodes.length === 1
? chatNodes[0].id
: selectedChannelId
);
const activeChannel = $derived(
activeChannelId ? nodeStore.get(activeChannelId) : undefined
);
function selectChannel(id: string) {
selectedChannelId = id;
}
function backToList() {
selectedChannelId = null;
}
// =========================================================================
// Messages for the active channel
// =========================================================================
const messages = $derived.by(() => {
if (!activeChannelId) return [];
const seen = new Set<string>();
const nodes: Node[] = [];
// Text messages: belongs_to edges pointing to this communication node
for (const edge of edgeStore.byTarget(activeChannelId)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (node && nodeVisibility(node, userId) !== 'hidden' && !seen.has(node.id)) {
seen.add(node.id);
nodes.push(node);
}
}
// Media nodes: has_media edges
for (const edge of edgeStore.bySource(activeChannelId)) {
if (edge.edgeType !== 'has_media') continue;
const node = nodeStore.get(edge.targetId);
if (node && nodeVisibility(node, userId) !== 'hidden' && !seen.has(node.id)) {
seen.add(node.id);
nodes.push(node);
}
}
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 (for channel title)
// =========================================================================
const participants = $derived.by(() => {
if (!activeChannelId) return [];
const edges = edgeStore.byTarget(activeChannelId)
.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;
});
const chatTitle = $derived.by(() => {
if (participants.length === 2 && userId) {
const other = participants.find(p => p.id !== userId);
if (other?.title) return other.title;
}
return activeChannel?.title || 'Samtale';
});
// =========================================================================
// Auto-scroll
// =========================================================================
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 message
// =========================================================================
async function handleSendMessage(content: string) {
if (!accessToken || !activeChannelId) throw new Error('Ikke innlogget');
await createNode(accessToken, {
node_kind: 'content',
content,
visibility: 'hidden',
context_id: activeChannelId,
});
}
// =========================================================================
// Helpers
// =========================================================================
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'
});
}
function senderName(node: Node): string {
if (!node.createdBy) return 'Ukjent';
const sender = nodeStore.get(node.createdBy);
return sender?.title || sender?.nodeKind || 'Ukjent';
}
function isAgentMessage(node: Node): boolean {
if (!node.createdBy) return false;
const sender = nodeStore.get(node.createdBy);
return sender?.nodeKind === 'agent';
}
function isOwnMessage(node: Node): boolean {
return !!userId && node.createdBy === userId;
}
function isAudioNode(node: Node): boolean {
if (node.nodeKind !== 'media') return false;
try {
const meta = JSON.parse(node.metadata ?? '{}');
return typeof meta.mime === 'string' && meta.mime.startsWith('audio/');
} catch { return false; }
}
function audioSrc(node: Node): string {
try {
const meta = JSON.parse(node.metadata ?? '{}');
return casUrl(meta.cas_hash);
} catch { return ''; }
}
function audioDuration(node: Node): number | undefined {
try {
const meta = JSON.parse(node.metadata ?? '{}');
const dms = meta.transcription?.duration_ms;
return dms ? dms / 1000 : undefined;
} catch { return undefined; }
}
function hasSegments(node: Node): boolean {
try {
const meta = JSON.parse(node.metadata ?? '{}');
return (meta.transcription?.segment_count ?? 0) > 0;
} catch { return false; }
}
function handleDragStart(e: DragEvent, node: Node) { function handleDragStart(e: DragEvent, node: Node) {
if (!e.dataTransfer) return; if (!e.dataTransfer) return;
setDragPayload(e.dataTransfer, { setDragPayload(e.dataTransfer, {
@ -66,27 +258,413 @@
} }
</script> </script>
<TraitPanel name="chat" label="Samtaler" icon="💬"> <!--
{#snippet children()} ChatTrait — fullverdig BlockShell-panel for chat.
Viser kanalliste eller inline chat avhengig av kontekst.
Forelder (collection page) wrapper dette i BlockShell.
-->
<div class="chat-trait">
{#if chatNodes.length === 0} {#if chatNodes.length === 0}
<p class="text-sm text-gray-400">Ingen samtaler knyttet til denne samlingen.</p> <!-- No channels -->
{:else} <div class="chat-empty">
<ul class="space-y-2"> <span class="chat-empty-icon">💬</span>
<p>Ingen samtaler knyttet til denne samlingen.</p>
</div>
{:else if !activeChannelId}
<!-- Channel list (multiple channels, none selected) -->
<div class="chat-channel-list">
<div class="chat-channel-header">Velg samtale</div>
{#each chatNodes as node (node.id)} {#each chatNodes as node (node.id)}
<li <button
class="chat-channel-item"
onclick={() => selectChannel(node.id)}
draggable="true" draggable="true"
ondragstart={(e) => handleDragStart(e, node)} ondragstart={(e) => handleDragStart(e, node)}
class="cursor-grab active:cursor-grabbing"
> >
<a <span class="chat-channel-icon">💬</span>
href="/chat/{node.id}" <span class="chat-channel-name">{node.title || 'Samtale'}</span>
class="block rounded border border-gray-100 px-3 py-2 transition-colors hover:border-blue-300 hover:bg-blue-50" </button>
>
<span class="text-sm font-medium text-gray-900">{node.title || 'Samtale'}</span>
</a>
</li>
{/each} {/each}
</ul> </div>
{:else}
<!-- Inline chat view -->
<div class="chat-view">
<!-- Channel sub-header (only if multiple channels, to allow going back) -->
{#if chatNodes.length > 1}
<div class="chat-subheader">
<button class="chat-back-btn" onclick={backToList} aria-label="Tilbake til kanalliste">
<svg class="chat-back-icon" 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>
</button>
<span class="chat-subheader-title">{chatTitle}</span>
{#if participants.length > 0}
<span class="chat-subheader-participants">
{participants.map(p => p.title || 'Ukjent').join(', ')}
</span>
{/if} {/if}
{/snippet} </div>
</TraitPanel> {/if}
<!-- Messages -->
<div class="chat-messages" bind:this={messagesContainer}>
{#if messages.length === 0}
<p class="chat-messages-empty">Ingen meldinger ennå. Skriv den første!</p>
{:else}
{#each messages as msg (msg.id)}
{@const own = isOwnMessage(msg)}
{@const audio = isAudioNode(msg)}
{@const bot = isAgentMessage(msg)}
<div class="chat-msg {own ? 'chat-msg-own' : 'chat-msg-other'}">
<div
class="chat-bubble {own ? (audio ? 'chat-bubble-own-audio' : 'chat-bubble-own') : bot ? 'chat-bubble-bot' : 'chat-bubble-other'}"
draggable="true"
ondragstart={(e) => handleDragStart(e, msg)}
>
{#if !own}
<p class="chat-sender {bot ? 'chat-sender-bot' : ''}">
{#if bot}<span title="AI-agent">🤖 </span>{/if}{senderName(msg)}
</p>
{/if}
{#if audio}
<div class="chat-audio-badge">
<svg class="chat-audio-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19 10v2a7 7 0 01-14 0v-2" />
</svg>
<span>Talenotat</span>
</div>
<AudioPlayer
src={audioSrc(msg)}
duration={audioDuration(msg)}
transcript={!hasSegments(msg) ? (msg.content || undefined) : undefined}
nodeId={hasSegments(msg) ? msg.id : undefined}
accessToken={hasSegments(msg) ? accessToken : undefined}
compact
/>
{:else}
<p class="chat-text">{msg.content || ''}</p>
{/if}
<p class="chat-time {own && !audio ? 'chat-time-own' : ''}">{formatTime(msg)}</p>
</div>
</div>
{/each}
{/if}
</div>
<!-- Input -->
{#if accessToken && activeChannelId}
<div class="chat-input-area">
<ChatInput onsubmit={handleSendMessage} {accessToken} contextId={activeChannelId} />
</div>
{/if}
</div>
{/if}
</div>
<style>
/* ================================================================= */
/* Root — fills BlockShell content area */
/* ================================================================= */
.chat-trait {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ================================================================= */
/* Empty state */
/* ================================================================= */
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
color: #9ca3af;
font-size: 13px;
text-align: center;
gap: 8px;
}
.chat-empty-icon {
font-size: 28px;
}
/* ================================================================= */
/* Channel list (multiple channels) */
/* ================================================================= */
.chat-channel-list {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
}
.chat-channel-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
}
.chat-channel-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #374151;
text-align: left;
transition: background 0.1s;
}
.chat-channel-item:hover {
background: #f3f4f6;
}
.chat-channel-icon {
font-size: 16px;
flex-shrink: 0;
}
.chat-channel-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ================================================================= */
/* Chat view (messages + input) */
/* ================================================================= */
.chat-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Sub-header (back button + channel name) */
.chat-subheader {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid #f3f4f6;
flex-shrink: 0;
min-height: 32px;
}
.chat-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: #6b7280;
flex-shrink: 0;
}
.chat-back-btn:hover {
background: #f3f4f6;
color: #374151;
}
.chat-back-icon {
width: 14px;
height: 14px;
}
.chat-subheader-title {
font-size: 13px;
font-weight: 600;
color: #374151;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-subheader-participants {
font-size: 11px;
color: #9ca3af;
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
/* Messages area */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
}
.chat-messages-empty {
text-align: center;
font-size: 12px;
color: #9ca3af;
padding: 24px 8px;
margin: auto 0;
}
/* Message row */
.chat-msg {
display: flex;
}
.chat-msg-own {
justify-content: flex-end;
}
.chat-msg-other {
justify-content: flex-start;
}
/* Bubbles */
.chat-bubble {
max-width: 85%;
border-radius: 12px;
padding: 6px 10px;
cursor: grab;
}
.chat-bubble:active {
cursor: grabbing;
}
.chat-bubble-own {
background: #2563eb;
color: white;
}
.chat-bubble-own-audio {
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e293b;
}
.chat-bubble-other {
background: white;
border: 1px solid #e5e7eb;
color: #1e293b;
}
.chat-bubble-bot {
background: #fffbeb;
border: 1px solid #fde68a;
color: #1e293b;
}
.chat-sender {
font-size: 11px;
font-weight: 600;
color: #2563eb;
margin-bottom: 1px;
}
.chat-sender-bot {
color: #b45309;
}
.chat-text {
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
margin: 0;
}
.chat-time {
font-size: 10px;
color: #9ca3af;
text-align: right;
margin-top: 2px;
}
.chat-time-own {
color: rgba(255, 255, 255, 0.6);
}
/* Audio badge */
.chat-audio-badge {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
color: #2563eb;
}
.chat-audio-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
}
/* Input area */
.chat-input-area {
flex-shrink: 0;
padding: 6px 8px;
border-top: 1px solid #f3f4f6;
background: #fafbfc;
}
/* ================================================================= */
/* Responsive within bounded container */
/* ================================================================= */
/* Small panels: tighter spacing */
@container (max-width: 320px) {
.chat-bubble {
max-width: 92%;
padding: 5px 8px;
}
.chat-text {
font-size: 12px;
}
.chat-input-area {
padding: 4px 6px;
}
}
/* Mobile viewport fallback */
@media (max-width: 480px) {
.chat-bubble {
max-width: 90%;
}
.chat-messages {
padding: 6px;
}
.chat-input-area {
padding: 6px;
}
}
</style>

View file

@ -334,7 +334,7 @@
{#if trait === 'editor'} {#if trait === 'editor'}
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} /> <EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
{:else if trait === 'chat'} {:else if trait === 'chat'}
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'kanban'} {:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'podcast'} {:else if trait === 'podcast'}
@ -389,7 +389,7 @@
{#if trait === 'editor'} {#if trait === 'editor'}
<EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} /> <EditorTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} collectionMetadata={parsedMetadata} />
{:else if trait === 'chat'} {:else if trait === 'chat'}
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'kanban'} {:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} /> <KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
{:else if trait === 'podcast'} {:else if trait === 'podcast'}

View file

@ -525,7 +525,7 @@
{#if panel.trait === 'editor'} {#if panel.trait === 'editor'}
<EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} /> <EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} />
{:else if panel.trait === 'chat'} {:else if panel.trait === 'chat'}
<ChatTrait collection={undefined} config={{}} userId={nodeId} /> <ChatTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if panel.trait === 'kanban'} {:else if panel.trait === 'kanban'}
<KanbanTrait collection={undefined} config={{}} userId={nodeId} /> <KanbanTrait collection={undefined} config={{}} userId={nodeId} />
{:else if panel.trait === 'calendar'} {:else if panel.trait === 'calendar'}
@ -580,7 +580,7 @@
{#if trait === 'editor'} {#if trait === 'editor'}
<EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} /> <EditorTrait collection={undefined} config={{}} userId={nodeId} {accessToken} collectionMetadata={{}} />
{:else if trait === 'chat'} {:else if trait === 'chat'}
<ChatTrait collection={undefined} config={{}} userId={nodeId} /> <ChatTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if trait === 'kanban'} {:else if trait === 'kanban'}
<KanbanTrait collection={undefined} config={{}} userId={nodeId} /> <KanbanTrait collection={undefined} config={{}} userId={nodeId} />
{:else if trait === 'calendar'} {:else if trait === 'calendar'}

View file

@ -227,8 +227,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md
- [x] 20.2 source_material edge-type: legg til i edge-skjema + maskinrommet-validering. Støtt kontekst-metadata (quoted, summarized, referenced) og excerpt-felt. Ref: `docs/retninger/arbeidsflaten.md` § "source_material-edge". - [x] 20.2 source_material edge-type: legg til i edge-skjema + maskinrommet-validering. Støtt kontekst-metadata (quoted, summarized, referenced) og excerpt-felt. Ref: `docs/retninger/arbeidsflaten.md` § "source_material-edge".
- [x] 20.3 BlockReceiver interface: implementer `canReceive()`, `receive()`, `renderDropZone()` i alle trait-komponenter (Chat, Kanban, Kalender, Editor, Studio). Kompatibilitetsmatrise bestemmer godkjente drops. Ref: `docs/features/universell_overfoering.md` § 45. - [x] 20.3 BlockReceiver interface: implementer `canReceive()`, `receive()`, `renderDropZone()` i alle trait-komponenter (Chat, Kanban, Kalender, Editor, Studio). Kompatibilitetsmatrise bestemmer godkjente drops. Ref: `docs/features/universell_overfoering.md` § 45.
- [x] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3. - [x] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3.
- [~] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container. - [x] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container.
> Påbegynt: 2026-03-18T08:22
- [ ] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt. - [ ] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt.
- [ ] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt. - [ ] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
- [ ] 20.8 Panelrework — Editor/Artikkelverktøy: gjør artikkelverktøy til BlockShell-panel med source_material mottak fra andre paneler. Ref: `docs/features/artikkelverktoy.md`. - [ ] 20.8 Panelrework — Editor/Artikkelverktøy: gjør artikkelverktøy til BlockShell-panel med source_material mottak fra andre paneler. Ref: `docs/features/artikkelverktoy.md`.