Skip to content

Generalize Content-block handling: capability-based API for editable types #123

Description

@DrumRobot

Problem

Two related defects in how the Web UI handles Content shapes and exposes per-message capabilities:

  1. Categorization gap: categorizeMessage in packages/web/src/lib/utils/message.ts originally assumed Content was always ContentItem[], missing the string and single-ContentItem shapes. This part was resolved by PR fix(web): normalize Content shapes before message categorization #166 (normalizeContent helper).
  2. Capability gap (load-bearing concern): per-message capability checks in MessageItem.svelte are ad-hoc derived (isToolResult, hasNonTextBlocks, isEditable) and conflate "which ContentItem.type is this" with "which capability does it support". Result: editable types like tool_result and thinking are blanket-blocked from editing because the assistant-side tool_use pairing concern bled into all non-text shapes. The intent is a type-aware capability API that lets each ContentItem.type opt into edit/delete/copy/export/convert/extract independently.

Origin

CodeRabbit review on PR #97 (6d447a6) flagged scope (1). User testing surfaced scope (2) as the load-bearing intent — categorization alone is "tidy refactoring," but the actual user-facing block is "I cannot edit my tool_result messages."

Scope (a) — Categorize all Content shapes (resolved)

Resolved by PR #166 (merged 2026-06-06 → beta, squash commit bb7f3d6). Not yet synced to main.

Acceptance Criteria for (a)

Scope (b) — Type-aware capability API for editable content (open)

Replace the ad-hoc isEditable derived in MessageItem.svelte with a getCapabilities(content: Content): Capabilities function in packages/web/src/lib/utils/message.ts (or a sibling module). The function returns per-message capability booleans.

Target types (editable side)

ContentItem.type is open (api.ts ContentItem.type: string). The capability matrix covers the types currently observed in session JSONL:

Type Edit Delete Copy Export Convert Extract
text (human/user/assistant)
tool_result (user side) ✅ (with tool_use_id preservation) ✅ (e.g. tool_result → text) ✅ (extract stdout/stderr/content)
thinking (assistant side) ✅ (thinking → text)

Explicitly excluded:

  • tool_use (assistant output) — editing breaks tool_use ↔ tool_result pairing invariant. Delete + copy may still apply.
  • image, document — not currently observed in session JSONL; revisit when they appear.

Capability API shape (proposed)

export interface Capabilities {
  canEdit: boolean
  canDelete: boolean
  canCopy: boolean
  canExport: boolean
  canConvert: boolean
  canExtract: boolean
}

export const getCapabilities = (msg: Message): Capabilities => {
  // Use normalizeContent(msg.message.content) to get ContentItem[].
  // Inspect items[0].type (and items.some(...)) to drive each boolean.
  // tool_use pairing constraint stays inside this function, not bled into UI.
}

MessageItem.svelte consumes getCapabilities(msg) and renders edit/delete/copy/export/convert/extract affordances accordingly. No more ad-hoc isEditable, isToolResult, hasNonTextBlocks in the component.

Acceptance Criteria for (b)

Out of scope

  • tool_use editing (intentional — pairing invariant).
  • image / document content (revisit when they appear in session JSONL).
  • Edit conflict resolution across concurrent sessions (separate concern).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions