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:
parent
26170c193c
commit
9484f831ce
5 changed files with 625 additions and 39 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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` § 4–5.
|
- [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` § 4–5.
|
||||||
- [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`.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue