Skip to content

Commit ae4eac6

Browse files
committed
fix: chat ui fixes
1 parent 7aa34d9 commit ae4eac6

8 files changed

Lines changed: 240 additions & 150 deletions

File tree

apps/emdash-desktop/src/renderer/features/tasks/conversations/chat/chat-store.ts

Lines changed: 26 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,6 @@ export class ChatStore {
9696
/** Bound chat-ui transcript — set when the ChatTranscript component mounts. */
9797
private _transcript: TranscriptApi | null = null;
9898

99-
/**
100-
* Accumulated thinking text keyed by thinking-id.
101-
* upsertThinking() takes full text (not deltas), so we accumulate here then pass
102-
* the growing string on each chunk.
103-
*/
104-
private _thinkingTextMap = new Map<string, string>();
105-
106-
/** Epoch ms when the first thought chunk of the current turn arrived. */
107-
private _thinkingStartedAt: number | null = null;
108-
10999
constructor(conversationId: string, projectId: string, taskId: string, initialModel?: string) {
110100
this._conversationId = conversationId;
111101
this._projectId = projectId;
@@ -156,8 +146,6 @@ export class ChatStore {
156146
if (payload.phase === 'start') {
157147
this.items = [];
158148
this._streamKeyMap.clear();
159-
this._thinkingTextMap.clear();
160-
this._thinkingStartedAt = null;
161149
this.isClosed = false;
162150
this._transcript?.reset();
163151
} else {
@@ -202,8 +190,8 @@ export class ChatStore {
202190
text,
203191
streaming: false,
204192
});
205-
this._transcript?.appendMessageChunk('user', userId, text);
206-
this._transcript?.finalizeTurn();
193+
this._transcript?.dispatch({ type: 'message_chunk', role: 'user', id: userId, text });
194+
this._transcript?.dispatch({ type: 'turn_done' });
207195

208196
void rpc.acp
209197
.prompt(this._conversationId, text)
@@ -274,26 +262,24 @@ export class ChatStore {
274262
const existing = this._streamKeyMap.get(streamKey);
275263
if (existing) {
276264
existing.text += text;
277-
this._transcript?.appendMessageChunk(role, messageId, text);
278-
return;
265+
} else {
266+
const created = this._pushMessage(role, text);
267+
this._streamKeyMap.set(streamKey, created);
279268
}
280-
const created = this._pushMessage(role, text);
281-
this._streamKeyMap.set(streamKey, created);
282-
this._transcript?.appendMessageChunk(role, messageId, text);
269+
this._transcript?.dispatch({ type: 'message_chunk', role, id: messageId, text });
283270
return;
284271
}
285272

286273
// No messageId: append to the trailing bubble if it's the same role and still
287274
// streaming; otherwise start a new bubble.
288275
const last = this.items.at(-1);
289-
const syntheticId = `${role}-${Date.now()}-${Math.random()}`;
290276
if (last && last.kind === 'message' && last.role === role && last.streaming) {
291277
last.text += text;
292-
this._transcript?.appendMessageChunk(role, last.id, text);
278+
this._transcript?.dispatch({ type: 'message_chunk', role, id: last.id, text });
293279
return;
294280
}
295-
this._pushMessage(role, text);
296-
this._transcript?.appendMessageChunk(role, syntheticId, text);
281+
const created = this._pushMessage(role, text);
282+
this._transcript?.dispatch({ type: 'message_chunk', role, id: created.id, text });
297283
}
298284

299285
private _appendThinkingChunk(
@@ -303,7 +289,7 @@ export class ChatStore {
303289
chunk.content.type === 'text' ? ((chunk.content as { text?: string }).text ?? '') : '';
304290
const thinkingId = chunk.messageId ?? `thought-${Date.now()}`;
305291

306-
// Mirror into items[] as a legacy thought message for hydration fallback.
292+
// Mirror into items[] as a legacy thought message for seed() hydration.
307293
const streamKey = `thought:${thinkingId}`;
308294
const existing = this._streamKeyMap.get(streamKey);
309295
if (existing) {
@@ -313,18 +299,8 @@ export class ChatStore {
313299
this._streamKeyMap.set(streamKey, created);
314300
}
315301

316-
// Drive TranscriptApi with full accumulated text (upsertThinking is not delta).
317-
const accumulated = (this._thinkingTextMap.get(thinkingId) ?? '') + text;
318-
this._thinkingTextMap.set(thinkingId, accumulated);
319-
if (this._thinkingStartedAt === null) {
320-
this._thinkingStartedAt = Date.now();
321-
}
322-
this._transcript?.upsertThinking({
323-
id: thinkingId,
324-
text: accumulated,
325-
status: 'thinking',
326-
startedAt: this._thinkingStartedAt,
327-
});
302+
// The transcript reducer owns startedAt and text accumulation — just dispatch the delta.
303+
this._transcript?.dispatch({ type: 'thinking_chunk', id: thinkingId, text });
328304
}
329305

330306
private _pushMessage(
@@ -358,6 +334,13 @@ export class ChatStore {
358334
if (patch.toolName !== undefined) existing.toolName = patch.toolName;
359335
if (patch.status !== undefined) existing.status = patch.status;
360336
if (patch.inputSummary !== undefined) existing.inputSummary = patch.inputSummary;
337+
this._transcript?.dispatch({
338+
type: 'tool_update',
339+
id: patch.toolCallId,
340+
status: patch.status,
341+
name: patch.toolName,
342+
inputSummary: patch.inputSummary,
343+
});
361344
} else {
362345
this.items.push({
363346
kind: 'tool',
@@ -367,13 +350,13 @@ export class ChatStore {
367350
status: patch.status ?? 'running',
368351
inputSummary: patch.inputSummary,
369352
});
353+
this._transcript?.dispatch({
354+
type: 'tool_start',
355+
id: patch.toolCallId,
356+
name: patch.toolName ?? '(tool)',
357+
inputSummary: patch.inputSummary,
358+
});
370359
}
371-
this._transcript?.upsertTool({
372-
id: patch.toolCallId,
373-
name: patch.toolName ?? '(tool)',
374-
status: patch.status ?? 'running',
375-
inputSummary: patch.inputSummary,
376-
});
377360
}
378361

379362
/** Marks all streaming message bubbles as settled and commits the active turn. */
@@ -382,9 +365,7 @@ export class ChatStore {
382365
if (item.kind === 'message') item.streaming = false;
383366
}
384367
this._streamKeyMap.clear();
385-
this._thinkingTextMap.clear();
386-
this._thinkingStartedAt = null;
387-
this._transcript?.finalizeTurn();
368+
this._transcript?.dispatch({ type: 'turn_done' });
388369
}
389370

390371
private _summarizeInput(input: unknown): string | undefined {

apps/emdash-desktop/src/renderer/features/tasks/view/pane-content.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useDroppable } from '@dnd-kit/core';
22
import { observer } from 'mobx-react-lite';
3+
import { useRef } from 'react';
34
import { BrowserPane } from '@renderer/features/browser/browser-pane';
45
import { ShowHide } from '@renderer/lib/ui/show-hide';
56
import { ChatPanel } from '../conversations/chat/chat-panel';
@@ -20,6 +21,19 @@ export const PaneContent = observer(function PaneContent() {
2021

2122
const paneRenderer = resolvePaneRenderer(paneTabManager);
2223

24+
// Chat tabs are kept alive using visibility-based stacking so that:
25+
// 1. Each conversation gets its own ChatPanel instance, keyed by conversationId,
26+
// ensuring the transcript is bound to the correct ChatStore on mount.
27+
// 2. Switching tabs toggles visibility (not display), preserving scroll position
28+
// and the virtualizer's scroll signal — no desync, no flicker, no re-seed.
29+
// activatedSet tracks which chats have been viewed at least once (lazy mount).
30+
// Must be called unconditionally (Rules of Hooks) before any early return.
31+
const chatTabs = paneTabManager.resolvedTabs.filter((t) => t.kind === 'chat');
32+
const activatedSetRef = useRef<Set<string>>(new Set());
33+
for (const t of chatTabs) {
34+
if (t.isActive) activatedSetRef.current.add(t.conversationId);
35+
}
36+
2337
if (!paneRenderer) {
2438
return <PaneEmptyState />;
2539
}
@@ -34,7 +48,23 @@ export const PaneContent = observer(function PaneContent() {
3448
<ShowHide visible={paneRenderer.kind === 'pty-agent'}>
3549
<ConversationsPanel />
3650
</ShowHide>
37-
{paneRenderer.kind === 'chat' && <ChatPanel conversationId={paneRenderer.conversationId} />}
51+
52+
{/* Chat pane pool — one ChatPanel per opened chat tab, visibility-toggled */}
53+
{chatTabs
54+
.filter((t) => activatedSetRef.current.has(t.conversationId))
55+
.map((t) => (
56+
<div
57+
key={t.conversationId}
58+
className="absolute inset-0"
59+
style={{
60+
visibility: t.isActive ? 'visible' : 'hidden',
61+
zIndex: t.isActive ? 1 : 0,
62+
}}
63+
>
64+
<ChatPanel conversationId={t.conversationId} />
65+
</div>
66+
))}
67+
3868
{paneRenderer.kind === 'browser' && <BrowserPane browserId={paneRenderer.browserId} />}
3969
{paneRenderer.kind === 'file' && <FileRenderer tab={paneRenderer.tab} />}
4070
{paneRenderer.kind === 'file-diff' && <DiffView tab={paneRenderer.tab} />}

packages/chat-ui/src/ChatRoot.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ export function ChatRoot(props: ChatRootProps) {
201201
};
202202

203203
const onScroll = () => {
204+
// Ignore events fired while the element is detached (display:none resets
205+
// scrollTop to 0 without firing a real user scroll; let the saved signal
206+
// remain authoritative so the virtualizer position is preserved).
207+
if (scrollEl.offsetParent === null) return;
204208
// Ignore the event we triggered ourselves during anchor correction;
205209
// the scrollTop signal was already updated synchronously there.
206210
if (programmaticScroll) {

packages/chat-ui/src/chat.module.css

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@
2828
left: 0;
2929
width: 100%;
3030
will-change: transform;
31-
content-visibility: auto;
32-
/* Placeholder size for rows skipped by content-visibility, so the browser
33-
doesn't have to lay them out to discover their height. */
34-
contain-intrinsic-size: auto 60px;
3531
/* Isolate paint/layout/style recalc within each row. NOT `contain: size`
36-
so offsetHeight measurements remain correct. */
32+
so offsetHeight measurements remain correct.
33+
34+
NB: `content-visibility: auto` is intentionally NOT used here. The JS
35+
virtualizer already keeps only visible + overscan rows in the DOM, so
36+
browser-level culling is redundant — and it actively broke tall rows: a
37+
message taller than the `contain-intrinsic-size` placeholder whose top sat
38+
above the viewport was judged off-screen and left blank until its top edge
39+
scrolled into view. */
3740
contain: layout paint style;
3841
}

packages/chat-ui/src/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { createViewState } from './state/view-state';
1515
import type { ViewState } from './state/view-state';
1616

1717
export type { ChatItem, ChatMessage, ChatToolCall, ChatThinking, ChatRole } from './model';
18-
export type { TranscriptApi } from './state/transcript';
18+
export type { TranscriptApi, TranscriptEvent } from './state/transcript';
1919
export type { ViewState } from './state/view-state';
2020
export { generateMockTranscript } from './mock-transcript';
2121

0 commit comments

Comments
 (0)