@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistkick/create",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "description": "Scaffold assistkick-product-system into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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();
@@ -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
- ## Git Repository Structure
162
- The workspace (${wsPath}) has its OWN independent git repo.
163
- When committing and pushing changes, always use the workspace git repo, not the top-level repo.
164
-
165
- ## Skills
166
- Project skills (e.g. assistkick-interview, assistkick-developer, etc.) are available via the Skill tool.
167
- 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.
168
- Only edit code and project files never modify skill definition files.`);
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';
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Server → Client messages:
14
14
  * { type: 'stream_start', claudeSessionId }
15
- * { type: 'stream_event', event } — forwarded parsed stream-json 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}`);
@@ -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;
@@ -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 messages) {
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
- }, [messages, searchQuery]);
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={messages} />
738
+ <ChatTodoSidebar messages={allMessages} />
720
739
  </div>
721
740
 
722
741
  {/* Error banner */}
@@ -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 empty assistant message that will accumulate content
398
- setMessages(prev => [
399
- ...prev,
400
- {id: nextMessageId(), role: 'assistant', content: [], isStreaming: true},
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
- // Mark the last assistant message as no longer streaming
415
- setMessages(prev => {
416
- const updated = [...prev];
417
- for (let i = updated.length - 1; i >= 0; i--) {
418
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
419
- updated[i] = {...updated[i], isStreaming: false};
420
- break;
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
- // Discard the partial assistant response and the triggering user message.
432
- // Prepopulate the cancelled user message text for the input field.
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
- // Mark the last assistant message as streaming so new events update it.
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
- setMessages(prev => markOrCreateStreamingAssistant(prev, nextMessageId()));
469
- break;
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
- const updated = [...prev];
477
- for (let i = updated.length - 1; i >= 0; i--) {
478
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
479
- updated[i] = {...updated[i], isStreaming: false};
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 updated;
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
- setMessages(prev => {
528
- const updated = [...prev];
529
- // Find the last streaming assistant message
530
- for (let i = updated.length - 1; i >= 0; i--) {
531
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
532
- // Each assistant event is cumulative within its turn, but a new
533
- // turn (after tool use) starts fresh. Detect turn boundaries by
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 last
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
- setMessages(prev => {
580
- const updated = [...prev];
581
- for (let i = updated.length - 1; i >= 0; i--) {
582
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
583
- // Append tool_result blocks, deduplicating by toolUseId
584
- const existingToolResultIds = new Set(
585
- updated[i].content
586
- .filter((b): b is ToolResultBlock => b.type === 'tool_result')
587
- .map(b => b.toolUseId),
588
- );
589
- const newResults = resultBlocks.filter(
590
- b => !existingToolResultIds.has(b.toolUseId),
591
- );
592
- if (newResults.length > 0) {
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
- return updated;
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,
@@ -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
+ });
@@ -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: [