feat: finally a good working state

This commit is contained in:
2026-02-26 11:55:21 +01:00
parent cf57879d1f
commit 121aa6a9f7
12 changed files with 585 additions and 44 deletions

View File

@@ -8,7 +8,6 @@ import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { ChatTranscript } from '../ChatSurface';
import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css';
@@ -55,7 +54,19 @@ export const AssistantSidebar: React.FC = () => {
} = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
const {
surfacesByTurn,
latestSurfaceId,
dismissedSurfaceIds,
dismissSurface,
dispatchAction,
updateLocalData,
} = useA2UISurface({ conversationId });
// Current turn index for associating streaming surfaces
const currentTurnIndex = useMemo(() => {
return messages.filter(m => m.role === 'user').length - 1;
}, [messages]);
const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
@@ -266,19 +277,16 @@ export const AssistantSidebar: React.FC = () => {
assistantRoleLabel={tr('chat.role.assistant')}
userRoleLabel={tr('chat.role.you')}
showToolMarkers={surfaceMode.showToolMarkers}
surfacesByTurn={surfacesByTurn}
latestSurfaceId={latestSurfaceId}
dismissedSurfaceIds={dismissedSurfaceIds}
onSurfaceDismiss={dismissSurface}
onSurfaceAction={dispatchAction}
onSurfaceDataChange={updateLocalData}
currentTurnIndex={currentTurnIndex}
/>
</div>
)}
{surfaces.map((surface) => (
<A2UIRenderer
key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
</div>
);
};

View File

@@ -1,11 +1,10 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import type { ChatConversation, ChatModel } from '../../types/electron';
import { useChatMessageSender } from '../../navigation/useChatMessageSender';
import { useChatSurfaceState } from '../../navigation/useChatSurfaceState';
import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode';
import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher';
import { useA2UISurface } from '../../a2ui/useA2UISurface';
import { A2UIRenderer } from '../../a2ui/A2UIRenderer';
import { useAppStore } from '../../store';
import { ChatTranscript } from '../ChatSurface';
import { useI18n } from '../../i18n';
@@ -60,7 +59,19 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
} = useChatSurfaceState();
// A2UI surface rendering
const { surfaces, dispatchAction, updateLocalData } = useA2UISurface({ conversationId });
const {
surfacesByTurn,
latestSurfaceId,
dismissedSurfaceIds,
dismissSurface,
dispatchAction,
updateLocalData,
} = useA2UISurface({ conversationId });
// Current turn index for associating streaming surfaces
const currentTurnIndex = useMemo(() => {
return messages.filter(m => m.role === 'user').length - 1;
}, [messages]);
// Scroll to bottom when messages change
const scrollToBottom = useCallback(() => {
@@ -368,18 +379,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
userRoleLabel={tr('chat.role.you')}
showToolMarkers={surfaceMode.showToolMarkers}
endRef={messagesEndRef}
surfacesByTurn={surfacesByTurn}
latestSurfaceId={latestSurfaceId}
dismissedSurfaceIds={dismissedSurfaceIds}
onSurfaceDismiss={dismissSurface}
onSurfaceAction={dispatchAction}
onSurfaceDataChange={updateLocalData}
currentTurnIndex={currentTurnIndex}
/>
{surfaces.map((surface) => (
<A2UIRenderer
key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
onAction={dispatchAction}
onDataChange={updateLocalData}
/>
))}
{actionError && <p className="chat-surface-error">{actionError}</p>}
</div>

View File

@@ -2,6 +2,10 @@ import React from 'react';
import Markdown from 'marked-react';
import type { ChatMessage } from '../../types/electron';
import type { ChatToolEvent } from '../../navigation/useChatSurfaceState';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
import { InlineSurface } from '../../a2ui/InlineSurface';
import type { SurfaceEntry } from '../../a2ui/useA2UISurface';
import { computeTurnIndex } from '../../a2ui/surfaceAssociation';
interface ChatTranscriptProps {
messages: ChatMessage[];
@@ -12,6 +16,20 @@ interface ChatTranscriptProps {
userRoleLabel: string;
showToolMarkers?: boolean;
endRef?: React.RefObject<HTMLDivElement | null>;
/** Surfaces grouped by the turn index that created them */
surfacesByTurn?: Map<number, SurfaceEntry[]>;
/** The surfaceId of the most recently created surface */
latestSurfaceId?: string | null;
/** Set of surface IDs the user has dismissed */
dismissedSurfaceIds?: Set<string>;
/** Callback to dismiss a surface */
onSurfaceDismiss?: (surfaceId: string) => void;
/** Callback for surface actions */
onSurfaceAction?: (action: A2UIClientAction) => void;
/** Callback for surface data changes */
onSurfaceDataChange?: (surfaceId: string, path: string, value: unknown) => void;
/** The current streaming turn index */
currentTurnIndex?: number;
}
export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
@@ -23,6 +41,13 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
userRoleLabel,
showToolMarkers = true,
endRef,
surfacesByTurn,
latestSurfaceId,
dismissedSurfaceIds,
onSurfaceDismiss,
onSurfaceAction,
onSurfaceDataChange,
currentTurnIndex,
}) => {
const renderToolMarkers = (events: ChatToolEvent[]) => {
if (events.length === 0) {
@@ -65,7 +90,34 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
);
};
const renderMessage = (message: ChatMessage) => {
const renderInlineSurfaces = (turnIndex: number) => {
if (!surfacesByTurn?.has(turnIndex)) {
return null;
}
const turnSurfaces = surfacesByTurn.get(turnIndex)!;
const visibleSurfaces = turnSurfaces.filter(
(s) => !dismissedSurfaceIds?.has(s.surfaceId),
);
if (visibleSurfaces.length === 0) {
return null;
}
return visibleSurfaces.map((surface) => (
<InlineSurface
key={surface.surfaceId}
surfaceId={surface.surfaceId}
tree={surface.tree}
isExpanded={surface.surfaceId === latestSurfaceId}
onDismiss={onSurfaceDismiss}
onAction={onSurfaceAction}
onDataChange={onSurfaceDataChange}
/>
));
};
const renderMessage = (message: ChatMessage, messageIndex: number) => {
if (message.role === 'system' || message.role === 'tool') {
return null;
}
@@ -80,7 +132,7 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
}
}
return (
const messageEl = (
<div key={message.id} className={`chat-message ${message.role}`}>
<div className="chat-message-avatar">
{message.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
@@ -113,28 +165,47 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
</div>
</div>
);
// After an assistant message, render any inline surfaces for this turn
if (message.role === 'assistant' && surfacesByTurn) {
const turnIndex = computeTurnIndex(messages, messageIndex);
const inlineSurfaces = renderInlineSurfaces(turnIndex);
if (inlineSurfaces) {
return (
<React.Fragment key={message.id}>
{messageEl}
{inlineSurfaces}
</React.Fragment>
);
}
}
return messageEl;
};
return (
<>
{messages.map(renderMessage)}
{messages.map((message, index) => renderMessage(message, index))}
{isStreaming && (streamingContent || (showToolMarkers && toolEvents.length > 0)) && (
<div className="chat-message assistant streaming">
<div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content">
<div className="chat-message-header">
<span className="chat-message-role">{assistantRoleLabel}</span>
<span className="streaming-indicator">{'\u25CF'}</span>
</div>
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
{streamingContent && (
<div className="chat-message-text">
<Markdown gfm>{streamingContent}</Markdown>
<>
<div className="chat-message assistant streaming">
<div className="chat-message-avatar">{'\u{1F916}'}</div>
<div className="chat-message-content">
<div className="chat-message-header">
<span className="chat-message-role">{assistantRoleLabel}</span>
<span className="streaming-indicator">{'\u25CF'}</span>
</div>
)}
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
{streamingContent && (
<div className="chat-message-text">
<Markdown gfm>{streamingContent}</Markdown>
</div>
)}
</div>
</div>
</div>
{currentTurnIndex !== undefined && renderInlineSurfaces(currentTurnIndex)}
</>
)}
{isStreaming && !streamingContent && (!showToolMarkers || toolEvents.length === 0) && (