@assistkick/create 1.33.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/src/server.ts +1 -1
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +41 -16
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +25 -5
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx +3 -3
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx +3 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +27 -8
- package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx +3 -3
- package/templates/assistkick-product-system/packages/frontend/src/hooks/use_chat_stream.ts +102 -114
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts +105 -0
- package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts +48 -1
- package/templates/assistkick-product-system/packages/shared/db/migrate.ts +25 -0
package/package.json
CHANGED
|
@@ -269,7 +269,7 @@ const chatWss = new WebSocketServer({ noServer: true });
|
|
|
269
269
|
const chatCliBridge = new ChatCliBridge({ projectRoot: paths.projectRoot, workspacesDir: paths.workspacesDir, dataDir: paths.runtimeDataDir, log });
|
|
270
270
|
const permissionService = new PermissionService({ getDb, log });
|
|
271
271
|
const titleGeneratorService = new TitleGeneratorService({ log });
|
|
272
|
-
const chatHandler = new ChatWsHandler({ wss: chatWss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, log });
|
|
272
|
+
const chatHandler = new ChatWsHandler({ wss: chatWss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, agentService, log });
|
|
273
273
|
|
|
274
274
|
// Resume any CLI sessions left running by the previous server instance
|
|
275
275
|
chatHandler.resumeSessions();
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts
CHANGED
|
@@ -22,6 +22,26 @@ import { tmpdir } from 'node:os';
|
|
|
22
22
|
|
|
23
23
|
export type PermissionMode = 'skip' | 'allowed_tools';
|
|
24
24
|
|
|
25
|
+
/** The well-known ID for the default chat agent (seeded in migrate.ts). */
|
|
26
|
+
export const CHAT_AGENT_ID = 'chat-agent-default';
|
|
27
|
+
|
|
28
|
+
/** Fallback grounding used when no chat agent is found in the database. */
|
|
29
|
+
const DEFAULT_CHAT_GROUNDING = `We are working on project-id {{projectId}}
|
|
30
|
+
|
|
31
|
+
## Project Workspace
|
|
32
|
+
The project workspace is at: {{workspacePath}}
|
|
33
|
+
All file edits (write, edit, delete, create) MUST target files inside the workspace directory above.
|
|
34
|
+
You may read files from anywhere in the project root for context, but all changes go into the workspace.
|
|
35
|
+
|
|
36
|
+
## Git Repository Structure
|
|
37
|
+
The workspace ({{workspacePath}}) has its OWN independent git repo.
|
|
38
|
+
When committing and pushing changes, always use the workspace git repo, not the top-level repo.
|
|
39
|
+
|
|
40
|
+
## Skills
|
|
41
|
+
Project skills (e.g. assistkick-interview, assistkick-developer, etc.) are available via the Skill tool.
|
|
42
|
+
To invoke a skill, always use the Skill tool (e.g. Skill with skill: "assistkick-interview"). Do NOT manually read or execute skill files — the Skill tool handles loading and execution.
|
|
43
|
+
Only edit code and project files — never modify skill definition files.`;
|
|
44
|
+
|
|
25
45
|
export interface ChatCliBridgeDeps {
|
|
26
46
|
projectRoot: string;
|
|
27
47
|
workspacesDir: string;
|
|
@@ -40,6 +60,8 @@ export interface SpawnOptions {
|
|
|
40
60
|
continuationContext?: string;
|
|
41
61
|
/** Path to a temporary MCP config JSON file to pass via --mcp-config */
|
|
42
62
|
mcpConfigPath?: string;
|
|
63
|
+
/** Agent grounding template with {{placeholder}} variables. When provided, replaces the hardcoded system prompt. */
|
|
64
|
+
agentGrounding?: string;
|
|
43
65
|
onEvent: (event: Record<string, unknown>) => void;
|
|
44
66
|
}
|
|
45
67
|
|
|
@@ -117,7 +139,7 @@ export class ChatCliBridge {
|
|
|
117
139
|
};
|
|
118
140
|
|
|
119
141
|
buildArgs = (options: SpawnOptions): { args: string[]; systemPrompt: string | null } => {
|
|
120
|
-
const { projectId, claudeSessionId, isNewSession, permissionMode, allowedTools, continuationContext } = options;
|
|
142
|
+
const { projectId, claudeSessionId, isNewSession, permissionMode, allowedTools, continuationContext, agentGrounding } = options;
|
|
121
143
|
const args: string[] = [];
|
|
122
144
|
let systemPrompt: string | null = null;
|
|
123
145
|
|
|
@@ -150,22 +172,17 @@ export class ChatCliBridge {
|
|
|
150
172
|
if (continuationContext) {
|
|
151
173
|
systemParts.push(continuationContext);
|
|
152
174
|
}
|
|
153
|
-
const wsPath = this.getWorkspacePath(projectId);
|
|
154
|
-
systemParts.push(`We are working on project-id ${projectId}
|
|
155
|
-
|
|
156
|
-
## Project Workspace
|
|
157
|
-
The project workspace is at: ${wsPath}
|
|
158
|
-
All file edits (write, edit, delete, create) MUST target files inside the workspace directory above.
|
|
159
|
-
You may read files from anywhere in the project root for context, but all changes go into the workspace.
|
|
160
175
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
const wsPath = this.getWorkspacePath(projectId);
|
|
177
|
+
const vars: Record<string, string> = { projectId, workspacePath: wsPath };
|
|
178
|
+
|
|
179
|
+
if (agentGrounding) {
|
|
180
|
+
// Use agent grounding with resolved {{placeholder}} variables
|
|
181
|
+
systemParts.push(this.resolveTemplate(agentGrounding, vars));
|
|
182
|
+
} else {
|
|
183
|
+
// Fallback: hardcoded default (should not happen once chat agent is seeded)
|
|
184
|
+
systemParts.push(this.resolveTemplate(DEFAULT_CHAT_GROUNDING, vars));
|
|
185
|
+
}
|
|
169
186
|
|
|
170
187
|
systemPrompt = systemParts.join('\n\n');
|
|
171
188
|
args.push('--append-system-prompt', systemPrompt);
|
|
@@ -174,6 +191,14 @@ export class ChatCliBridge {
|
|
|
174
191
|
return { args, systemPrompt };
|
|
175
192
|
};
|
|
176
193
|
|
|
194
|
+
/** Replace {{placeholder}} variables in a template string. */
|
|
195
|
+
private resolveTemplate = (template: string, vars: Record<string, string>): string => {
|
|
196
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
|
197
|
+
if (key in vars) return vars[key];
|
|
198
|
+
return `{{${key}}}`;
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
177
202
|
buildEnv = (): Record<string, string> => {
|
|
178
203
|
const env = { ...process.env } as Record<string, string>;
|
|
179
204
|
const home = env.HOME || '/root';
|
package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Server → Client messages:
|
|
14
14
|
* { type: 'stream_start', claudeSessionId }
|
|
15
|
-
* { type: 'stream_event', event }
|
|
15
|
+
* { type: 'stream_event', event, claudeSessionId } — forwarded parsed stream-json event
|
|
16
16
|
* { type: 'stream_end', exitCode, claudeSessionId }
|
|
17
17
|
* { type: 'stream_cancelled', claudeSessionId } — user-initiated stop
|
|
18
18
|
* { type: 'stream_error', error }
|
|
@@ -30,6 +30,8 @@ import type { ChatMessageRepository } from './chat_message_repository.js';
|
|
|
30
30
|
import type { ChatSessionService } from './chat_session_service.js';
|
|
31
31
|
import type { TitleGeneratorService } from './title_generator_service.js';
|
|
32
32
|
import type { McpConfigService } from './mcp_config_service.js';
|
|
33
|
+
import type { AgentService } from './agent_service.js';
|
|
34
|
+
import { CHAT_AGENT_ID } from './chat_cli_bridge.js';
|
|
33
35
|
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
34
36
|
import { tmpdir } from 'node:os';
|
|
35
37
|
import { join } from 'node:path';
|
|
@@ -43,6 +45,7 @@ export interface ChatWsHandlerDeps {
|
|
|
43
45
|
chatSessionService?: ChatSessionService;
|
|
44
46
|
titleGeneratorService?: TitleGeneratorService;
|
|
45
47
|
mcpConfigService?: McpConfigService;
|
|
48
|
+
agentService?: AgentService;
|
|
46
49
|
log: (tag: string, ...args: unknown[]) => void;
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -90,6 +93,7 @@ export interface StreamStartMessage {
|
|
|
90
93
|
export interface StreamEventMessage {
|
|
91
94
|
type: 'stream_event';
|
|
92
95
|
event: Record<string, unknown>;
|
|
96
|
+
claudeSessionId: string;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export interface StreamEndMessage {
|
|
@@ -243,6 +247,7 @@ export class ChatWsHandler {
|
|
|
243
247
|
private readonly chatSessionService: ChatSessionService | null;
|
|
244
248
|
private readonly titleGeneratorService: TitleGeneratorService | null;
|
|
245
249
|
private readonly mcpConfigService: McpConfigService | null;
|
|
250
|
+
private readonly agentService: AgentService | null;
|
|
246
251
|
private readonly log: ChatWsHandlerDeps['log'];
|
|
247
252
|
private readonly activeStreams = new Map<WebSocket, Set<string>>();
|
|
248
253
|
|
|
@@ -258,7 +263,7 @@ export class ChatWsHandler {
|
|
|
258
263
|
/** Map WebSocket connections to their authenticated user ID. */
|
|
259
264
|
private readonly wsUserIds = new Map<WebSocket, string>();
|
|
260
265
|
|
|
261
|
-
constructor({ wss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, log }: ChatWsHandlerDeps) {
|
|
266
|
+
constructor({ wss, authService, chatCliBridge, permissionService, chatMessageRepository, chatSessionService, titleGeneratorService, mcpConfigService, agentService, log }: ChatWsHandlerDeps) {
|
|
262
267
|
this.wss = wss;
|
|
263
268
|
this.authService = authService;
|
|
264
269
|
this.chatCliBridge = chatCliBridge;
|
|
@@ -267,6 +272,7 @@ export class ChatWsHandler {
|
|
|
267
272
|
this.chatSessionService = chatSessionService || null;
|
|
268
273
|
this.titleGeneratorService = titleGeneratorService || null;
|
|
269
274
|
this.mcpConfigService = mcpConfigService || null;
|
|
275
|
+
this.agentService = agentService || null;
|
|
270
276
|
this.log = log;
|
|
271
277
|
|
|
272
278
|
// Register the permission request handler to route requests to the right WebSocket
|
|
@@ -285,7 +291,7 @@ export class ChatWsHandler {
|
|
|
285
291
|
// Forward to connected WS (if any)
|
|
286
292
|
const currentWs = this.sessionToWs.get(claudeSessionId);
|
|
287
293
|
if (currentWs) {
|
|
288
|
-
this.sendWsMessage(currentWs, { type: 'stream_event', event });
|
|
294
|
+
this.sendWsMessage(currentWs, { type: 'stream_event', event, claudeSessionId });
|
|
289
295
|
}
|
|
290
296
|
// Accumulate content in stream context
|
|
291
297
|
this.accumulateEvent(claudeSessionId, event);
|
|
@@ -524,6 +530,19 @@ export class ChatWsHandler {
|
|
|
524
530
|
}
|
|
525
531
|
}
|
|
526
532
|
|
|
533
|
+
// Load chat agent grounding from database (if available)
|
|
534
|
+
let agentGrounding: string | undefined;
|
|
535
|
+
if (isNewSession && this.agentService) {
|
|
536
|
+
try {
|
|
537
|
+
const chatAgent = await this.agentService.getById(CHAT_AGENT_ID);
|
|
538
|
+
if (chatAgent) {
|
|
539
|
+
agentGrounding = chatAgent.grounding;
|
|
540
|
+
}
|
|
541
|
+
} catch (err: any) {
|
|
542
|
+
this.log('CHAT_WS', `Failed to load chat agent grounding: ${err.message}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
527
546
|
try {
|
|
528
547
|
const { completion, systemPrompt } = this.chatCliBridge.spawn({
|
|
529
548
|
projectId,
|
|
@@ -534,11 +553,12 @@ export class ChatWsHandler {
|
|
|
534
553
|
allowedTools: msg.allowedTools,
|
|
535
554
|
continuationContext,
|
|
536
555
|
mcpConfigPath,
|
|
556
|
+
agentGrounding,
|
|
537
557
|
onEvent: (event) => {
|
|
538
558
|
// Forward to current WS (may have been re-attached after disconnect)
|
|
539
559
|
const currentWs = this.sessionToWs.get(claudeSessionId);
|
|
540
560
|
if (currentWs) {
|
|
541
|
-
this.sendWsMessage(currentWs, { type: 'stream_event', event });
|
|
561
|
+
this.sendWsMessage(currentWs, { type: 'stream_event', event, claudeSessionId });
|
|
542
562
|
}
|
|
543
563
|
this.accumulateEvent(claudeSessionId, event);
|
|
544
564
|
},
|
|
@@ -939,7 +959,7 @@ export class ChatWsHandler {
|
|
|
939
959
|
type: 'assistant',
|
|
940
960
|
message: { content: JSON.parse(ctx.lastAssistantContent) },
|
|
941
961
|
};
|
|
942
|
-
this.sendWsMessage(ws, { type: 'stream_event', event: catchUpEvent });
|
|
962
|
+
this.sendWsMessage(ws, { type: 'stream_event', event: catchUpEvent, claudeSessionId });
|
|
943
963
|
}
|
|
944
964
|
|
|
945
965
|
this.log('CHAT_WS', `Client re-attached to in-flight stream for session ${claudeSessionId}`);
|
package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageBubble.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* text blocks, tool use cards, and tool result blocks.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { useState, useCallback } from 'react';
|
|
12
|
+
import { memo, useState, useCallback } from 'react';
|
|
13
13
|
import type { ChatMessage, TextBlock, ImageBlock, FileBlock } from '../hooks/use_chat_stream';
|
|
14
14
|
import { ChatMessageContent } from './ChatMessageContent';
|
|
15
15
|
import { HighlightedText } from './HighlightedText';
|
|
@@ -41,12 +41,12 @@ const getFileTypeLabel = (mimeType: string): string => {
|
|
|
41
41
|
return 'FILE';
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
export function ChatMessageBubble({ message, searchQuery = '', activeMatchIndex = -1, matchIndexOffset = 0 }: ChatMessageBubbleProps) {
|
|
44
|
+
export const ChatMessageBubble = memo(function ChatMessageBubble({ message, searchQuery = '', activeMatchIndex = -1, matchIndexOffset = 0 }: ChatMessageBubbleProps) {
|
|
45
45
|
if (message.role === 'user') {
|
|
46
46
|
return <UserBubble message={message} searchQuery={searchQuery} activeMatchIndex={activeMatchIndex} matchIndexOffset={matchIndexOffset} />;
|
|
47
47
|
}
|
|
48
48
|
return <AssistantBubble message={message} searchQuery={searchQuery} activeMatchIndex={activeMatchIndex} matchIndexOffset={matchIndexOffset} />;
|
|
49
|
-
}
|
|
49
|
+
});
|
|
50
50
|
|
|
51
51
|
function UserBubble({ message, searchQuery, activeMatchIndex, matchIndexOffset }: { message: ChatMessage; searchQuery: string; activeMatchIndex: number; matchIndexOffset: number }) {
|
|
52
52
|
const textBlock = message.content.find((b) => b.type === 'text') as TextBlock | undefined;
|
package/templates/assistkick-product-system/packages/frontend/src/components/ChatMessageContent.tsx
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - ToolResultBlock → collapsed ToolResultCard (shows tool output)
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { memo } from 'react';
|
|
10
11
|
import ReactMarkdown from 'react-markdown';
|
|
11
12
|
import remarkGfm from 'remark-gfm';
|
|
12
13
|
import type { ContentBlock, ToolResultBlock, ToolUseBlock } from '../hooks/use_chat_stream';
|
|
@@ -67,7 +68,7 @@ const chatMarkdownClass = [
|
|
|
67
68
|
].join(' ');
|
|
68
69
|
|
|
69
70
|
|
|
70
|
-
export const ChatMessageContent = ({ content, isStreaming, searchQuery = '', activeMatchIndex = -1, matchIndexOffset = 0 }: ChatMessageContentProps) => {
|
|
71
|
+
export const ChatMessageContent = memo(({ content, isStreaming, searchQuery = '', activeMatchIndex = -1, matchIndexOffset = 0 }: ChatMessageContentProps) => {
|
|
71
72
|
const resultMap = buildResultMap(content);
|
|
72
73
|
|
|
73
74
|
// Pre-compute per-block match offsets so each text block knows its global offset
|
|
@@ -139,4 +140,4 @@ export const ChatMessageContent = ({ content, isStreaming, searchQuery = '', act
|
|
|
139
140
|
})}
|
|
140
141
|
</div>
|
|
141
142
|
);
|
|
142
|
-
};
|
|
143
|
+
});
|
|
@@ -75,6 +75,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
75
75
|
loadMessages,
|
|
76
76
|
attachSession,
|
|
77
77
|
messages,
|
|
78
|
+
streamingMessage,
|
|
78
79
|
streaming,
|
|
79
80
|
connected,
|
|
80
81
|
error,
|
|
@@ -136,12 +137,18 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
136
137
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
137
138
|
const sessionRestoredRef = useRef(false);
|
|
138
139
|
|
|
140
|
+
// Combined messages for search and sidebar (historical + streaming)
|
|
141
|
+
const allMessages = useMemo(
|
|
142
|
+
() => streamingMessage ? [...messages, streamingMessage] : messages,
|
|
143
|
+
[messages, streamingMessage],
|
|
144
|
+
);
|
|
145
|
+
|
|
139
146
|
// --- Search: compute total matches and per-message offsets ---
|
|
140
147
|
const { totalMatches, messageOffsets } = useMemo(() => {
|
|
141
148
|
if (!searchQuery) return { totalMatches: 0, messageOffsets: [] as number[] };
|
|
142
149
|
let total = 0;
|
|
143
150
|
const offsets: number[] = [];
|
|
144
|
-
for (const msg of
|
|
151
|
+
for (const msg of allMessages) {
|
|
145
152
|
offsets.push(total);
|
|
146
153
|
for (const block of msg.content) {
|
|
147
154
|
if (block.type === 'text') {
|
|
@@ -150,7 +157,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
159
|
return { totalMatches: total, messageOffsets: offsets };
|
|
153
|
-
}, [
|
|
160
|
+
}, [allMessages, searchQuery]);
|
|
154
161
|
|
|
155
162
|
// Reset active match when query changes
|
|
156
163
|
useEffect(() => {
|
|
@@ -320,7 +327,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
320
327
|
// Auto-scroll to bottom when new messages arrive
|
|
321
328
|
useEffect(() => {
|
|
322
329
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
323
|
-
}, [messages, queue]);
|
|
330
|
+
}, [messages, streamingMessage, queue]);
|
|
324
331
|
|
|
325
332
|
// Keep a ref to sessions so the onStreamEnd callback can look up session details
|
|
326
333
|
// without stale closure issues.
|
|
@@ -366,7 +373,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
366
373
|
// Save current session's input, messages, context usage, and system prompt
|
|
367
374
|
if (activeSessionId) {
|
|
368
375
|
inputCacheRef.current.set(activeSessionId, inputValue);
|
|
369
|
-
messagesCacheRef.current.set(activeSessionId, messages);
|
|
376
|
+
messagesCacheRef.current.set(activeSessionId, streamingMessage ? [...messages, streamingMessage] : messages);
|
|
370
377
|
if (contextUsage) contextUsageCacheRef.current.set(activeSessionId, contextUsage);
|
|
371
378
|
if (systemPrompt) systemPromptCacheRef.current.set(activeSessionId, systemPrompt);
|
|
372
379
|
}
|
|
@@ -424,7 +431,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
424
431
|
});
|
|
425
432
|
}
|
|
426
433
|
},
|
|
427
|
-
[activeSessionId, inputValue, messages, contextUsage, systemPrompt, clearMessages, restoreMessages, clearAttachments, loadMessages, attachSession, initContextUsage, initSystemPrompt, setQueueActiveSession],
|
|
434
|
+
[activeSessionId, inputValue, messages, streamingMessage, contextUsage, systemPrompt, clearMessages, restoreMessages, clearAttachments, loadMessages, attachSession, initContextUsage, initSystemPrompt, setQueueActiveSession],
|
|
428
435
|
);
|
|
429
436
|
|
|
430
437
|
// Handle selecting a newly created session
|
|
@@ -433,7 +440,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
433
440
|
// Save current session's state before switching
|
|
434
441
|
if (activeSessionId) {
|
|
435
442
|
inputCacheRef.current.set(activeSessionId, inputValue);
|
|
436
|
-
messagesCacheRef.current.set(activeSessionId, messages);
|
|
443
|
+
messagesCacheRef.current.set(activeSessionId, streamingMessage ? [...messages, streamingMessage] : messages);
|
|
437
444
|
if (contextUsage) contextUsageCacheRef.current.set(activeSessionId, contextUsage);
|
|
438
445
|
if (systemPrompt) systemPromptCacheRef.current.set(activeSessionId, systemPrompt);
|
|
439
446
|
}
|
|
@@ -445,7 +452,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
445
452
|
setIsNewSession(true);
|
|
446
453
|
setInputValue('');
|
|
447
454
|
},
|
|
448
|
-
[activeSessionId, inputValue, messages, contextUsage, systemPrompt, clearMessages, clearAttachments, initSystemPrompt, setQueueActiveSession],
|
|
455
|
+
[activeSessionId, inputValue, messages, streamingMessage, contextUsage, systemPrompt, clearMessages, clearAttachments, initSystemPrompt, setQueueActiveSession],
|
|
449
456
|
);
|
|
450
457
|
|
|
451
458
|
// File handling for attachments
|
|
@@ -691,6 +698,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
691
698
|
<SystemPromptAccordion prompt={displayedSystemPrompt} />
|
|
692
699
|
)}
|
|
693
700
|
|
|
701
|
+
{/* Historical messages — stable array, memoized components skip re-renders */}
|
|
694
702
|
{messages.map((msg, idx) => (
|
|
695
703
|
<ChatMessageBubble
|
|
696
704
|
key={msg.id}
|
|
@@ -701,6 +709,17 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
701
709
|
/>
|
|
702
710
|
))}
|
|
703
711
|
|
|
712
|
+
{/* Streaming message — isolated, only this re-renders per token */}
|
|
713
|
+
{streamingMessage && (
|
|
714
|
+
<ChatMessageBubble
|
|
715
|
+
key={streamingMessage.id}
|
|
716
|
+
message={streamingMessage}
|
|
717
|
+
searchQuery={searchQuery}
|
|
718
|
+
activeMatchIndex={activeMatchIndex}
|
|
719
|
+
matchIndexOffset={messageOffsets[messages.length] ?? 0}
|
|
720
|
+
/>
|
|
721
|
+
)}
|
|
722
|
+
|
|
704
723
|
{/* Queued messages */}
|
|
705
724
|
{queue.map((qm) => (
|
|
706
725
|
<QueuedMessageBubble
|
|
@@ -716,7 +735,7 @@ export function ChatView({ visible }: ChatViewProps) {
|
|
|
716
735
|
</div>
|
|
717
736
|
|
|
718
737
|
{/* Todo sidebar — shown when tasks exist */}
|
|
719
|
-
<ChatTodoSidebar messages={
|
|
738
|
+
<ChatTodoSidebar messages={allMessages} />
|
|
720
739
|
</div>
|
|
721
740
|
|
|
722
741
|
{/* Error banner */}
|
package/templates/assistkick-product-system/packages/frontend/src/components/ToolUseCard.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* content-secondary text, monospace font at 13px.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { useState } from 'react';
|
|
10
|
+
import { memo, useState } from 'react';
|
|
11
11
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
12
12
|
import { summarizeToolUse, toolIcon } from '../lib/tool_use_summary';
|
|
13
13
|
import { ToolDiffView } from './ToolDiffView';
|
|
@@ -64,7 +64,7 @@ const hasIntegratedView = (name: string): boolean =>
|
|
|
64
64
|
const hasGenericView = (name: string): boolean =>
|
|
65
65
|
!['Edit', 'Write', 'Read', 'Bash', 'Agent'].includes(name);
|
|
66
66
|
|
|
67
|
-
export const ToolUseCard = ({ name, input, isStreaming, result }: ToolUseCardProps) => {
|
|
67
|
+
export const ToolUseCard = memo(({ name, input, isStreaming, result }: ToolUseCardProps) => {
|
|
68
68
|
const [expanded, setExpanded] = useState(false);
|
|
69
69
|
const [viewTab, setViewTab] = useState<ViewTab>('humanized');
|
|
70
70
|
const summary = summarizeToolUse(name, input);
|
|
@@ -160,4 +160,4 @@ export const ToolUseCard = ({ name, input, isStreaming, result }: ToolUseCardPro
|
|
|
160
160
|
)}
|
|
161
161
|
</div>
|
|
162
162
|
);
|
|
163
|
-
};
|
|
163
|
+
});
|
|
@@ -25,7 +25,7 @@ import {useRef, useState, useCallback} from 'react';
|
|
|
25
25
|
import type {PermissionDecision} from '../components/PermissionDialog';
|
|
26
26
|
import {apiClient} from '../api/client';
|
|
27
27
|
import {extractContextUsage} from '../lib/context_usage_helpers';
|
|
28
|
-
import {markOrCreateStreamingAssistant} from '../lib/chat_message_helpers';
|
|
28
|
+
import {markOrCreateStreamingAssistant, mergeAssistantContent} from '../lib/chat_message_helpers';
|
|
29
29
|
import {useChatSessionStore} from '../stores/useChatSessionStore';
|
|
30
30
|
|
|
31
31
|
// --- Server → Client message types ---
|
|
@@ -38,6 +38,7 @@ interface StreamStartMessage {
|
|
|
38
38
|
interface StreamEventMessage {
|
|
39
39
|
type: 'stream_event';
|
|
40
40
|
event: Record<string, unknown>;
|
|
41
|
+
claudeSessionId: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
interface StreamEndMessage {
|
|
@@ -213,6 +214,8 @@ export interface UseChatStreamReturn {
|
|
|
213
214
|
/** Attempt to re-attach to an in-flight stream after page reload. */
|
|
214
215
|
attachSession: (claudeSessionId: string) => void;
|
|
215
216
|
messages: ChatMessage[];
|
|
217
|
+
/** The currently streaming assistant message (separate from messages for render isolation). */
|
|
218
|
+
streamingMessage: ChatMessage | null;
|
|
216
219
|
streaming: boolean;
|
|
217
220
|
connected: boolean;
|
|
218
221
|
error: string | null;
|
|
@@ -349,9 +352,16 @@ const normalizeContentBlock = (block: Record<string, unknown>): ContentBlock | n
|
|
|
349
352
|
export const useChatStream = (): UseChatStreamReturn => {
|
|
350
353
|
const wsRef = useRef<WebSocket | null>(null);
|
|
351
354
|
const streamEndCallbackRef = useRef<(() => void) | null>(null);
|
|
355
|
+
/** Tracks the claudeSessionId that this hook is actively streaming for. */
|
|
356
|
+
const activeClaudeSessionRef = useRef<string | null>(null);
|
|
352
357
|
const [connected, setConnected] = useState(false);
|
|
353
358
|
const [streaming, setStreaming] = useState(false);
|
|
354
359
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
360
|
+
/** The currently streaming assistant message, isolated from historical messages
|
|
361
|
+
* so that per-token updates don't trigger re-renders of the entire message list. */
|
|
362
|
+
const [streamingMessage, setStreamingMessage] = useState<ChatMessage | null>(null);
|
|
363
|
+
/** Ref mirror of streamingMessage for synchronous reads in event handlers. */
|
|
364
|
+
const streamingMessageRef = useRef<ChatMessage | null>(null);
|
|
355
365
|
const [error, setError] = useState<string | null>(null);
|
|
356
366
|
const [pendingPermission, setPendingPermission] = useState<PendingPermission | null>(null);
|
|
357
367
|
const [cancelledUserMessage, setCancelledUserMessage] = useState<string | null>(null);
|
|
@@ -391,57 +401,56 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
391
401
|
}
|
|
392
402
|
|
|
393
403
|
switch (msg.type) {
|
|
394
|
-
case 'stream_start':
|
|
404
|
+
case 'stream_start': {
|
|
405
|
+
// Only process if this event is for our active session
|
|
406
|
+
if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
|
|
395
407
|
setStreaming(true);
|
|
396
408
|
setError(null);
|
|
397
|
-
// Create an
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
]);
|
|
409
|
+
// Create an isolated streaming message (not in the messages array)
|
|
410
|
+
const startMsg: ChatMessage = {id: nextMessageId(), role: 'assistant', content: [], isStreaming: true};
|
|
411
|
+
streamingMessageRef.current = startMsg;
|
|
412
|
+
setStreamingMessage(startMsg);
|
|
402
413
|
break;
|
|
414
|
+
}
|
|
403
415
|
|
|
404
416
|
case 'stream_event':
|
|
417
|
+
// Only process events for our active session — prevents cross-session leaking
|
|
418
|
+
if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
|
|
405
419
|
handleStreamEvent(msg.event);
|
|
406
420
|
break;
|
|
407
421
|
|
|
408
|
-
case 'stream_end':
|
|
422
|
+
case 'stream_end': {
|
|
423
|
+
// Only process if this event is for our active session
|
|
424
|
+
if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
|
|
409
425
|
setStreaming(false);
|
|
426
|
+
activeClaudeSessionRef.current = null;
|
|
410
427
|
// Surface error details from non-zero exit codes
|
|
411
428
|
if (msg.error) {
|
|
412
429
|
setError(msg.error);
|
|
413
430
|
}
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
return updated;
|
|
424
|
-
});
|
|
431
|
+
// Merge the streaming message into historical messages
|
|
432
|
+
const endMsg = streamingMessageRef.current;
|
|
433
|
+
if (endMsg) {
|
|
434
|
+
setMessages(prev => [...prev, {...endMsg, isStreaming: false}]);
|
|
435
|
+
}
|
|
436
|
+
streamingMessageRef.current = null;
|
|
437
|
+
setStreamingMessage(null);
|
|
425
438
|
// Notify consumers (e.g. message queue) that streaming has ended
|
|
426
439
|
streamEndCallbackRef.current?.();
|
|
427
440
|
break;
|
|
441
|
+
}
|
|
428
442
|
|
|
429
443
|
case 'stream_cancelled':
|
|
444
|
+
// Only process if this event is for our active session
|
|
445
|
+
if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
|
|
430
446
|
setStreaming(false);
|
|
431
|
-
|
|
432
|
-
//
|
|
447
|
+
activeClaudeSessionRef.current = null;
|
|
448
|
+
// Discard the streaming assistant message
|
|
449
|
+
streamingMessageRef.current = null;
|
|
450
|
+
setStreamingMessage(null);
|
|
451
|
+
// Remove the last user message and prepopulate the input field
|
|
433
452
|
setMessages(prev => {
|
|
434
453
|
const updated = [...prev];
|
|
435
|
-
|
|
436
|
-
// Remove the last streaming assistant message (partial response)
|
|
437
|
-
for (let i = updated.length - 1; i >= 0; i--) {
|
|
438
|
-
if (updated[i].role === 'assistant' && updated[i].isStreaming) {
|
|
439
|
-
updated.splice(i, 1);
|
|
440
|
-
break;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Remove the last user message and capture its text
|
|
445
454
|
for (let i = updated.length - 1; i >= 0; i--) {
|
|
446
455
|
if (updated[i].role === 'user') {
|
|
447
456
|
const userContent = updated[i].content;
|
|
@@ -453,36 +462,46 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
453
462
|
break;
|
|
454
463
|
}
|
|
455
464
|
}
|
|
456
|
-
|
|
457
465
|
return updated;
|
|
458
466
|
});
|
|
459
467
|
break;
|
|
460
468
|
|
|
461
|
-
case 'stream_resumed':
|
|
469
|
+
case 'stream_resumed': {
|
|
462
470
|
// Re-attached to an in-flight stream after page reload or session switch.
|
|
463
|
-
|
|
464
|
-
// If no assistant message exists (e.g. messages not yet persisted to DB),
|
|
465
|
-
// create an empty streaming assistant message for catch-up content.
|
|
471
|
+
activeClaudeSessionRef.current = msg.claudeSessionId;
|
|
466
472
|
setStreaming(true);
|
|
467
473
|
setError(null);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
case 'stream_error':
|
|
472
|
-
setStreaming(false);
|
|
473
|
-
setError(msg.error);
|
|
474
|
-
// Mark streaming message as done
|
|
474
|
+
// Extract last assistant message from historical messages into streamingMessage,
|
|
475
|
+
// or create a new empty one if none exists.
|
|
476
|
+
streamingMessageRef.current = null;
|
|
475
477
|
setMessages(prev => {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
break;
|
|
478
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
479
|
+
if (prev[i].role === 'assistant') {
|
|
480
|
+
streamingMessageRef.current = {...prev[i], isStreaming: true};
|
|
481
|
+
return [...prev.slice(0, i), ...prev.slice(i + 1)];
|
|
481
482
|
}
|
|
482
483
|
}
|
|
483
|
-
return
|
|
484
|
+
return prev;
|
|
484
485
|
});
|
|
486
|
+
if (!streamingMessageRef.current) {
|
|
487
|
+
streamingMessageRef.current = {id: nextMessageId(), role: 'assistant', content: [], isStreaming: true};
|
|
488
|
+
}
|
|
489
|
+
setStreamingMessage(streamingMessageRef.current);
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
case 'stream_error': {
|
|
494
|
+
setStreaming(false);
|
|
495
|
+
setError(msg.error);
|
|
496
|
+
// Merge the streaming message into historical messages (marked as done)
|
|
497
|
+
const errMsg = streamingMessageRef.current;
|
|
498
|
+
if (errMsg) {
|
|
499
|
+
setMessages(prev => [...prev, {...errMsg, isStreaming: false}]);
|
|
500
|
+
}
|
|
501
|
+
streamingMessageRef.current = null;
|
|
502
|
+
setStreamingMessage(null);
|
|
485
503
|
break;
|
|
504
|
+
}
|
|
486
505
|
|
|
487
506
|
case 'permission_request':
|
|
488
507
|
setPendingPermission({
|
|
@@ -524,51 +543,17 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
524
543
|
const blocks = extractContentBlocks(event);
|
|
525
544
|
if (blocks.length === 0) return;
|
|
526
545
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// comparing tool_use IDs: if existing content has tool_use blocks
|
|
535
|
-
// not present in the new blocks, we've moved to a new turn.
|
|
536
|
-
const existing = updated[i].content;
|
|
537
|
-
|
|
538
|
-
const existingToolUseIds = new Set(
|
|
539
|
-
existing
|
|
540
|
-
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
|
541
|
-
.map(b => b.id),
|
|
542
|
-
);
|
|
543
|
-
const newToolUseIds = new Set(
|
|
544
|
-
blocks
|
|
545
|
-
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
|
546
|
-
.map(b => b.id),
|
|
547
|
-
);
|
|
548
|
-
|
|
549
|
-
const isNewTurn = existingToolUseIds.size > 0 &&
|
|
550
|
-
![...existingToolUseIds].some(id => newToolUseIds.has(id));
|
|
551
|
-
|
|
552
|
-
if (isNewTurn) {
|
|
553
|
-
// New turn: preserve all existing content, append new blocks
|
|
554
|
-
updated[i] = {...updated[i], content: [...existing, ...blocks]};
|
|
555
|
-
} else {
|
|
556
|
-
// Same turn: replace with cumulative snapshot, preserving tool_result blocks
|
|
557
|
-
const existingToolResults = existing.filter(
|
|
558
|
-
(b): b is ToolResultBlock => b.type === 'tool_result',
|
|
559
|
-
);
|
|
560
|
-
updated[i] = {...updated[i], content: [...blocks, ...existingToolResults]};
|
|
561
|
-
}
|
|
562
|
-
break;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return updated;
|
|
566
|
-
});
|
|
546
|
+
// Update only the isolated streaming message — historical messages stay untouched
|
|
547
|
+
const current = streamingMessageRef.current;
|
|
548
|
+
if (current) {
|
|
549
|
+
const updated = {...current, content: mergeAssistantContent(current.content, blocks)};
|
|
550
|
+
streamingMessageRef.current = updated;
|
|
551
|
+
setStreamingMessage(updated);
|
|
552
|
+
}
|
|
567
553
|
return;
|
|
568
554
|
}
|
|
569
555
|
|
|
570
|
-
// Process tool result events — merge tool_result blocks into the
|
|
571
|
-
// streaming assistant message so ToolUseCard can show output.
|
|
556
|
+
// Process tool result events — merge tool_result blocks into the streaming message
|
|
572
557
|
if (event.type === 'tool_result' || event.type === 'human' || event.type === 'user') {
|
|
573
558
|
const blocks = extractContentBlocks(event);
|
|
574
559
|
const resultBlocks = blocks.filter(
|
|
@@ -576,30 +561,22 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
576
561
|
);
|
|
577
562
|
if (resultBlocks.length === 0) return;
|
|
578
563
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
updated[i] = {
|
|
594
|
-
...updated[i],
|
|
595
|
-
content: [...updated[i].content, ...newResults],
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
break;
|
|
599
|
-
}
|
|
564
|
+
const current = streamingMessageRef.current;
|
|
565
|
+
if (current) {
|
|
566
|
+
const existingToolResultIds = new Set(
|
|
567
|
+
current.content
|
|
568
|
+
.filter((b): b is ToolResultBlock => b.type === 'tool_result')
|
|
569
|
+
.map(b => b.toolUseId),
|
|
570
|
+
);
|
|
571
|
+
const newResults = resultBlocks.filter(
|
|
572
|
+
b => !existingToolResultIds.has(b.toolUseId),
|
|
573
|
+
);
|
|
574
|
+
if (newResults.length > 0) {
|
|
575
|
+
const updated = {...current, content: [...current.content, ...newResults]};
|
|
576
|
+
streamingMessageRef.current = updated;
|
|
577
|
+
setStreamingMessage(updated);
|
|
600
578
|
}
|
|
601
|
-
|
|
602
|
-
});
|
|
579
|
+
}
|
|
603
580
|
}
|
|
604
581
|
|
|
605
582
|
// Extract contextWindow from result event's modelUsage
|
|
@@ -631,6 +608,9 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
631
608
|
return;
|
|
632
609
|
}
|
|
633
610
|
|
|
611
|
+
// Track which session we're streaming for so event filtering works
|
|
612
|
+
activeClaudeSessionRef.current = options.claudeSessionId;
|
|
613
|
+
|
|
634
614
|
// Build user message content blocks: text + any attachment blocks
|
|
635
615
|
const userContent: ContentBlock[] = [{type: 'text', text: message}];
|
|
636
616
|
if (options.attachmentBlocks && options.attachmentBlocks.length > 0) {
|
|
@@ -689,6 +669,9 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
689
669
|
|
|
690
670
|
if (texts.length === 0) return;
|
|
691
671
|
|
|
672
|
+
// Track which session we're streaming for so event filtering works
|
|
673
|
+
activeClaudeSessionRef.current = options.claudeSessionId;
|
|
674
|
+
|
|
692
675
|
// Add each queued message as a separate user bubble
|
|
693
676
|
setMessages(prev => [
|
|
694
677
|
...prev,
|
|
@@ -734,11 +717,15 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
734
717
|
|
|
735
718
|
const clearMessages = useCallback(() => {
|
|
736
719
|
setMessages([]);
|
|
720
|
+
streamingMessageRef.current = null;
|
|
721
|
+
setStreamingMessage(null);
|
|
737
722
|
setContextUsage(null);
|
|
738
723
|
}, []);
|
|
739
724
|
|
|
740
725
|
const restoreMessages = useCallback((msgs: ChatMessage[]) => {
|
|
741
726
|
setMessages(msgs);
|
|
727
|
+
streamingMessageRef.current = null;
|
|
728
|
+
setStreamingMessage(null);
|
|
742
729
|
}, []);
|
|
743
730
|
|
|
744
731
|
const initContextUsage = useCallback((usage: ContextUsage | null) => {
|
|
@@ -809,6 +796,7 @@ export const useChatStream = (): UseChatStreamReturn => {
|
|
|
809
796
|
loadMessages,
|
|
810
797
|
attachSession,
|
|
811
798
|
messages,
|
|
799
|
+
streamingMessage,
|
|
812
800
|
streaming,
|
|
813
801
|
connected,
|
|
814
802
|
error,
|
package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
buildStreamOptions,
|
|
10
10
|
prepareMergedQueueText,
|
|
11
11
|
markOrCreateStreamingAssistant,
|
|
12
|
+
mergeAssistantContent,
|
|
12
13
|
} from './chat_message_helpers.ts';
|
|
13
14
|
import type { ChatMessage, ContentBlock } from '../hooks/use_chat_stream.ts';
|
|
14
15
|
import type { QueuedMessage } from './message_queue.ts';
|
|
@@ -303,3 +304,107 @@ describe('markOrCreateStreamingAssistant', () => {
|
|
|
303
304
|
assert.deepEqual(result[1].content, content);
|
|
304
305
|
});
|
|
305
306
|
});
|
|
307
|
+
|
|
308
|
+
describe('mergeAssistantContent', () => {
|
|
309
|
+
it('replaces content with cumulative snapshot when new blocks have text', () => {
|
|
310
|
+
const existing: ContentBlock[] = [
|
|
311
|
+
{ type: 'text', text: 'partial text' },
|
|
312
|
+
];
|
|
313
|
+
const newBlocks: ContentBlock[] = [
|
|
314
|
+
{ type: 'text', text: 'full text response' },
|
|
315
|
+
];
|
|
316
|
+
const result = mergeAssistantContent(existing, newBlocks);
|
|
317
|
+
assert.equal(result.length, 1);
|
|
318
|
+
assert.equal((result[0] as any).text, 'full text response');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('preserves text when partial event only has tool_use (Bug 1 fix)', () => {
|
|
322
|
+
const existing: ContentBlock[] = [
|
|
323
|
+
{ type: 'text', text: 'Let me read the file.' },
|
|
324
|
+
];
|
|
325
|
+
// Partial event during tool_use streaming — only contains the tool_use block
|
|
326
|
+
const newBlocks: ContentBlock[] = [
|
|
327
|
+
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
|
|
328
|
+
];
|
|
329
|
+
const result = mergeAssistantContent(existing, newBlocks);
|
|
330
|
+
// Text block should be preserved
|
|
331
|
+
assert.equal(result.length, 2);
|
|
332
|
+
assert.equal(result[0].type, 'text');
|
|
333
|
+
assert.equal((result[0] as any).text, 'Let me read the file.');
|
|
334
|
+
assert.equal(result[1].type, 'tool_use');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('replaces text when cumulative event includes both text and tool_use', () => {
|
|
338
|
+
const existing: ContentBlock[] = [
|
|
339
|
+
{ type: 'text', text: 'old partial' },
|
|
340
|
+
];
|
|
341
|
+
const newBlocks: ContentBlock[] = [
|
|
342
|
+
{ type: 'text', text: 'Let me read the file.' },
|
|
343
|
+
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: '/etc/hosts' } },
|
|
344
|
+
];
|
|
345
|
+
const result = mergeAssistantContent(existing, newBlocks);
|
|
346
|
+
assert.equal(result.length, 2);
|
|
347
|
+
assert.equal((result[0] as any).text, 'Let me read the file.');
|
|
348
|
+
assert.equal(result[1].type, 'tool_use');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('preserves tool_result blocks from existing content in same turn', () => {
|
|
352
|
+
const existing: ContentBlock[] = [
|
|
353
|
+
{ type: 'text', text: 'Reading file...' },
|
|
354
|
+
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
|
|
355
|
+
{ type: 'tool_result', toolUseId: 'tu_1', content: 'file content', isError: false },
|
|
356
|
+
];
|
|
357
|
+
const newBlocks: ContentBlock[] = [
|
|
358
|
+
{ type: 'text', text: 'Reading file...' },
|
|
359
|
+
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: '/etc/hosts' } },
|
|
360
|
+
];
|
|
361
|
+
const result = mergeAssistantContent(existing, newBlocks);
|
|
362
|
+
assert.equal(result.length, 3);
|
|
363
|
+
assert.equal(result[2].type, 'tool_result');
|
|
364
|
+
assert.equal((result[2] as any).toolUseId, 'tu_1');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('appends new blocks on new turn (different tool_use IDs)', () => {
|
|
368
|
+
const existing: ContentBlock[] = [
|
|
369
|
+
{ type: 'text', text: 'First action' },
|
|
370
|
+
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
|
|
371
|
+
{ type: 'tool_result', toolUseId: 'tu_1', content: 'ok', isError: false },
|
|
372
|
+
];
|
|
373
|
+
// New turn — completely different tool_use ID
|
|
374
|
+
const newBlocks: ContentBlock[] = [
|
|
375
|
+
{ type: 'text', text: 'Now writing...' },
|
|
376
|
+
{ type: 'tool_use', id: 'tu_2', name: 'Write', input: {} },
|
|
377
|
+
];
|
|
378
|
+
const result = mergeAssistantContent(existing, newBlocks);
|
|
379
|
+
// Should preserve all existing + append new
|
|
380
|
+
assert.equal(result.length, 5);
|
|
381
|
+
assert.equal(result[0].type, 'text');
|
|
382
|
+
assert.equal((result[0] as any).text, 'First action');
|
|
383
|
+
assert.equal(result[3].type, 'text');
|
|
384
|
+
assert.equal((result[3] as any).text, 'Now writing...');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('handles empty existing content', () => {
|
|
388
|
+
const newBlocks: ContentBlock[] = [
|
|
389
|
+
{ type: 'text', text: 'Hello' },
|
|
390
|
+
];
|
|
391
|
+
const result = mergeAssistantContent([], newBlocks);
|
|
392
|
+
assert.equal(result.length, 1);
|
|
393
|
+
assert.equal((result[0] as any).text, 'Hello');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('preserves text when new blocks have empty text block', () => {
|
|
397
|
+
const existing: ContentBlock[] = [
|
|
398
|
+
{ type: 'text', text: 'I will help you.' },
|
|
399
|
+
];
|
|
400
|
+
// Event with empty text + tool_use
|
|
401
|
+
const newBlocks: ContentBlock[] = [
|
|
402
|
+
{ type: 'text', text: '' },
|
|
403
|
+
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: {} },
|
|
404
|
+
];
|
|
405
|
+
const result = mergeAssistantContent(existing, newBlocks);
|
|
406
|
+
// Should carry forward the existing non-empty text
|
|
407
|
+
assert.equal(result[0].type, 'text');
|
|
408
|
+
assert.equal((result[0] as any).text, 'I will help you.');
|
|
409
|
+
});
|
|
410
|
+
});
|
package/templates/assistkick-product-system/packages/frontend/src/lib/chat_message_helpers.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Includes stream options building, message filtering, and text extraction.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ChatMessage, ContentBlock, TextBlock, ToolResultBlock } from '../hooks/use_chat_stream';
|
|
8
|
+
import type { ChatMessage, ContentBlock, TextBlock, ToolUseBlock, ToolResultBlock } from '../hooks/use_chat_stream';
|
|
9
9
|
import type { QueuedMessage } from './message_queue';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -97,6 +97,53 @@ export const prepareMergedQueueText = (messages: QueuedMessage[]): string => {
|
|
|
97
97
|
* Used by the stream_resumed handler when re-attaching to an in-flight stream.
|
|
98
98
|
* Returns a new array (does not mutate the input).
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* Merge new content blocks from a streaming assistant event into existing content.
|
|
102
|
+
* Handles same-turn cumulative replacement and new-turn appending.
|
|
103
|
+
*
|
|
104
|
+
* Key behavior: when partial events during tool_use streaming omit prior text blocks,
|
|
105
|
+
* existing text blocks are preserved so they don't disappear from the UI.
|
|
106
|
+
*/
|
|
107
|
+
export const mergeAssistantContent = (
|
|
108
|
+
existing: ContentBlock[],
|
|
109
|
+
newBlocks: ContentBlock[],
|
|
110
|
+
): ContentBlock[] => {
|
|
111
|
+
const existingToolUseIds = new Set(
|
|
112
|
+
existing
|
|
113
|
+
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
|
114
|
+
.map(b => b.id),
|
|
115
|
+
);
|
|
116
|
+
const newToolUseIds = new Set(
|
|
117
|
+
newBlocks
|
|
118
|
+
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
|
119
|
+
.map(b => b.id),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const isNewTurn = existingToolUseIds.size > 0 &&
|
|
123
|
+
![...existingToolUseIds].some(id => newToolUseIds.has(id));
|
|
124
|
+
|
|
125
|
+
if (isNewTurn) {
|
|
126
|
+
// New turn: preserve all existing content, append new blocks
|
|
127
|
+
return [...existing, ...newBlocks];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Same turn: replace with cumulative snapshot, preserving tool_result blocks.
|
|
131
|
+
// Also preserve existing text blocks when partial events omit them
|
|
132
|
+
// (partial events during tool_use streaming may only contain the tool_use block).
|
|
133
|
+
const existingToolResults = existing.filter(
|
|
134
|
+
(b): b is ToolResultBlock => b.type === 'tool_result',
|
|
135
|
+
);
|
|
136
|
+
const hasNewText = newBlocks.some(
|
|
137
|
+
b => b.type === 'text' && (b as TextBlock).text.length > 0,
|
|
138
|
+
);
|
|
139
|
+
if (hasNewText) {
|
|
140
|
+
return [...newBlocks, ...existingToolResults];
|
|
141
|
+
}
|
|
142
|
+
// No text in new event — carry forward existing text blocks
|
|
143
|
+
const existingText = existing.filter(b => b.type === 'text');
|
|
144
|
+
return [...existingText, ...newBlocks, ...existingToolResults];
|
|
145
|
+
};
|
|
146
|
+
|
|
100
147
|
export const markOrCreateStreamingAssistant = (
|
|
101
148
|
messages: ChatMessage[],
|
|
102
149
|
nextId: string,
|
|
@@ -156,6 +156,31 @@ async function seedDefaults() {
|
|
|
156
156
|
if (check.rows.length > 0) seeded++;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Default Chat Agent — system prompt for the chat bridge
|
|
160
|
+
const chatGrounding = `We are working on project-id {{projectId}}
|
|
161
|
+
|
|
162
|
+
## Project Workspace
|
|
163
|
+
The project workspace is at: {{workspacePath}}
|
|
164
|
+
All file edits (write, edit, delete, create) MUST target files inside the workspace directory above.
|
|
165
|
+
You may read files from anywhere in the project root for context, but all changes go into the workspace.
|
|
166
|
+
|
|
167
|
+
## Git Repository Structure
|
|
168
|
+
The workspace ({{workspacePath}}) has its OWN independent git repo.
|
|
169
|
+
When committing and pushing changes, always use the workspace git repo, not the top-level repo.
|
|
170
|
+
|
|
171
|
+
## Skills
|
|
172
|
+
Project skills (e.g. assistkick-interview, assistkick-developer, etc.) are available via the Skill tool.
|
|
173
|
+
To invoke a skill, always use the Skill tool (e.g. Skill with skill: "assistkick-interview"). Do NOT manually read or execute skill files — the Skill tool handles loading and execution.
|
|
174
|
+
Only edit code and project files — never modify skill definition files.`;
|
|
175
|
+
|
|
176
|
+
const chatGroundingEscaped = sqlEscape(chatGrounding);
|
|
177
|
+
await client.execute(
|
|
178
|
+
`INSERT OR IGNORE INTO agents (id, name, model, skills, grounding, default_grounding, project_id, is_default, created_at, updated_at)
|
|
179
|
+
VALUES ('chat-agent-default', 'Chat Agent', 'claude-opus-4-6', '[]', '${chatGroundingEscaped}', '${chatGroundingEscaped}', NULL, 1, '${now}', '${now}')`
|
|
180
|
+
);
|
|
181
|
+
const chatCheck = await client.execute(`SELECT id FROM agents WHERE id = 'chat-agent-default'`);
|
|
182
|
+
if (chatCheck.rows.length > 0) seeded++;
|
|
183
|
+
|
|
159
184
|
// Default Pipeline workflow
|
|
160
185
|
const defaultPipelineGraph = JSON.stringify({
|
|
161
186
|
nodes: [
|