Implementer transfer service med innholdstransfer og lettvekts-triage (oppgave 20.4)

Transfer service bestemmer overføringsmodus basert på verktøy-par:
- innholdstransfer (ny node + source_material edge) for transformasjoner
  (f.eks. chat → editor: chatboble blir kilde for ny artikkel)
- lettvekts-triage (ny edge/plassering) for konteksttillegg
  (f.eks. chat → kanban: noden vises i begge kontekster)

Shift-modifier overstyrer alltid til innholdstransfer (ny node).

Endringer:
- transfer.ts: resolveTransferMode() med verktøy-par-matrise,
  executeTransfer() som kaller API for node/edge-opprettelse
- BlockShell: sender e.shiftKey til onDrop-callback
- Workspace handlePanelDrop: kobler sammen modus-resolving og API-kall
- Docs: oppdatert universell_overfoering.md med implementasjonsstatus

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 08:21:35 +00:00
parent 23aaf7e5a9
commit b5f47daa63
6 changed files with 308 additions and 18 deletions

View file

@ -226,7 +226,39 @@ Sync-workeren persisterer til PG `message_placements`-tabellen.
Ny tabell `message_placement` med reducers for place/remove/move. Ny tabell `message_placement` med reducers for place/remove/move.
## 9. Instruks for Claude Code ## 9. Implementasjonsstatus
### 9.1 Transfer service (`$lib/transfer.ts`)
Sentral `transferService` med:
- **`resolveTransferMode(source, target, shiftKey)`** — bestemmer modus fra verktøy-par.
Bruker en matrise (`DEFAULT_TRANSFER_MODE`) som mapper source→target til default-modus.
Shift-modifier overstyrer alltid til `innholdstransfer`.
- **`executeTransfer(accessToken, payload, intent, shiftKey)`** — utfører overføringen:
- Innholdstransfer: `createNode()``createEdge(source_material)``createEdge(belongs_to)`
- Lettvekts-triage: `createEdge(belongs_to)` med placement-metadata
- **Kompatibilitetssjekker** per verktøy-type (`checkChatCompat`, `checkKanbanCompat`, etc.)
- **`createBlockReceiver(toolType)`** — factory for `BlockReceiver`-implementasjoner
### 9.2 Shift-modifier
- `BlockShell.handleDropEvent()` sender `e.shiftKey` til `onDrop`-callback
- Workspace `handlePanelDrop()` sender shiftKey videre til `resolveTransferMode()`
- Shift = alltid ny node (innholdstransfer), uansett verktøy-par
### 9.3 Default-modus per verktøy-par
| Source → Target | Default-modus | Begrunnelse |
|----------------|---------------|-------------|
| chat → kanban | lettvekts-triage | Noden vises på brettet |
| chat → editor | innholdstransfer | Ny artikkelnode fra chatinnhold |
| chat → calendar | lettvekts-triage | Noden planlegges |
| kanban → calendar | lettvekts-triage | Oppgave planlegges |
| * → editor | innholdstransfer | Artikkelverktøyet skaper nytt innhold |
| * → ai_tool | innholdstransfer | AI skaper nytt innhold |
| * → chat/kanban/calendar/studio | lettvekts-triage | Noden vises i ny kontekst |
## 10. Instruks for Claude Code
- **Innholdstransfer** (ny node) brukes når innholdet transformeres (chat → artikkel) - **Innholdstransfer** (ny node) brukes når innholdet transformeres (chat → artikkel)
- **Lettvekts-triage** (ny edge/plassering) brukes når kontekst legges til (chat → kanban) - **Lettvekts-triage** (ny edge/plassering) brukes når kontekst legges til (chat → kanban)
- `source_material`-edge kobler ny node tilbake til kilden ved innholdstransfer - `source_material`-edge kobler ny node tilbake til kilden ved innholdstransfer
@ -236,3 +268,4 @@ Ny tabell `message_placement` med reducers for place/remove/move.
- Drag-and-drop bruker HTML5 API mellom paneler, pointer events intra-canvas - Drag-and-drop bruker HTML5 API mellom paneler, pointer events intra-canvas
- Hold overføringslogikken i en sentral `transferService` - Hold overføringslogikken i en sentral `transferService`
- Mottaker-interfacet er obligatorisk for alle verktøy-panel-typer - Mottaker-interfacet er obligatorisk for alle verktøy-panel-typer
- Shift-modifier overstyrer alltid til innholdstransfer (ny node)

View file

@ -271,7 +271,8 @@
return; return;
} }
} }
onDrop?.(payload); // Pass shiftKey so transfer service can override mode
onDrop?.(payload, e.shiftKey);
} }
} }

View file

@ -76,6 +76,6 @@ export interface BlockShellEvents {
onFullscreenChange?: (isFullscreen: boolean) => void; onFullscreenChange?: (isFullscreen: boolean) => void;
/** Panel minimize state changed (double-click header to toggle) */ /** Panel minimize state changed (double-click header to toggle) */
onMinimizeChange?: (isMinimized: boolean) => void; onMinimizeChange?: (isMinimized: boolean) => void;
/** Drop received on this panel */ /** Drop received on this panel. shiftKey indicates Shift was held (forces innholdstransfer). */
onDrop?: (payload: DragPayload) => void; onDrop?: (payload: DragPayload, shiftKey: boolean) => void;
} }

View file

@ -1,15 +1,27 @@
/** /**
* Transfer service drag-and-drop compatibility between tool panels. * Transfer service drag-and-drop compatibility and execution between tool panels.
* *
* Centralizes compatibility checking and incompatibility messages * Centralizes compatibility checking, transfer mode resolution, and
* for the universal transfer system (universell overføring). * transfer execution for the universal transfer system (universell overføring).
* *
* Ref: docs/features/universell_overfoering.md * Two modes:
* - **Innholdstransfer**: Creates new node + source_material edge (content transformation)
* - **Lettvekts-triage**: Adds edge/placement to existing node (context addition)
*
* Default mode is determined by the sourcetarget tool pair.
* Shift-modifier overrides to always create a new node (innholdstransfer).
*
* Ref: docs/features/universell_overfoering.md § 1, 3
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise * Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/ */
import { createNode, createEdge, type CreateNodeResponse } from '$lib/api.js';
import type { PlacementIntent } from '$lib/components/blockshell/types.js';
export type ToolType = 'ai_tool' | 'editor' | 'chat' | 'kanban' | 'calendar' | 'studio'; export type ToolType = 'ai_tool' | 'editor' | 'chat' | 'kanban' | 'calendar' | 'studio';
export type TransferMode = 'innholdstransfer' | 'lettvekts-triage';
export interface DragPayload { export interface DragPayload {
nodeId: string; nodeId: string;
nodeKind: string; nodeKind: string;
@ -256,3 +268,226 @@ export function createBlockReceiver(toolType: ToolType): { canReceive: (payload:
} }
}; };
} }
// =============================================================================
// Transfer mode resolution
// Ref: docs/features/universell_overfoering.md § 1.1
// =============================================================================
/**
* Default transfer mode matrix: source target mode.
*
* Innholdstransfer (new node): content is *transformed* for a new context.
* e.g. chat editor: chatboble becomes source material for an article.
*
* Lettvekts-triage (existing node): content *appears* in an additional context.
* e.g. chat kanban: same node gets a board placement.
*
* Rule of thumb: if the target tool creates a *new kind* of content from
* the source, it's innholdstransfer. If it just shows the same node in
* a different view, it's lettvekts-triage.
*/
const DEFAULT_TRANSFER_MODE: Partial<Record<ToolType, Partial<Record<ToolType, TransferMode>>>> = {
chat: {
kanban: 'lettvekts-triage', // Same node appears on board
calendar: 'lettvekts-triage', // Same node gets scheduled
editor: 'innholdstransfer', // New article from chat content
studio: 'lettvekts-triage', // Open audio in studio
ai_tool: 'innholdstransfer', // AI creates new content from source
chat: 'lettvekts-triage', // Cross-chat share
},
kanban: {
chat: 'lettvekts-triage', // Share task in chat
calendar: 'lettvekts-triage', // Schedule existing task
editor: 'innholdstransfer', // Task becomes article source
studio: 'lettvekts-triage', // Open in studio
ai_tool: 'innholdstransfer', // AI processes task
kanban: 'lettvekts-triage', // Cross-board move
},
editor: {
chat: 'lettvekts-triage', // Share article in chat
kanban: 'lettvekts-triage', // Track article as task
calendar: 'lettvekts-triage', // Schedule article
studio: 'lettvekts-triage', // Open media in studio
ai_tool: 'innholdstransfer', // AI revises article
editor: 'innholdstransfer', // Article → new article (fork)
},
calendar: {
chat: 'lettvekts-triage', // Share event in chat
kanban: 'lettvekts-triage', // Track event as task
editor: 'innholdstransfer', // Event becomes article source
studio: 'lettvekts-triage', // Open in studio
ai_tool: 'innholdstransfer', // AI processes event
calendar: 'lettvekts-triage', // Cross-calendar
},
studio: {
chat: 'lettvekts-triage', // Share audio in chat
kanban: 'lettvekts-triage', // Track audio as task
calendar: 'lettvekts-triage', // Schedule audio
editor: 'innholdstransfer', // Audio → article source
ai_tool: 'innholdstransfer', // AI processes audio
studio: 'lettvekts-triage', // Cross-studio
},
ai_tool: {
chat: 'lettvekts-triage', // Share AI result in chat
kanban: 'lettvekts-triage', // Track result as task
calendar: 'lettvekts-triage', // Schedule result
editor: 'innholdstransfer', // AI result → article source
studio: 'lettvekts-triage', // Open in studio
ai_tool: 'innholdstransfer', // Chain AI processing
},
};
/**
* Resolve the transfer mode for a sourcetarget tool pair.
*
* - Default mode comes from the tool-pair matrix above.
* - Shift-modifier forces innholdstransfer (always create new node).
*
* @param source - Source tool type (where content was dragged from)
* @param target - Target tool type (where content was dropped)
* @param shiftKey - Whether Shift was held during drop (forces new node)
* @returns The resolved transfer mode
*/
export function resolveTransferMode(
source: ToolType,
target: ToolType,
shiftKey: boolean = false,
): TransferMode {
// Shift override: always create a new node
if (shiftKey) return 'innholdstransfer';
// Look up default from matrix
const sourceMap = DEFAULT_TRANSFER_MODE[source];
if (sourceMap) {
const mode = sourceMap[target];
if (mode) return mode;
}
// Fallback: if the target creates new content (editor, ai_tool), use innholdstransfer.
// Otherwise default to lettvekts-triage (most common case).
if (target === 'editor' || target === 'ai_tool') return 'innholdstransfer';
return 'lettvekts-triage';
}
// =============================================================================
// Transfer execution
// Ref: docs/features/universell_overfoering.md § 1, 3, 4
// =============================================================================
/** Result of a completed transfer */
export interface TransferResult {
mode: TransferMode;
/** The node that ended up in the target (original for triage, new for innholdstransfer) */
nodeId: string;
/** Edge ID for the source_material edge (only for innholdstransfer) */
sourceEdgeId?: string;
/** Edge ID for the placement/context edge (for lettvekts-triage) */
placementEdgeId?: string;
}
/**
* Execute a transfer: either create a new node with source_material edge
* (innholdstransfer) or add an edge/placement to the existing node
* (lettvekts-triage).
*
* @param accessToken - Auth token for API calls
* @param payload - The drag payload (source node info)
* @param intent - The placement intent from the receiver (target info)
* @param shiftKey - Whether Shift was held (overrides mode to innholdstransfer)
*/
export async function executeTransfer(
accessToken: string,
payload: DragPayload,
intent: PlacementIntent,
shiftKey: boolean = false,
): Promise<TransferResult> {
const mode = resolveTransferMode(payload.sourcePanel, intent.contextType as ToolType, shiftKey);
if (mode === 'innholdstransfer') {
return executeInnholdstransfer(accessToken, payload, intent);
} else {
return executeLettvektsTriage(accessToken, payload, intent);
}
}
/**
* Innholdstransfer: Create a new node and link it back to the source
* with a source_material edge.
*
* Flow:
* 1. Create new content node (inherits title from source, empty content)
* 2. Create source_material edge: new node source node
* 3. If target has a context, create belongs_to edge to context
*/
async function executeInnholdstransfer(
accessToken: string,
payload: DragPayload,
intent: PlacementIntent,
): Promise<TransferResult> {
// 1. Create a new content node in the target context
const nodeResp: CreateNodeResponse = await createNode(accessToken, {
node_kind: 'content',
visibility: 'hidden',
metadata: {
transfer_source: payload.sourcePanel,
transfer_target: intent.contextType,
},
});
// 2. Create source_material edge: new node → original source
const edgeResp = await createEdge(accessToken, {
source_id: nodeResp.node_id,
target_id: payload.nodeId,
edge_type: 'source_material',
metadata: {
context: 'referenced',
excerpt: '', // Will be populated by UI or AI later
},
});
// 3. Create belongs_to edge to target context if contextId is set
let placementEdgeId: string | undefined;
if (intent.contextId) {
const belongsResp = await createEdge(accessToken, {
source_id: nodeResp.node_id,
target_id: intent.contextId,
edge_type: 'belongs_to',
metadata: intent.position ? { placement: intent.position } : {},
});
placementEdgeId = belongsResp.edge_id;
}
return {
mode: 'innholdstransfer',
nodeId: nodeResp.node_id,
sourceEdgeId: edgeResp.edge_id,
placementEdgeId,
};
}
/**
* Lettvekts-triage: Add the existing node to a new context via edge.
*
* Flow:
* 1. Create belongs_to edge from source node to target context
* (with position metadata for kanban column, calendar date, etc.)
*/
async function executeLettvektsTriage(
accessToken: string,
payload: DragPayload,
intent: PlacementIntent,
): Promise<TransferResult> {
const edgeResp = await createEdge(accessToken, {
source_id: payload.nodeId,
target_id: intent.contextId,
edge_type: 'belongs_to',
metadata: intent.position ? { placement: intent.position } : {},
});
return {
mode: 'lettvekts-triage',
nodeId: payload.nodeId,
placementEdgeId: edgeResp.edge_id,
};
}

View file

@ -36,7 +36,7 @@
import MixerTrait from '$lib/components/traits/MixerTrait.svelte'; import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte'; import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import AiToolPanel from '$lib/components/AiToolPanel.svelte'; import AiToolPanel from '$lib/components/AiToolPanel.svelte';
import { createBlockReceiver, type DragPayload } from '$lib/transfer'; import { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer';
import type { BlockReceiver } from '$lib/components/blockshell/types'; import type { BlockReceiver } from '$lib/components/blockshell/types';
const session = $derived($page.data.session as Record<string, unknown> | undefined); const session = $derived($page.data.session as Record<string, unknown> | undefined);
@ -344,14 +344,36 @@
return receiverCache.get(trait); return receiverCache.get(trait);
} }
function handlePanelDrop(trait: string, payload: DragPayload) { function handlePanelDrop(trait: string, payload: DragPayload, shiftKey: boolean = false) {
// Drop handling will be fully implemented in task 20.4 (transfer service).
// For now, log the intent for debugging.
const receiver = getReceiverForTrait(trait); const receiver = getReceiverForTrait(trait);
if (receiver?.receive) { if (!receiver?.receive) {
const intent = receiver.receive(payload); console.warn(`[universell-overføring] Ingen mottaker for ${trait}`);
console.log(`[universell-overføring] ${payload.sourcePanel} → ${trait}:`, intent); return;
} }
const intent = receiver.receive(payload);
const mode = resolveTransferMode(
payload.sourcePanel,
trait as import('$lib/transfer').ToolType,
shiftKey,
);
// Override intent mode with centrally resolved mode
intent.mode = mode;
if (!accessToken) {
console.error('[universell-overføring] Mangler accessToken — kan ikke utføre overføring');
return;
}
console.log(`[universell-overføring] ${payload.sourcePanel} → ${trait} (${mode}${shiftKey ? ', shift' : ''}):`, intent);
executeTransfer(accessToken, payload, intent, shiftKey)
.then((result) => {
console.log(`[universell-overføring] Fullført:`, result);
})
.catch((err) => {
console.error(`[universell-overføring] Feil:`, err);
});
} }
</script> </script>
@ -552,7 +574,7 @@
onResize={(w, h) => handlePanelResize(trait, w, h)} onResize={(w, h) => handlePanelResize(trait, w, h)}
onClose={() => handlePanelClose(trait)} onClose={() => handlePanelClose(trait)}
onMinimizeChange={(m) => handlePanelMinimize(trait, m)} onMinimizeChange={(m) => handlePanelMinimize(trait, m)}
onDrop={(payload) => handlePanelDrop(trait, payload)} onDrop={(payload, shiftKey) => handlePanelDrop(trait, payload, shiftKey)}
> >
{#if knownTraits.has(trait)} {#if knownTraits.has(trait)}
{#if trait === 'editor'} {#if trait === 'editor'}

View file

@ -226,8 +226,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md
- [x] 20.1 message_placements tabell: PG-migrasjon + SpacetimeDB-modul med `place_message`, `remove_placement`, `move_on_canvas` reducers. Synk STDB→PG. Ref: `docs/features/universell_overfoering.md` § 2. - [x] 20.1 message_placements tabell: PG-migrasjon + SpacetimeDB-modul med `place_message`, `remove_placement`, `move_on_canvas` reducers. Synk STDB→PG. Ref: `docs/features/universell_overfoering.md` § 2.
- [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.
- [~] 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.
> Påbegynt: 2026-03-18T08:14
- [ ] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container. - [ ] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container.
- [ ] 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.