Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
945f008
feat(reference): per-session PendingReference context
itsdestin Jul 26, 2026
f2e93f6
feat(reference): pure PendingReference builder
itsdestin Jul 26, 2026
abb7831
fix(reference): hold live DOM handles instead of mutating selectors
itsdestin Jul 26, 2026
e7a8f0a
test(reference): extend no-mutation guard to buildCodeReference and b…
itsdestin Jul 26, 2026
3f43793
feat(reference): menu produces a PendingReference; disable on streami…
itsdestin Jul 26, 2026
fe124b1
fix(chat): memo comparator must compare `streaming` prop
itsdestin Jul 26, 2026
946b2cc
feat(reference): composer placeholder + send-time scaffold assembly
itsdestin Jul 26, 2026
d7b972f
fix(inputbar): keep held reference and draft as one unit across two gaps
itsdestin Jul 26, 2026
9e6eb3b
fix(inputbar): soft-fail useReference() in Buddy windows + guard cros…
itsdestin Jul 26, 2026
701af2e
feat(reference): pure stepped-union outline geometry
itsdestin Jul 26, 2026
35c5777
feat(reference): window-wide scrim + cancel affordances
itsdestin Jul 26, 2026
9bbc765
fix(reference): lift composer above scrim + document depth-race, per …
itsdestin Jul 26, 2026
f2b1ec0
feat(reference): traced selection outline
itsdestin Jul 26, 2026
bf7ce63
feat(reference): FLIP the referenced message to screen centre
itsdestin Jul 26, 2026
83f7c6b
fix(reference-overlay): stop scroll from restarting the travel FLIP
itsdestin Jul 26, 2026
1efccbb
fix(reference): restore pointer-events on Cancel button; correct clon…
itsdestin Jul 27, 2026
d4775ec
feat(reference): reduced-effects and reduced-motion fallback
itsdestin Jul 27, 2026
3528339
fix(reference): three cross-task defects found by the final branch re…
itsdestin Jul 27, 2026
c727292
fix(reference): highlight hugs the travelling card; clone fills its w…
itsdestin Jul 28, 2026
e32be31
fix(reference): quoted text must exclude the bubble timestamp
itsdestin Jul 28, 2026
11feb38
fix(reference): dedup on true sent text, render scaffold as inline reply
itsdestin Jul 28, 2026
9c625d9
fix(reference): unclip the card ring; make the cancel button a solid …
itsdestin Jul 28, 2026
9561007
feat(reference): reply renders as a collapsed pill that expands to a …
itsdestin Jul 28, 2026
2a89452
fix(reference): hide moved source, highlight the selection, drop the …
itsdestin Jul 28, 2026
fdb2c3a
fix(reference): animate the reference card back on cancel, fix invisi…
itsdestin Jul 28, 2026
949532b
fix(reference): restore the traced selection outline, anchored to the…
itsdestin Jul 28, 2026
cf44e6c
fix(reference): union same-line mark rects before the outline traversal
itsdestin Jul 28, 2026
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
16 changes: 13 additions & 3 deletions desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import TerminalRightSlot from './components/TerminalRightSlot';
import { ChatProvider, useChatDispatch, useChatStore } from './state/chat-context';
import { artifactReducer, initialArtifactState } from './state/artifact-tracker';
import { ArtifactProvider } from './state/ArtifactContext';
import { ReferenceProvider } from './state/reference-context';
import { categorizeArtifact } from '../shared/artifacts/categorization';
import { resolveTrackedPath } from '../shared/artifacts/resolve-tracked-path';
// Central slash-command router — also used by the drawer so drawer-initiated
Expand Down Expand Up @@ -92,6 +93,7 @@ import { ZoomOverlay } from './components/ZoomOverlay';
import { RemoteSnapshotExporter } from './components/RemoteSnapshotExporter';
import RemoteUnsupportedNotice from './components/RemoteUnsupportedNotice';
import { ContextMenuHost } from './components/context-menu/ContextMenuHost';
import { ReferenceOverlay } from './components/reference/ReferenceOverlay';
import { BuddyMascotApp } from './components/buddy/BuddyMascotApp';
import { BuddyChatApp } from './components/buddy/BuddyChatApp';
import { BuddyBarApp } from './components/buddy/BuddyBarApp';
Expand Down Expand Up @@ -2616,9 +2618,13 @@ function AppInner() {
) : null;

return (
// ArtifactProvider: exposes artifact state + dispatch to the entire AppInner
// subtree. Sits inside all top-level providers (ChatProvider, ThemeProvider,
// etc.) because artifact operations may eventually consume chat/theme context.
// ReferenceProvider: holds the "Ask Claude about this" pending reference,
// parked per session so it can't leak between conversations. Outside
// ArtifactProvider because the artifact viewer is one of its two sources.
<ReferenceProvider sessionId={sessionId ?? ''}>
{/* ArtifactProvider: exposes artifact state + dispatch to the entire AppInner
subtree. Sits inside all top-level providers (ChatProvider, ThemeProvider,
etc.) because artifact operations may eventually consume chat/theme context. */}
<ArtifactProvider value={{ state: artifactState, dispatch: dispatchArtifact }}>
<div className={`app-shell flex w-screen h-full text-fg ${getPlatform() === 'android' && currentViewMode === 'terminal' ? '' : 'bg-canvas'}`}>
{/* Mount-only: listens for chat:export-snapshot from main, serializes
Expand All @@ -2632,6 +2638,9 @@ function AppInner() {
(copy/paste, Ask about this, file-pill actions). Opens only over
surfaces it owns; leaves the terminal and other chrome untouched. */}
<ContextMenuHost />
{/* Mount-only: the held "Ask Claude about this" reference — window-wide
dim, traced outline, and the lifted source card. */}
<ReferenceOverlay />
{/* Main area — relative so bottom-float chrome can position against it.
When a Phase-2 full-screen destination is active, hide the chat
chrome entirely. Unmounting via `hidden` is cleaner than z-index
Expand Down Expand Up @@ -3397,6 +3406,7 @@ function AppInner() {
/>
</div>
</ArtifactProvider>
</ReferenceProvider>
);
}

Expand Down
44 changes: 44 additions & 0 deletions desktop/src/renderer/components/AssistantTurnBubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,50 @@ describe('AssistantTurnBubble — memo comparator (streaming perf)', () => {

expect(mdRenders.length).toBeGreaterThan(rendersAfterMount);
});

it('DOES re-render when `streaming` flips with the SAME turn object reference (crash/error path)', () => {
// Pins the bug: SESSION_PROCESS_EXITED / NATIVE_SESSION_ERROR call endTurn(session),
// which flips isThinking false WITHOUT replacing the turn object in
// session.assistantTurns (unlike TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT,
// which both do assistantTurns.set(id, {...turn, ...})). For a text-only turn with
// no tool groups, `turn` stays === across the re-render and the per-group loop in
// the comparator never runs — `streaming` must be compared explicitly or the memo
// silently blocks the update and data-streaming stays "true" forever, permanently
// disabling "Ask about this" on a finished message.
const turn: AssistantTurn = {
id: 'turn_streaming',
segments: [{ type: 'text' as const, content: 'hello world', messageId: 'turn_streaming-msg' }],
timestamp: 0,
stopReason: null,
model: null,
usage: null,
anthropicRequestId: null,
};
const toolGroups = new Map<string, ToolGroupState>();
const toolCalls = new Map<string, ToolCallState>();

const props = { turn, toolGroups, toolCalls, sessionId: 'test', showTimestamps: false };
const { container, rerender } = render(
<ChatProvider>
<AssistantTurnBubble {...props} streaming={true} />
</ChatProvider>
);

const bubbleEl = container.querySelector('.assistant-bubble');
expect(bubbleEl).not.toBeNull();
expect(bubbleEl).toHaveAttribute('data-streaming', 'true');

// Same `turn` object reference — only `streaming` changes, exactly what
// endTurn() produces on the crash/error paths.
rerender(
<ChatProvider>
<AssistantTurnBubble {...props} turn={turn} streaming={false} />
</ChatProvider>
);

const bubbleElAfter = container.querySelector('.assistant-bubble');
expect(bubbleElAfter).not.toHaveAttribute('data-streaming');
});
});

describe('splitIntoBubbles — BUG A (tool group mis-attribution after interleaved reasoning)', () => {
Expand Down
18 changes: 16 additions & 2 deletions desktop/src/renderer/components/AssistantTurnBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ interface Props {
/** Session provider — drives provider-aware stop-reason copy (native vs Claude). */
provider?: SessionProvider;
showTimestamps: boolean;
/** True only for the turn currently being written. Gates "Ask about this" —
* the reference card is a static clone and would freeze mid-sentence. */
streaming?: boolean;
}

// Non-end_turn stop reasons rendered inline under the affected turn.
Expand Down Expand Up @@ -327,6 +330,14 @@ function assistantTurnPropsAreEqual(prev: Props, next: Props): boolean {
if (prev.sessionId !== next.sessionId) return false;
if (prev.provider !== next.provider) return false;
if (prev.showTimestamps !== next.showTimestamps) return false;
// WHY: SESSION_PROCESS_EXITED / NATIVE_SESSION_ERROR call endTurn(session),
// which flips isThinking false WITHOUT replacing the turn object (unlike
// TRANSCRIPT_TURN_COMPLETE / TRANSCRIPT_INTERRUPT, which both do
// assistantTurns.set(id, {...turn, ...})). For a text-only turn with no
// tool groups, `streaming` going true->false is otherwise the ONLY prop
// that changes — miss it here and data-streaming stays "true" forever,
// permanently disabling "Ask about this" on a finished message.
if (prev.streaming !== next.streaming) return false;

// Same turn object (checked above) ⇒ same segments ⇒ same group IDs. We only
// need to walk one side's IDs.
Expand All @@ -343,7 +354,7 @@ function assistantTurnPropsAreEqual(prev: Props, next: Props): boolean {
return true;
}

export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolCalls, sessionId, provider, showTimestamps }: Props) {
export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolCalls, sessionId, provider, showTimestamps, streaming }: Props) {
// Read opt-in metadata preference here so the strip below only renders when
// the user has explicitly turned it on in PreferencesPopup (default false).
const { showTurnMetadata } = useTheme();
Expand Down Expand Up @@ -371,7 +382,10 @@ export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolC
const isLastBubble = i === bubbles.length - 1;
return (
<div key={bubble.key} className="flex justify-start px-4 py-0.5">
<div className={`assistant-bubble max-w-[85%] break-words rounded-2xl rounded-bl-sm bg-inset text-sm text-fg px-5 ${toolsOnly ? 'py-2.5' : hasTools ? 'pt-4 pb-3' : reasoningOnly ? 'py-2.5' : 'py-3.5'}`}>
<div
data-streaming={streaming ? 'true' : undefined}
className={`assistant-bubble max-w-[85%] break-words rounded-2xl rounded-bl-sm bg-inset text-sm text-fg px-5 ${toolsOnly ? 'py-2.5' : hasTools ? 'pt-4 pb-3' : reasoningOnly ? 'py-2.5' : 'py-3.5'}`}
>
{bubble.reasoning && (
<ReasoningSection content={bubble.reasoning.content} />
)}
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/renderer/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,9 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane
sessionId={sessionId}
provider={provider}
showTimestamps={showTimestamps}
// Only the LAST entry can be mid-stream; everything above
// it is complete and safe to reference.
streaming={state.isThinking && idx === state.timeline.length - 1}
/>
);
break;
Expand Down
73 changes: 73 additions & 0 deletions desktop/src/renderer/components/InputBar.reference.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
// Pins the two composer contracts of the held reference (spec 2026-07-26 §3.5):
// 1. the placeholder announces the reference, and
// 2. promptText is prepended EXACTLY ONCE at send, then the reference clears —
// while the user's own draft is never touched by a cancel.
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, act } from '@testing-library/react';
import { ReferenceProvider, useReference, type PendingReference } from '../state/reference-context';
import { composeOutgoing, placeholderFor } from './InputBar';

const REF: PendingReference = {
kind: 'chat-text',
label: '"the reducer preserves…"',
promptText: 'In an earlier message, you said:\n"x"\n\nThe user has a follow-up: ',
anchor: null,
};

describe('placeholderFor', () => {
it('falls back to the default with no reference', () => {
expect(placeholderFor(null, false)).toBe('Message Claude...');
});

it('announces the held reference', () => {
expect(placeholderFor(REF, false)).toBe('Ask Claude about "the reducer preserves…"');
});

it('the approval gate still wins over a held reference', () => {
expect(placeholderFor(REF, true)).toBe('Waiting for approval...');
});

// Gap 2 (task-4-report.md "Concerns" #2): minimal (terminal view) send
// paths write straight to the PTY and never call composeOutgoing, so a
// reference can never be consumed there. Announcing it in the placeholder
// would promise a scaffold that will never be sent — minimal must silence
// it even though a reference IS held (default `minimal` arg is `false`,
// which is why every other test above still sees it announced).
it('does not announce a held reference in minimal (terminal view) mode', () => {
expect(placeholderFor(REF, false, true)).toBe('Message Claude...');
});
});

describe('composeOutgoing', () => {
it('returns the draft unchanged with no reference', () => {
expect(composeOutgoing('why?', null)).toBe('why?');
});

it('prepends promptText exactly once', () => {
expect(composeOutgoing('why?', REF)).toBe(REF.promptText + 'why?');
});

it('sends the scaffold alone when the draft is empty', () => {
expect(composeOutgoing('', REF)).toBe(REF.promptText);
});
});

// Hoisted to module scope (matches reference-context.test.tsx's Probe idiom):
// tsc's definite-assignment check only exempts USAGE inside a nested closure
// (e.g. act(() => api.foo())) — a direct `expect(api.reference)` in the SAME
// scope as a local `let api` declaration still trips TS2454 "used before
// being assigned", even though render() has synchronously run Probe by then.
let api: ReturnType<typeof useReference>;
function Probe() { api = useReference(); return null; }

describe('cancel does not touch the draft', () => {
it('clearReference leaves composer state alone', () => {
render(<ReferenceProvider sessionId="s1"><Probe /></ReferenceProvider>);
act(() => api.setReference(REF));
act(() => api.clearReference());
// The context owns ONLY the reference — it has no draft to clobber.
expect(api.reference).toBeNull();
});
});
Loading