Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions docs/rendering/legacy-deletion-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,27 @@ blocked until the phone also fed the ledger.
`shadowParity.test.ts` CI diff. `shadow/shadowDiff.ts` KEPT (pure diff util —
bundleCorpus/recordingCorpus still assert ledger output through it).

**STILL ALIVE — the block-level un-collapse slice (next PR):** the view bridge
still COLLAPSES the ledger's block-level semantic rows back into turn-level
items so `SemanticStreamingTurn` can render them. Killing that component (and
with it ghost rule 3, `selectMergedEntries` as Feed's entries source, and
`committedClaudeMessageTurnIds`) requires the ledger to own live turns
block-by-block through the shared registry dispatch — the hardest slice, and
the one that needs live visual verification the turn-grain corpus can't give.
Until then the completeness grep below still returns `SemanticStreamingTurn` /
`selectMergedEntries` / `committedClaudeMessageTurnIds` hits BY DESIGN;
`deriveFeedRenderModel` / `buildSemanticRenderUnits` are already clean.
**DONE — the block-level un-collapse (#491):** the view bridge no longer
collapses; it emits ONE `FeedRenderItem` per ledger-approved semantic block
(`semantic-block` / `semantic-collapsed-activity` / blockless `semantic-text`),
drawn by the dumb `SemanticLiveBlockRow` / `SemanticCollapsedActivityRow`. The
ledger (observations/semantic.ts + model/ownership.ts) is now the SOLE decider
of live-turn visibility; the bridge applies only the presentation grouping
(`groupSemanticActivity`, a pure fold — no suppression). **`SemanticStreamingTurn`
is DELETED**, and with it Feed's `deriveFeedCommittedProjection` /
`committedClaudeMessageTurnIds`.

Three of the old grep tokens SURVIVE, and after this slice they are NOT
SemanticStreamingTurn artifacts — each has a non-render owner:
- `buildSemanticRenderUnits` — retained for `semanticTurnHasRenderableContent`,
the NON-render #239 prompt-ownership predicate (streaming.ts) + debug bundles.
Rendering no longer calls it (that was the duplicate-decider #491 killed).
- `selectMergedEntries` — still Feed's ghost-folded `entries` SOURCE (tool
indices / scroll), used by 6 non-render call sites. The manifest's "ABSORB"
is a separate, entangled change.
- `ghost-semantic-owned` (ghost rule 3) — retained: it is a ghost-vs-live-turn
dedup (rejects a ghost whose turnId is the live turn), load-bearing regardless
of which component draws the turn. See its updated note in model/types.ts.

## Deleted at Stage 3 cutover (the big reviewed PR)

Expand Down
151 changes: 45 additions & 106 deletions src/renderer/src/features/feed/model/renderModel.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
import {
isCompactBoundaryEntry,
isCompactSummaryEntry,
isConversationEntry,
type Entry,
} from '@shared/types/transcript'
import { asRecord } from '@shared/lib/asRecord'
import { type Entry } from '@shared/types/transcript'

import type {
SemanticLiveTurn,
Expand All @@ -16,31 +10,17 @@ import type {
DebugVisibleRow,
VisibleDecision,
} from '@renderer/features/feed/types'
import {
debugKeyForEntry,
debugLabelForEntry,
} from '@renderer/features/feed/lib/helpers'
import {
buildCommittedAssistantText,
type CommittedAssistantText,
} from '@renderer/features/feed/ui/semantic/renderUnits'
import { debugLabelForEntry } from '@renderer/features/feed/lib/helpers'
import type { SemanticRenderUnit } from '@renderer/features/feed/ui/semantic/types'

// NOTE (Stage 3 cutover, 2026-07): `deriveFeedRenderModel` — the legacy in-Feed
// decision core that partitioned committed + semantic planes into an ordered
// item list — lived here and is deleted. The ownership ledger
// (src/renderer/src/rendering/) is now the sole decision-maker; both the
// desktop and the phone hand Feed the ledger's pre-ordered items. What remains
// is deliberately dumb: the FeedRenderItem contract, `feedRenderModelFromItems`
// (attaches debug side-products to the ledger's list), and
// `deriveFeedCommittedProjection` (still feeds SemanticStreamingTurn's committed
// dedup until the block-level un-collapse retires that component too).

export type FeedCommittedProjection = {
visibleDecisions: VisibleDecision[]
visibleEntries: Entry[]
committedClaudeMessageTurnIds: ReadonlySet<string>
committedAssistantText: CommittedAssistantText
}
// NOTE (Stage 3 cutover + #491 block-level un-collapse): the legacy in-Feed
// decision core lived here and is fully deleted — `deriveFeedRenderModel` (the
// plane partitioner) AND `deriveFeedCommittedProjection` (which only fed
// SemanticStreamingTurn's committed dedup, now that the ledger owns that and the
// component is gone). The ownership ledger (src/renderer/src/rendering/) is the
// sole decision-maker; desktop and phone hand Feed the ledger's pre-ordered
// items. What remains is deliberately dumb: the FeedRenderItem contract and
// `feedRenderModelFromItems` (attaches debug side-products to the ledger's list).

export type FeedRenderModel = {
items: FeedRenderItem[]
Expand All @@ -64,16 +44,37 @@ export type FeedRenderItem =
entryOrdinal: number
order: FeedRenderItemOrder
}
// #491 block-level un-collapse: the view bridge no longer hands Feed a whole
// turn (which SemanticStreamingTurn then re-suppressed block-by-block). The
// ledger already decided which blocks are visible, so the bridge emits ONE
// item per ledger-approved block (or a collapsed churn receipt / a blockless
// text row), drawn by a dumb row. `owner` is kept so Feed can still derive the
// history/current debug + work-hint signals it used to read off the turn item.
| {
type: 'semantic-block'
key: string
turnId: string
owner: 'semantic-current' | 'semantic-history'
block: SemanticLiveTurn['blocks'][number]
toolState: SemanticLiveTurn['lookups']['toolCallsById'][string] | null
order: FeedRenderItemOrder
}
| {
type: 'semantic-history'
type: 'semantic-collapsed-activity'
key: string
turn: SemanticLiveTurn
turnId: string
owner: 'semantic-current' | 'semantic-history'
unit: Extract<SemanticRenderUnit, { type: 'collapsed_activity' }>
order: FeedRenderItemOrder
}
| {
type: 'semantic-current'
// Blockless turn text (Codex/opencode deliver text ONLY on turn.text with
// an empty block map — semantic.ts mints a `sem:<turnId>:text` candidate).
type: 'semantic-text'
key: string
turn: SemanticLiveTurn
turnId: string
owner: 'semantic-current' | 'semantic-history'
text: string
order: FeedRenderItemOrder
}
| {
Expand All @@ -91,67 +92,16 @@ export type FeedRenderItem =
order: FeedRenderItemOrder
}

function committedMessageIds(entries: Entry[]): Set<string> {
const ids = new Set<string>()
for (const entry of entries) {
if (entry.type !== 'assistant') continue
const record = asRecord(entry)
const message = asRecord(record?.message)
const messageId = message?.id
if (typeof messageId === 'string') ids.add(messageId)
}
return ids
}

function visibleDecisionForEntry(entry: Entry, index: number): VisibleDecision {
if (isCompactBoundaryEntry(entry)) {
return {
key: debugKeyForEntry(entry, index),
entry,
visible: true,
reason: 'compact_boundary',
}
}
if (isCompactSummaryEntry(entry)) {
return {
key: debugKeyForEntry(entry, index),
entry,
visible: true,
reason: 'compact_summary',
}
}
if (!isConversationEntry(entry)) {
return {
key: debugKeyForEntry(entry, index),
entry,
visible: false,
reason: 'not_conversation',
}
}
if (asRecord(entry)?.isMeta === true) {
return {
key: debugKeyForEntry(entry, index),
entry,
visible: false,
reason: 'meta_filtered',
}
}
return {
key: debugKeyForEntry(entry, index),
entry,
visible: true,
reason: 'conversation',
}
}

function labelForItem(item: FeedRenderItem, provider: AgentProvider): string {
switch (item.type) {
case 'entry':
return debugLabelForEntry(item.entry)
case 'semantic-history':
return `semantic history ${item.turn.turnId.slice(0, 12)} · ${item.turn.source ?? 'unknown'}`
case 'semantic-current':
return `semantic turn ${item.turn.turnId.slice(0, 12)} · ${item.turn.source ?? 'unknown'}`
case 'semantic-block':
return `semantic ${item.owner === 'semantic-current' ? 'current' : 'history'} ${item.turnId.slice(0, 12)}#${item.block.blockIndex} · ${item.block.kind}`
case 'semantic-collapsed-activity':
return `collapsed ${item.turnId.slice(0, 12)} · ${item.unit.count} tools${item.unit.isRunning ? ' (running)' : ''}`
case 'semantic-text':
return `semantic text ${item.owner === 'semantic-current' ? 'current' : 'history'} ${item.turnId.slice(0, 12)}`
case 'work':
return item.toolName && (
item.phase === 'tool-input' ||
Expand All @@ -169,8 +119,9 @@ function slotForItem(item: FeedRenderItem): DebugVisibleRow['slot'] {
switch (item.type) {
case 'entry':
return 'entry'
case 'semantic-history':
case 'semantic-current':
case 'semantic-block':
case 'semantic-collapsed-activity':
case 'semantic-text':
return 'semantic'
case 'work':
return 'work'
Expand Down Expand Up @@ -210,15 +161,3 @@ export function feedRenderModelFromItems(
return { items, visibleDecisions, debugRows: debugRowsForItems(items, provider) }
}

export function deriveFeedCommittedProjection(entries: Entry[]): FeedCommittedProjection {
const visibleDecisions = entries.map(visibleDecisionForEntry)
const visibleEntries = visibleDecisions
.filter(item => item.visible)
.map(item => item.entry)
return {
visibleDecisions,
visibleEntries,
committedClaudeMessageTurnIds: committedMessageIds(entries),
committedAssistantText: buildCommittedAssistantText(entries),
}
}
85 changes: 59 additions & 26 deletions src/renderer/src/features/feed/ui/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ import {
debugLabelForEntry,
} from '@renderer/features/feed/lib/helpers'
import {
deriveFeedCommittedProjection,
feedRenderModelFromItems,
type FeedRenderItem,
} from '@renderer/features/feed/model/renderModel'
import { SemanticStreamingTurn } from '@renderer/features/feed/ui/semantic'
import {
SemanticLiveBlockRow,
SemanticCollapsedActivityRow,
} from '@renderer/features/feed/ui/semantic'
import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow'
import { StreamingProse } from '@renderer/features/feed/ui/markdown'
import { semanticTurnScrollSignal } from '@renderer/workspace/semantic/helpers'
import {
EAGER_TAIL,
Expand Down Expand Up @@ -779,14 +783,11 @@ function FeedImpl({
return out
}, [entries])

const committedProjection = useMemo(() => {
// Semantic deltas can arrive many times per second while committed
// JSONL entries stay unchanged. Keep all committed-entry scans behind
// an entries-only memo so live semantic text/tool updates do not keep
// rebuilding visible decisions, Claude message-id ownership, and the
// committed assistant text index for old rows.
return deriveFeedCommittedProjection(entries)
}, [entries])
// #491: `committedProjection` (deriveFeedCommittedProjection) is deleted — it
// existed ONLY to feed SemanticStreamingTurn's committedAssistantText for its
// in-component dedup. The ledger now owns that suppression, and
// SemanticStreamingTurn is gone, so Feed no longer derives any committed
// projection of its own.

// Stage 3 cutover (2026-07): the ownership ledger is the SOLE decision core.
// Feed no longer derives its own render model — both the desktop (TileLeaf)
Expand All @@ -805,20 +806,41 @@ function FeedImpl({
const renderItems = renderModel.items
const renderedRows = renderModel.debugRows
const visibleEntryCount = renderItems.filter(item => item.type === 'entry').length
const renderedSemanticHistoryTurnIds = useMemo(
() =>
renderItems
.filter(item => item.type === 'semantic-history')
.map(item => item.turn.turnId),
[renderItems],
)
const renderedSemanticHistorySignature = renderedSemanticHistoryTurnIds.join('\u0000')
const renderedSemanticTurn = useMemo(() => {
// #491: semantic items are now block-level (semantic-block/-collapsed-activity/
// -text), each tagged with its turn's owner. Derive the same debug signals the
// old turn-level items exposed — unique history turnIds, and "is the current
// turn on screen" — from the block items' owner+turnId.
const renderedSemanticHistoryTurnIds = useMemo(() => {
const seen = new Set<string>()
const ids: string[] = []
for (const item of renderItems) {
if (item.type === 'semantic-current') return item.turn
if (
(item.type === 'semantic-block' ||
item.type === 'semantic-collapsed-activity' ||
item.type === 'semantic-text') &&
item.owner === 'semantic-history' &&
!seen.has(item.turnId)
) {
seen.add(item.turnId)
ids.push(item.turnId)
}
}
return null
return ids
}, [renderItems])
const renderedSemanticHistorySignature = renderedSemanticHistoryTurnIds.join('\u0000')
// The current live turn IS rendered iff any block item carries owner
// 'semantic-current'. WorkIndicator's tool-hint reads the full turn, which
// Feed already has as the `semanticTurn` prop — return that when present.
const renderedSemanticTurn = useMemo(() => {
const currentOnScreen = renderItems.some(
item =>
(item.type === 'semantic-block' ||
item.type === 'semantic-collapsed-activity' ||
item.type === 'semantic-text') &&
item.owner === 'semantic-current',
)
return currentOnScreen ? semanticTurn : null
}, [renderItems, semanticTurn])

const previousRenderedRowsRef = useRef<DebugVisibleRow[] | null>(null)
const previousRenderDebugSignatureRef = useRef<string | null>(null)
Expand Down Expand Up @@ -910,15 +932,26 @@ function FeedImpl({
</div>
)
}
case 'semantic-history':
case 'semantic-current':
case 'semantic-block':
// #491: the ledger already decided this block is visible; the row is a
// pure drawer (no suppression). SemanticLiveBlockRow renders the exact
// per-kind streaming affordances it always did.
return (
<SemanticStreamingTurn
<SemanticLiveBlockRow
key={item.key}
turn={item.turn}
committedAssistantText={committedProjection.committedAssistantText}
block={item.block}
toolState={item.toolState}
/>
)
case 'semantic-collapsed-activity':
return <SemanticCollapsedActivityRow key={item.key} unit={item.unit} />
case 'semantic-text':
// Blockless Codex/opencode turn text — the legacy no-blocks path.
return (
<MarkerRow key={item.key} marker="⏺">
<StreamingProse text={item.text} />
</MarkerRow>
)
case 'work':
return (
<WorkIndicator
Expand Down
Loading