@assistkick/create 1.34.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.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "Scaffold assistkick-product-system into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ });
@@ -214,6 +214,8 @@ export interface UseChatStreamReturn {
214
214
  /** Attempt to re-attach to an in-flight stream after page reload. */
215
215
  attachSession: (claudeSessionId: string) => void;
216
216
  messages: ChatMessage[];
217
+ /** The currently streaming assistant message (separate from messages for render isolation). */
218
+ streamingMessage: ChatMessage | null;
217
219
  streaming: boolean;
218
220
  connected: boolean;
219
221
  error: string | null;
@@ -355,6 +357,11 @@ export const useChatStream = (): UseChatStreamReturn => {
355
357
  const [connected, setConnected] = useState(false);
356
358
  const [streaming, setStreaming] = useState(false);
357
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);
358
365
  const [error, setError] = useState<string | null>(null);
359
366
  const [pendingPermission, setPendingPermission] = useState<PendingPermission | null>(null);
360
367
  const [cancelledUserMessage, setCancelledUserMessage] = useState<string | null>(null);
@@ -394,17 +401,17 @@ export const useChatStream = (): UseChatStreamReturn => {
394
401
  }
395
402
 
396
403
  switch (msg.type) {
397
- case 'stream_start':
404
+ case 'stream_start': {
398
405
  // Only process if this event is for our active session
399
406
  if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
400
407
  setStreaming(true);
401
408
  setError(null);
402
- // Create an empty assistant message that will accumulate content
403
- setMessages(prev => [
404
- ...prev,
405
- {id: nextMessageId(), role: 'assistant', content: [], isStreaming: true},
406
- ]);
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);
407
413
  break;
414
+ }
408
415
 
409
416
  case 'stream_event':
410
417
  // Only process events for our active session — prevents cross-session leaking
@@ -412,7 +419,7 @@ export const useChatStream = (): UseChatStreamReturn => {
412
419
  handleStreamEvent(msg.event);
413
420
  break;
414
421
 
415
- case 'stream_end':
422
+ case 'stream_end': {
416
423
  // Only process if this event is for our active session
417
424
  if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
418
425
  setStreaming(false);
@@ -421,40 +428,29 @@ export const useChatStream = (): UseChatStreamReturn => {
421
428
  if (msg.error) {
422
429
  setError(msg.error);
423
430
  }
424
- // Mark the last assistant message as no longer streaming
425
- setMessages(prev => {
426
- const updated = [...prev];
427
- for (let i = updated.length - 1; i >= 0; i--) {
428
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
429
- updated[i] = {...updated[i], isStreaming: false};
430
- break;
431
- }
432
- }
433
- return updated;
434
- });
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);
435
438
  // Notify consumers (e.g. message queue) that streaming has ended
436
439
  streamEndCallbackRef.current?.();
437
440
  break;
441
+ }
438
442
 
439
443
  case 'stream_cancelled':
440
444
  // Only process if this event is for our active session
441
445
  if (activeClaudeSessionRef.current && msg.claudeSessionId !== activeClaudeSessionRef.current) break;
442
446
  setStreaming(false);
443
447
  activeClaudeSessionRef.current = null;
444
- // Discard the partial assistant response and the triggering user message.
445
- // Prepopulate the cancelled user message text for the input field.
448
+ // Discard the streaming assistant message
449
+ streamingMessageRef.current = null;
450
+ setStreamingMessage(null);
451
+ // Remove the last user message and prepopulate the input field
446
452
  setMessages(prev => {
447
453
  const updated = [...prev];
448
-
449
- // Remove the last streaming assistant message (partial response)
450
- for (let i = updated.length - 1; i >= 0; i--) {
451
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
452
- updated.splice(i, 1);
453
- break;
454
- }
455
- }
456
-
457
- // Remove the last user message and capture its text
458
454
  for (let i = updated.length - 1; i >= 0; i--) {
459
455
  if (updated[i].role === 'user') {
460
456
  const userContent = updated[i].content;
@@ -466,38 +462,46 @@ export const useChatStream = (): UseChatStreamReturn => {
466
462
  break;
467
463
  }
468
464
  }
469
-
470
465
  return updated;
471
466
  });
472
467
  break;
473
468
 
474
- case 'stream_resumed':
469
+ case 'stream_resumed': {
475
470
  // Re-attached to an in-flight stream after page reload or session switch.
476
- // Update active session to the one we're re-attaching to.
477
471
  activeClaudeSessionRef.current = msg.claudeSessionId;
478
- // Mark the last assistant message as streaming so new events update it.
479
- // If no assistant message exists (e.g. messages not yet persisted to DB),
480
- // create an empty streaming assistant message for catch-up content.
481
472
  setStreaming(true);
482
473
  setError(null);
483
- setMessages(prev => markOrCreateStreamingAssistant(prev, nextMessageId()));
484
- break;
485
-
486
- case 'stream_error':
487
- setStreaming(false);
488
- setError(msg.error);
489
- // 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;
490
477
  setMessages(prev => {
491
- const updated = [...prev];
492
- for (let i = updated.length - 1; i >= 0; i--) {
493
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
494
- updated[i] = {...updated[i], isStreaming: false};
495
- 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)];
496
482
  }
497
483
  }
498
- return updated;
484
+ return prev;
499
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);
500
503
  break;
504
+ }
501
505
 
502
506
  case 'permission_request':
503
507
  setPendingPermission({
@@ -539,21 +543,17 @@ export const useChatStream = (): UseChatStreamReturn => {
539
543
  const blocks = extractContentBlocks(event);
540
544
  if (blocks.length === 0) return;
541
545
 
542
- setMessages(prev => {
543
- const updated = [...prev];
544
- for (let i = updated.length - 1; i >= 0; i--) {
545
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
546
- updated[i] = {...updated[i], content: mergeAssistantContent(updated[i].content, blocks)};
547
- break;
548
- }
549
- }
550
- return updated;
551
- });
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
+ }
552
553
  return;
553
554
  }
554
555
 
555
- // Process tool result events — merge tool_result blocks into the last
556
- // streaming assistant message so ToolUseCard can show output.
556
+ // Process tool result events — merge tool_result blocks into the streaming message
557
557
  if (event.type === 'tool_result' || event.type === 'human' || event.type === 'user') {
558
558
  const blocks = extractContentBlocks(event);
559
559
  const resultBlocks = blocks.filter(
@@ -561,30 +561,22 @@ export const useChatStream = (): UseChatStreamReturn => {
561
561
  );
562
562
  if (resultBlocks.length === 0) return;
563
563
 
564
- setMessages(prev => {
565
- const updated = [...prev];
566
- for (let i = updated.length - 1; i >= 0; i--) {
567
- if (updated[i].role === 'assistant' && updated[i].isStreaming) {
568
- // Append tool_result blocks, deduplicating by toolUseId
569
- const existingToolResultIds = new Set(
570
- updated[i].content
571
- .filter((b): b is ToolResultBlock => b.type === 'tool_result')
572
- .map(b => b.toolUseId),
573
- );
574
- const newResults = resultBlocks.filter(
575
- b => !existingToolResultIds.has(b.toolUseId),
576
- );
577
- if (newResults.length > 0) {
578
- updated[i] = {
579
- ...updated[i],
580
- content: [...updated[i].content, ...newResults],
581
- };
582
- }
583
- break;
584
- }
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);
585
578
  }
586
- return updated;
587
- });
579
+ }
588
580
  }
589
581
 
590
582
  // Extract contextWindow from result event's modelUsage
@@ -725,11 +717,15 @@ export const useChatStream = (): UseChatStreamReturn => {
725
717
 
726
718
  const clearMessages = useCallback(() => {
727
719
  setMessages([]);
720
+ streamingMessageRef.current = null;
721
+ setStreamingMessage(null);
728
722
  setContextUsage(null);
729
723
  }, []);
730
724
 
731
725
  const restoreMessages = useCallback((msgs: ChatMessage[]) => {
732
726
  setMessages(msgs);
727
+ streamingMessageRef.current = null;
728
+ setStreamingMessage(null);
733
729
  }, []);
734
730
 
735
731
  const initContextUsage = useCallback((usage: ContextUsage | null) => {
@@ -800,6 +796,7 @@ export const useChatStream = (): UseChatStreamReturn => {
800
796
  loadMessages,
801
797
  attachSession,
802
798
  messages,
799
+ streamingMessage,
803
800
  streaming,
804
801
  connected,
805
802
  error,