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
52 changes: 52 additions & 0 deletions docs/rendering/legacy-deletion-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Legacy Rendering — Deletion Manifest

The contract for the complete strip-out of the old rendering system. The old
code is NOT deleted early: Stage 2 shadow mode diffs the new ledger against
the live legacy renderer, so the legacy path must keep working until cutover.
This manifest is what "starting fresh" means in practice — every legacy file
is enumerated with its fate and its deletion point, so nothing survives by
accident. LOC figures from the knowledge dump's Blast Radius appendix
(measured 2026-05-22; drifted slightly since, fates unchanged).

Legend: **DELETE** = removed entirely · **GUT** = file stays, ownership logic
removed (becomes dumb/plumbing) · **ABSORB** = behavior re-encoded in the
ledger, then source deleted · **KEEP** = not ownership code, survives as-is.

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

| file | phys LOC | fate | notes |
|---|---:|---|---|
| `features/feed/ui/semantic/StreamingTurn.tsx` | 149 | **DELETE** | atomically with ghost rule 3 (plan D6) — never one without the other |
| `features/feed/model/renderModel.ts` | 430 | **DELETE** | replaced by ledger + view model; `committedClaudeMessageTurnIds` naming lie dies with it |
| `features/feed/ui/semantic/renderUnits.ts` | 359 | **ABSORB** | committed-ownership skips → ledger reasons; collapsed-activity policy → view layer |
| `workspace/mergedEntries.ts` | ~200 | **ABSORB** | five-rule predicate → ledger ghost decisions; the entries pre-pass merge dies |
| `workspace/semantic/foldEvent.ts` | 987 | **GUT→ABSORB** | reducer stays (events→turn state); ALL ownership/suppression gates + fold policies move to ledger candidate rules; yield hatches become policy |
| `features/feed/ui/semantic/BlockRow.tsx` | 495 | **GUT** | becomes dumb rows; hardcoded provider dispatch replaced by the registry (same one committed rows use); all suppression branches deleted |
| `features/feed/ui/Feed.tsx` | 946 | **GUT** | maps view-model rows once; all plane/ownership/debug-derivation logic deleted; scroll/lazy stay per migration hazards |
| `features/feed/ui/rows/Block.tsx` | 204 | **GUT** | dispatch stays, ownership guards deleted |
| `workspace/ghosts.ts` | ~530 | **GUT** | minting/reconcile/persist stay (bookkeeping); render-side selection is ledger's |
| legacy `visible_rows`-only debug derivation | — | **DELETE** | replaced by ownership_decision serialization (visible_rows stays as aggregate) |

## Deleted during Stage 1 (already superseded, no shadow value)

- old fold-policy literals already replaced on main (#415) — nothing to do
- `workspace/queueInvariants.ts` — **ABSORB** into ledger queue-handoff rules
at cutover, not before (echo providers still run through it live)

## KEEP (not ownership code)

| file | LOC | why it lives |
|---|---:|---|
| `markdown/Prose.tsx`, `MarkdownComponents.tsx`, remark plugins | ~250 | TextProse/StreamingProse split is load-bearing (migration hazard) |
| `lib/streamingWriteInput.ts` | 206 | Write-scanner, pure detector — feeds the view layer |
| `WorkIndicator.tsx` + hints | 305 | phase surface; consumes work candidates |
| row components (ToolUseRow/ToolResultRow/ConversationRow/Claude+Codex+OpenCode rows) | ~1600 | become dumb rows; only their embedded ownership guards die (counted under GUT above) |
| `lib/helpers.ts` committed text/tool index builders | 386 | logic ABSORBED into ownership.ts; file gutted at cutover |
| headless packages | ~11k | contract-tested, never rewritten (plan §10) |

## Verification of completeness at cutover

The Stage 3 PR is not mergeable until:
`grep -rn "SemanticStreamingTurn\|selectMergedEntries\|deriveFeedRenderModel\|committedClaudeMessageTurnIds\|buildSemanticRenderUnits" src/` returns only
hits inside `src/renderer/src/rendering/` history comments — i.e. every entry
in the DELETE/ABSORB tables above is gone or gutted, with its fixture green.
107 changes: 107 additions & 0 deletions src/renderer/src/rendering/__tests__/committed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'

import {
collectCommittedCandidates,
normalizeTextKey,
type RawCommittedEntry,
} from '@renderer/rendering/observations/committed'

// Fixture times are explicit ISO strings — entry.timestamp is producer
// wall-clock and the collector must parse it, never substitute Date.now().
const TS = '2026-07-06T12:00:00.000Z'
const TS_MS = Date.parse(TS)

const user = (over: Partial<RawCommittedEntry> = {}): RawCommittedEntry => ({
uuid: 'u1',
type: 'user',
timestamp: TS,
permissionMode: 'default',
message: { role: 'user', content: 'hello' },
...over,
})

describe('committed collector: visibility (#338, meta, non-conversation)', () => {
it('ordinary conversation rows become candidates with producer timestamps', () => {
const { candidates, decisions } = collectCommittedCandidates(
[user(), { uuid: 'a1', type: 'assistant', timestamp: TS, message: { id: 'msg_1', role: 'assistant', content: 'hi' } }],
'claude',
's1',
)
expect(candidates.map(c => c.id)).toEqual(['entry:u1', 'entry:a1'])
expect(candidates[0].timestampMs).toBe(TS_MS)
expect(candidates[1].messageId).toBe('msg_1')
expect(decisions.every(d => d.selected)).toBe(true)
})

it('meta rows and non-conversation rows are hidden WITH reasons, not dropped silently', () => {
const { candidates, decisions } = collectCommittedCandidates(
[
user({ uuid: 'meta', isMeta: true }),
{ uuid: 'sys', type: 'system', timestamp: TS },
],
'claude',
's1',
)
expect(candidates).toHaveLength(0)
expect(decisions.map(d => d.reason).sort()).toEqual(['meta-entry', 'not-conversation'])
})

it('#338: claude command scaffolding (<command-name>, <local-command-stdout>) filtered as synthetic-user', () => {
const { candidates, decisions } = collectCommittedCandidates(
[
user({ uuid: 'cmd', permissionMode: undefined, message: { role: 'user', content: '<command-name>/compact</command-name>' } }),
user({ uuid: 'out', permissionMode: undefined, message: { role: 'user', content: '<local-command-stdout>Compacted</local-command-stdout>' } }),
user({ uuid: 'real' }), // has permissionMode → real prompt
],
'claude',
's1',
)
expect(candidates.map(c => c.id)).toEqual(['entry:real'])
expect(decisions.filter(d => d.reason === 'synthetic-user-filtered')).toHaveLength(2)
})

it('#338 is claude-scoped: an opencode user message starting with "<" (pasted HTML) is NOT hidden', () => {
const { candidates } = collectCommittedCandidates(
[user({ uuid: 'html', permissionMode: undefined, message: { role: 'user', content: '<div>paste</div>' } })],
'opencode',
's1',
)
expect(candidates.map(c => c.id)).toEqual(['entry:html'])
})

it('compact boundary/summary rows are visible', () => {
const { candidates } = collectCommittedCandidates(
[
{ uuid: 'b', type: 'compact-boundary', timestamp: TS },
{ uuid: 's', type: 'compact-summary', timestamp: TS },
],
'claude',
's1',
)
expect(candidates.map(c => c.contentKind)).toEqual(['compact-boundary', 'compact-summary'])
})

it('assistant rows carry exact + normalized text keys; user rows carry neither', () => {
const { candidates } = collectCommittedCandidates(
[
{ uuid: 'a', type: 'assistant', timestamp: TS, message: { role: 'assistant', content: ' Same  answer ' } },
user(),
],
'codex',
's1',
)
const assistant = candidates.find(c => c.id === 'entry:a')
expect(assistant?.textKey).toBe(' Same  answer ')
expect(assistant?.normalizedTextKey).toBe(normalizeTextKey('Same answer'))
expect(candidates.find(c => c.id === 'entry:u1')?.textKey).toBeUndefined()
})

it('uuid-less entries get an INGEST-position id, never a visible-index id', () => {
const { candidates } = collectCommittedCandidates(
[{ type: 'user', timestamp: TS, permissionMode: 'default', message: { role: 'user', content: 'x' } }],
'claude',
's1',
)
expect(candidates[0].id).toBe('entry:ingest-0')
})
})
165 changes: 165 additions & 0 deletions src/renderer/src/rendering/observations/committed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import type { AgentProviderKind } from '@shared/types/providerKind'
import type {
OwnershipDecision,
RenderCandidate,
RenderContentKind,
} from '@renderer/rendering/model/types'

// ---------------------------------------------------------------------------
// Committed candidate collector — the anti-corruption boundary between raw
// transcript entries and the ledger (plan §4 observations/).
//
// Everything downstream consumes candidates, never raw provider shapes.
// Visibility is decided HERE, and hidden entries still produce decision
// records — "was this row hidden because it was meta/synthetic, or never
// ingested?" must be answerable from a bundle (the dump's visibleDecisions
// rationale + #344).
//
// The input type is deliberately STRUCTURAL and narrow: this module owns
// exactly the fields it reads, so provider-type churn (atp upgrades, new
// entry kinds) surfaces here as unknown-shaped input, not as silent
// downstream misbehavior.
// ---------------------------------------------------------------------------

export type RawCommittedEntry = {
uuid?: string
type?: string
isMeta?: boolean
timestamp?: string | number
/** Claude-shaped conversation body; opencode's mapper emits this too. */
message?: {
id?: string
role?: string
content?: unknown
}
/** Present on real Claude user prompts; absent on synthetic scaffolding. */
permissionMode?: string
}

export type CommittedCollection = {
candidates: RenderCandidate[]
decisions: OwnershipDecision[]
}

const CONVERSATION_TYPES = new Set(['user', 'assistant'])
const COMPACT_TYPES = new Set(['compact-boundary', 'compact_boundary', 'compact-summary', 'compact_summary'])

function entryTimestampMs(e: RawCommittedEntry): number | null {
// Committed entry.timestamp is the TOP of the trust hierarchy (plan D4) —
// producer wall-clock. Never substitute Date.now(): resume comparisons
// (ghost rule 4, ordering across restarts) live in producer time.
if (typeof e.timestamp === 'number' && Number.isFinite(e.timestamp)) return e.timestamp
if (typeof e.timestamp === 'string') {
const ms = Date.parse(e.timestamp)
return Number.isFinite(ms) ? ms : null
}
return null
}

function textOf(e: RawCommittedEntry): string | null {
const c = e.message?.content
if (typeof c === 'string') return c
if (Array.isArray(c)) {
const first = c.find(
(b): b is { type: string; text: string } =>
typeof b === 'object' && b !== null &&
(b as { type?: unknown }).type === 'text' &&
typeof (b as { text?: unknown }).text === 'string',
)
return first?.text ?? null
}
return null
}

/**
* The #338 synthetic-Claude-user predicate, centralized. Claude writes
* local-command scaffolding as NON-meta user rows — `<command-name>`,
* `<local-command-stdout>`, `<environment_context>` — which rendered as if
* the user typed them ("we are so often spitting out commands into the user
* prompts"). The stronger predicate already existed in latestUserPrompts.ts
* and sessionIndex.ts but feed visibility never adopted it; this collector
* is now the single home. Claude-only: other providers don't emit
* angle-bracket scaffolding as user rows, and a codex/opencode user message
* legitimately starting with '<' (pasted HTML) must not be hidden.
*/
function isSyntheticClaudeUserRow(
e: RawCommittedEntry,
provider: AgentProviderKind,
): boolean {
if (provider !== 'claude') return false
if (e.type !== 'user' || e.message?.role !== 'user') return false
if (e.permissionMode !== undefined) return false
const text = textOf(e)
return typeof text === 'string' && text.trimStart().startsWith('<')
}

function contentKindOf(e: RawCommittedEntry): RenderContentKind {
if (e.type === 'assistant') return 'assistant-text'
if (e.type === 'user') return 'user-text'
if (e.type?.includes('boundary')) return 'compact-boundary'
return 'compact-summary'
}

// NFKC + whitespace-collapse + trim — the same conservative normalization
// the legacy committedAssistantText sets used. Never fuzzier than this.
export function normalizeTextKey(text: string): string {
return text.normalize('NFKC').replace(/\s+/g, ' ').trim()
}

export function collectCommittedCandidates(
entries: readonly RawCommittedEntry[],
provider: AgentProviderKind,
sessionId: string,
): CommittedCollection {
const candidates: RenderCandidate[] = []
const decisions: OwnershipDecision[] = []

entries.forEach((e, index) => {
// Ingest-time stable id (plan migration hazard: keys must never fall
// back to visible index — index reuse after order shifts made React
// retain wrong subtrees, the "phantom duplicate" class). uuid when the
// provider gave one; otherwise position-at-INGEST, which is stable
// because the entries array is append-only.
const id = `entry:${e.uuid ?? `ingest-${index}`}`

const isConversation = CONVERSATION_TYPES.has(e.type ?? '')
const isCompact = COMPACT_TYPES.has(e.type ?? '')

if (!isConversation && !isCompact) {
decisions.push({ candidateId: id, selected: false, reason: 'not-conversation', evidence: [`type=${e.type ?? 'unknown'}`] })
return
}
if (isConversation && e.isMeta === true) {
decisions.push({ candidateId: id, selected: false, reason: 'meta-entry', evidence: [] })
return
}
if (isSyntheticClaudeUserRow(e, provider)) {
decisions.push({
candidateId: id,
selected: false,
reason: 'synthetic-user-filtered',
evidence: ['claude user row, no permissionMode, text starts with <'],
})
return
}

const text = textOf(e)
candidates.push({
id,
owner: 'committed',
provider,
sourcePlane: 'committed',
sessionId,
messageId: e.message?.id,
turnId: e.message?.id,
contentKind: contentKindOf(e),
timestampMs: entryTimestampMs(e),
sequence: index,
textKey: e.type === 'assistant' && text ? text : undefined,
normalizedTextKey: e.type === 'assistant' && text ? normalizeTextKey(text) : undefined,
})
decisions.push({ candidateId: id, selected: true, reason: 'selected', evidence: [] })
})

return { candidates, decisions }
}