Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in by Astro-Han · Pull Request #636 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in by Astro-Han · Pull Request #636 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in by Astro-Han · Pull Request #636 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in by Astro-Han · Pull Request #636 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in by Astro-Han · Pull Request #636 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in by Astro-Han · Pull Request #636 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): rework streaming output — step timeline, deep-thinking disclosure, tool trows, word fade-in by Astro-Han · Pull Request #636 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab4e676
feat(core,runtime): persist tool_call stepId for step-paired UI timeline
Astro-Han Jul 8, 2026
178b437
feat(ui): materialize per-turn render timeline from step-paired messages
Astro-Han Jul 8, 2026
2a27e7c
feat(ui): timeline-rendered turn body with 深度思考 disclosure + Codex to…
Astro-Han Jul 8, 2026
ef44607
feat(ui): replace streaming caret with per-word fade-in
Astro-Han Jul 8, 2026
7ed1165
feat(ui): wire live tool stepId + multi-step timeline fixtures
Astro-Han Jul 8, 2026
964f53f
fix(runtime): project tool_call stepId in the runtime-event read model
Astro-Han Jul 8, 2026
b542c62
fix(ui): anchor fade offsets at the tail across markdown-hidden syntax
Astro-Han Jul 8, 2026
d38d0c6
fix(ui): key timeline entries semantically, without the index
Astro-Han Jul 8, 2026
0a9b585
fix(ui): flush a pure-tool step's orphan tools before the next step's…
Astro-Han Jul 8, 2026
34e379a
fix(ui): force the tool trow open when a grouped tool errors
Astro-Han Jul 8, 2026
6e26031
fix(ui): disable mono ligatures so === stops rendering as strikethrough
Astro-Han Jul 8, 2026
56c169f
fix(ui): rework tool trow — flat detail body + unified row language
Astro-Han Jul 8, 2026
3ff2621
fix(ui): deep-thinking settled polish + streaming/timeline parity
Astro-Han Jul 8, 2026
3bf6a0f
fix(ui): unify chat timeline meta rows to one font tier
Astro-Han Jul 8, 2026
a0b9dc9
fix(ui): keep the streaming typewriter alive when rAF timestamps lag …
Astro-Han Jul 8, 2026
0c947ee
fix(ui): make the streaming→settled handoff height-neutral so the ans…
Astro-Han Jul 8, 2026
bd9b22c
fix(ui): reserve the footer placeholder for thinking-only turns too
Astro-Han Jul 8, 2026
c8e7631
docs(ui): scope the footer-placeholder claim to groundwork; track tex…
Astro-Han Jul 8, 2026
8d2b2e1
Merge remote-tracking branch 'origin/main' into feat/streaming-ui-rework
Astro-Han Jul 8, 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => {
}
});

it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => {
it('keeps the turn container (out of scope)', async () => {
const css = await readAllRendererCss();
for (const selector of [
// The `.maka-turn` flex/measure container is NOT a marker — it stays.
'.maka-turn {',
'.maka-turn-tools',
'.maka-turn-streaming',
'.maka-turn[data-search-highlight="true"]',
// `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron +
// @starting-style fade don't reduce to leaf utilities); it stays authored.
'.maka-turn-thinking',
'.maka-turn-thinking [data-slot="collapsible-trigger"]',
// NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the
// streaming UI rework — reasoning now renders through the `DeepThinking`
// disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive)
// and tools through the flat `ToolTrow`, so the hand-authored committed-
// turn thinking `<details>` chrome and the tools-section wrapper are gone.
]) {
assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`);
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => {
it('keeps alert/status badges in flow between the header and first chat content', async () => {
const src = await readRepo('packages/ui/src/chat-view.tsx');

// The cluster div is ALWAYS mounted (streaming-settle polish): it
// collapses via the CSS `:empty` height transition instead of conditional
// mount/unmount, which used to snap the conversation column up by the
// badge-row height the frame a run completed. The badges inside stay
// conditional.
assert.match(
src,
/<header className="maka-chat-header">[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/,
/<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*<div className="maka-chat-status-cluster">/,
'status badges should render after the header, not inside the header toolbar row',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/,
/<div className="maka-chat-status-cluster">\s*\{props\.sessionStatusBadge && /,
'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)',
);
assert.match(
src,
/<div className="maka-chat-status-cluster">[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/,
'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content',
);
});

it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size');
assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token');
const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty');
assert.match(emptyBody, /height:\s*0/);
assert.match(emptyBody, /opacity:\s*0/);
});

it('reserves the footer placeholder for every live turn, not only text answers', async () => {
// Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer
// (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone;
// materialize emits a timeline item for a step's thinking even with empty
// text), so a thinking-only turn settles WITH a footer. The live footer
// placeholder must live inside the `streamingText || thinkingText` section
// and render unconditionally there — never re-narrowed to streamingText —
// so it reserves the footer box for every live turn.
//
// Groundwork only: this locks the reserved box. It makes the swap
// height-neutral where the live section is held to settle (text turns, via
// the draining handshake). The textless / thinking-only completion path is
// still non-atomic (clears live before the committed footer mounts); that
// is tracked in the single-render-path convergence (#642), not asserted
// here.
const src = await readRepo('packages/ui/src/chat-view.tsx');
assert.match(
src,
/Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*<div aria-hidden="true" className="mt-0\.5 h-8" \/>/,
'the footer placeholder must render unconditionally inside the live section (covers thinking-only turns)',
);
assert.doesNotMatch(
src,
/\{props\.streamingText && <div aria-hidden="true" className="mt-0\.5 h-8" \/>\}/,
'the placeholder must not be re-gated on streamingText alone — that misses thinking-only settle',
);
});

it('uses an in-flow wrapping row instead of absolute positioning', async () => {
const css = await readRendererContractCss();
const body = ruleBody(css, '.maka-chat-status-cluster');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -111,10 +111,20 @@ describe('issue #406 design-system governance contract', () => {
'maka-toast-enter',
'maka-toast-exit',
'maka-processing-sweep',
'maka-cursor',
'maka-list-row-streaming-pulse',
'maka-pulse',
'maka-reasoning-panel-pulse',
// Streaming UI rework: the "深度思考" disclosure title + a working trow's
// active-tool summary sweep light across the label (functional "still
// working" signal), driven by the TextShimmer primitive. The retired ▎
// caret's `maka-cursor` is replaced by `maka-stream-fade-in`, the
// per-word entrance that signals freshly streamed text.
'maka-text-shimmer',
'maka-stream-fade-in',
// Handoff polish: the turn footer toolbar fades in when a live turn
// settles (opacity only, into an equal-height placeholder slot) —
// functional "the answer is final, actions are now available" signal,
// never applied on history-hydration mounts.
'maka-footer-fade-in',
'maka-shimmer',
'maka-status-spin',
'maka-tool-pulse',
Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,12 +198,13 @@ describe('PROSE-POLISH-13PX-0 contract (#546 Phase B)', () => {
&& !/\.maka-prose\s*>\s*p:nth-last-child/.test(css),
'structural :nth-last-child prose hacks must not return — they assume a trailing non-markdown child that no longer exists',
);
// The streaming caret still needs the trailing paragraph inlined, but
// scoped to the streaming bubble only.
assert.match(
css,
/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/,
'the caret-inline rule must be scoped to .maka-bubble-streaming so committed messages and .maka-prose reusers (tool results, #546 PR6) keep block paragraphs',
// The streaming ▎ caret (and its trailing-<p> inline hack) was retired by
// the streaming UI rework — the "still writing" signal is the per-word
// fade-in — so the streaming bubble must NOT re-introduce a
// `p:last-child { display: inline }` rule that would collapse block spacing.
assert.ok(
!/\.maka-bubble-streaming\s*>\s*p:last-child\s*\{[^}]*display:\s*inline/.test(css),
'the retired caret-inline hack must not return — the trailing paragraph keeps normal block layout',
);
// Negative side of the same contract: an unscoped variant on the
// committed-message classes would inline the final paragraph of every
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/main/__tests__/materialize-turns.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,122 @@ describe('materializeTurns', () => {
});
});

function toolCallStep(turnId: string, ts: number, id: string, stepId: string, toolName = 'Read'): StoredMessage {
return { type: 'tool_call', id, turnId, ts, toolName, args: {}, stepId };
}

function assistantStep(
turnId: string,
ts: number,
id: string,
text: string,
thinking?: string,
): StoredMessage {
return {
type: 'assistant',
id,
turnId,
ts,
text,
modelId: 'm',
...(thinking !== undefined ? { thinking: { text: thinking } } : {}),
} as StoredMessage;
}

describe('materializeTurns timeline', () => {
it('interleaves each step: thinking -> text -> that step’s tools', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a1', 'step one', 'think one'),
toolCallStep('t1', 104, 'c2', 'a2'),
toolResultMsg('t1', 105, 'c2'),
assistantStep('t1', 106, 'a2', 'step two', 'think two'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['thinking', 'text', 'tools', 'thinking', 'text', 'tools']);
assert.equal((timeline[0] as { text: string }).text, 'think one');
assert.equal((timeline[1] as { text: string }).text, 'step one');
assert.equal((timeline[2] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[5] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c2');
// Aggregate fields still reflect the concatenated whole for legacy consumers.
assert.equal(turns[0]?.assistant?.text, 'step one\n\nstep two');
assert.equal(turns[0]?.assistantThinking, 'think one\n\nthink two');
});

it('renders a pure-tool step’s orphan tools before the next step’s answer', () => {
// The most common tool turn: step a1 only calls tools (no assistant row
// is persisted for it), step a2 delivers the summary. The a1 tools carry
// a stepId no assistant row matches — they must still render before the
// answer, not park past it as answer-then-tools.
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
toolResultMsg('t1', 102, 'c1'),
assistantStep('t1', 103, 'a2', 'summary', 'think'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'thinking', 'text']);
assert.equal((timeline[0] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'c1');
assert.equal((timeline[2] as { text: string }).text, 'summary');
});

it('legacy call with no stepId sits before the summary text', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallMsg('t1', 101, 'c1', 'Read'),
toolResultMsg('t1', 102, 'c1'),
assistantMsg('t1', 103, 'summary'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools', 'text']);
assert.equal((timeline[1] as { text: string }).text, 'summary');
});

it('flushes leftover tools as a trailing group when the turn has no assistant row (abort)', () => {
const turns = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
]);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['tools']);
assert.equal((timeline[0] as { items: { status: string }[] }).items[0]?.status, 'interrupted');
});

it('appends live-only in-flight tools to the timeline tail', () => {
const turns = materializeTurns(
[userMsg('t1', 100, 'q'), assistantStep('t1', 103, 'a1', 'hi')],
[{ toolUseId: 'live-1', toolName: 'Bash', status: 'running', args: {} }],
);
const timeline = turns[0]!.timeline;
assert.deepEqual(timeline.map((i) => i.kind), ['text', 'tools']);
assert.equal((timeline[1] as { items: { toolUseId: string }[] }).items[0]?.toolUseId, 'live-1');
});

it('merges adjacent thinking blocks and adjacent tool groups', () => {
const thinkingOnly = materializeTurns([
userMsg('t1', 100, 'q'),
assistantStep('t1', 101, 'a1', '', 'first'),
assistantStep('t1', 102, 'a2', '', 'second'),
]);
const tl1 = thinkingOnly[0]!.timeline;
assert.deepEqual(tl1.map((i) => i.kind), ['thinking']);
assert.equal((tl1[0] as { text: string }).text, 'first\n\nsecond');

const toolsOnly = materializeTurns([
userMsg('t1', 100, 'q'),
toolCallStep('t1', 101, 'c1', 'a1'),
assistantStep('t1', 102, 'a1', ''),
toolCallStep('t1', 103, 'c2', 'a2'),
assistantStep('t1', 104, 'a2', ''),
]);
const tl2 = toolsOnly[0]!.timeline;
assert.deepEqual(tl2.map((i) => i.kind), ['tools']);
assert.equal((tl2[0] as { items: unknown[] }).items.length, 2);
});
});

describe('deriveTurnLineageMap', () => {
it('derives reverse links without mutating old turns', () => {
const map = deriveTurnLineageMap([
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/main/__tests__/smooth-stream.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,7 +132,14 @@ describe('computeFrameAdvance', () => {
);
});

it('returns 0 when dt is non-positive', () => {
// Regression lock (streaming UI rework, blank-bubble freeze): rAF timestamps
// are vsync-aligned and lag the wall clock under IPC delta bursts, so dtMs
// clamps to 0 for every tick in the recovery window. Returning 0 left
// displayedCount unchanged → the RAF effect never re-armed → the typewriter
// died with a full backlog and the answer snapped in at stream end. With
// backlog present, a non-positive dt must still advance 1 to keep the
// single-owner RAF chain alive.
it('advances 1 (not 0) when dt is non-positive but backlog remains — keeps the RAF chain alive', () => {
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 100,
Expand All@@ -142,7 +149,7 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
0,
1,
);
assert.equal(
computeFrameAdvance({
Expand All@@ -153,6 +160,18 @@ describe('computeFrameAdvance', () => {
minCps: 30,
maxCps: 400,
}),
1,
);
// …but never invents work: zero backlog still returns 0.
assert.equal(
computeFrameAdvance({
rawGraphemeCount: 10,
displayedGraphemeCount: 10,
emaCps: 60,
dtMs: 0,
minCps: 30,
maxCps: 400,
}),
0,
);
});
Expand Down
Loading
Loading