jupyter-chat-components 0.3.0 → 0.4.1

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.
@@ -0,0 +1,829 @@
1
+ import { PageConfig, PathExt } from '@jupyterlab/coreutils';
2
+ import { nullTranslator, TranslationBundle } from '@jupyterlab/translation';
3
+
4
+ import * as React from 'react';
5
+ import { structuredPatch } from 'diff';
6
+ import type { StructuredPatchHunk } from 'diff';
7
+
8
+ import {
9
+ IComponentProps,
10
+ IToolCallDiff,
11
+ IToolCallsEntry,
12
+ IToolCallsMetadata,
13
+ OpenToolCallPath,
14
+ ToolCallPermissionDecision
15
+ } from '../token';
16
+
17
+ /** Maximum number of rendered diff lines before truncation. */
18
+ const MAX_DIFF_LINES = 20;
19
+
20
+ /** Maximum number of lines shown in an expanded detail. */
21
+ const MAX_DETAIL_LINES = 15;
22
+
23
+ /** Tool kinds where expanded view shows file paths from locations. */
24
+ const FILE_KINDS = new Set(['read', 'edit', 'delete', 'move']);
25
+
26
+ const TOOL_KIND_LABELS: Record<string, string> = {
27
+ read: 'Reading',
28
+ edit: 'Editing',
29
+ delete: 'Deleting',
30
+ move: 'Moving',
31
+ search: 'Searching',
32
+ execute: 'Running command',
33
+ think: 'Thinking',
34
+ fetch: 'Fetching',
35
+ switch_mode: 'Switching mode'
36
+ };
37
+
38
+ interface IDiffLineInfo {
39
+ cssClass: string;
40
+ prefix: string;
41
+ text: string;
42
+ key: string;
43
+ }
44
+
45
+ /**
46
+ * Props for rendering grouped tool calls.
47
+ */
48
+ export interface IGroupedToolCallsProps
49
+ extends IComponentProps, IToolCallsMetadata {
50
+ toolCallPermissionDecision?: ToolCallPermissionDecision;
51
+ openToolCallPath?: OpenToolCallPath;
52
+ }
53
+
54
+ function getConfiguredServerRoot(): string | null {
55
+ const rootUri = PageConfig.getOption('rootUri');
56
+
57
+ if (rootUri) {
58
+ try {
59
+ return new URL(rootUri, 'http://localhost').pathname;
60
+ } catch (error) {
61
+ console.warn(
62
+ 'Could not parse rootUri while rendering tool calls.',
63
+ error
64
+ );
65
+ }
66
+ }
67
+
68
+ const serverRoot = PageConfig.getOption('serverRoot');
69
+ return serverRoot || null;
70
+ }
71
+
72
+ /**
73
+ * Convert an absolute filesystem path to a server-relative path when possible.
74
+ */
75
+ export function toServerRelativePath(absolutePath: string): string {
76
+ const serverRoot = getConfiguredServerRoot();
77
+
78
+ if (!serverRoot) {
79
+ return absolutePath;
80
+ }
81
+
82
+ const relativePath = PathExt.relative(serverRoot, absolutePath);
83
+ if (relativePath.startsWith('..')) {
84
+ return absolutePath;
85
+ }
86
+
87
+ return relativePath;
88
+ }
89
+
90
+ /**
91
+ * Format tool payload (input/output) for display.
92
+ */
93
+ export function formatToolCallIO(payload: unknown): string {
94
+ if (typeof payload === 'string') {
95
+ return payload;
96
+ }
97
+
98
+ if (
99
+ Array.isArray(payload) &&
100
+ payload.every(
101
+ item =>
102
+ typeof item === 'object' &&
103
+ item !== null &&
104
+ typeof (item as { text?: unknown }).text === 'string'
105
+ )
106
+ ) {
107
+ return payload.map(item => (item as { text: string }).text).join('\n');
108
+ }
109
+
110
+ return JSON.stringify(payload, null, 2);
111
+ }
112
+
113
+ function getLocationSummary(toolCall: IToolCallsEntry): string | null {
114
+ const firstLocation = toolCall.locations?.[0];
115
+
116
+ if (!firstLocation) {
117
+ return null;
118
+ }
119
+
120
+ return PathExt.basename(firstLocation) || firstLocation;
121
+ }
122
+
123
+ /**
124
+ * Compute the line title shown for a tool call.
125
+ */
126
+ export function getToolCallDisplayTitle(toolCall: IToolCallsEntry): string {
127
+ const title = toolCall.title?.trim();
128
+
129
+ if (title) {
130
+ return title;
131
+ }
132
+
133
+ const verb = TOOL_KIND_LABELS[toolCall.kind ?? ''] ?? 'Working';
134
+ const location = getLocationSummary(toolCall);
135
+
136
+ if (location) {
137
+ return `${verb} ${location}`;
138
+ }
139
+
140
+ return `${verb}...`;
141
+ }
142
+
143
+ /**
144
+ * Compute the pre-permission detail text for a tool call, or null if the
145
+ * title alone is enough.
146
+ */
147
+ export function buildPermissionDetail(
148
+ toolCall: IToolCallsEntry
149
+ ): string | null {
150
+ const { kind, title, locations, rawInput } = toolCall;
151
+
152
+ if (kind === 'execute') {
153
+ const rawObject =
154
+ typeof rawInput === 'object' &&
155
+ rawInput !== null &&
156
+ !Array.isArray(rawInput)
157
+ ? (rawInput as Record<string, unknown>)
158
+ : null;
159
+ const command =
160
+ rawObject && typeof rawObject.command === 'string'
161
+ ? rawObject.command
162
+ : title
163
+ ?.replace(/^Running:\s*/i, '')
164
+ .replace(/\.\.\.$/, '')
165
+ .trim() || null;
166
+
167
+ if (!command || command === title) {
168
+ return null;
169
+ }
170
+
171
+ return '$ ' + command;
172
+ }
173
+
174
+ if (
175
+ (kind === 'delete' || kind === 'move' || kind === 'read') &&
176
+ locations?.length
177
+ ) {
178
+ return kind === 'move' && locations.length >= 2
179
+ ? `${toServerRelativePath(locations[0])} \u2192 ${toServerRelativePath(
180
+ locations[1]
181
+ )}`
182
+ : locations.map(toServerRelativePath).join('\n');
183
+ }
184
+
185
+ if (
186
+ rawInput !== null &&
187
+ rawInput !== undefined &&
188
+ typeof rawInput === 'object' &&
189
+ !Array.isArray(rawInput)
190
+ ) {
191
+ const rawObject = rawInput as Record<string, unknown>;
192
+ const purpose =
193
+ typeof rawObject.__tool_use_purpose === 'string'
194
+ ? rawObject.__tool_use_purpose
195
+ : null;
196
+ const paramEntries = Object.entries(rawObject).filter(
197
+ ([key]) => !key.startsWith('__')
198
+ );
199
+ const filteredParams = paramEntries.reduce<Record<string, unknown>>(
200
+ (result, [key, value]) => {
201
+ result[key] = value;
202
+ return result;
203
+ },
204
+ {}
205
+ );
206
+ const params =
207
+ paramEntries.length > 0 ? formatToolCallIO(filteredParams) : null;
208
+
209
+ if (purpose && params) {
210
+ return `${purpose}\n${params}`;
211
+ }
212
+
213
+ if (purpose) {
214
+ return purpose;
215
+ }
216
+
217
+ if (params) {
218
+ return params;
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ if (rawInput !== null && rawInput !== undefined) {
225
+ return formatToolCallIO(rawInput);
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Returns true when a completed/failed tool call has expandable detail content.
233
+ */
234
+ function hasDetailContent(toolCall: IToolCallsEntry): boolean {
235
+ const { kind, locations, rawInput, rawOutput } = toolCall;
236
+ const hasInput = rawInput !== null && rawInput !== undefined;
237
+ const hasOutput = rawOutput !== null && rawOutput !== undefined;
238
+
239
+ if (kind && FILE_KINDS.has(kind) && locations?.length) {
240
+ return true;
241
+ }
242
+ return hasInput || hasOutput;
243
+ }
244
+
245
+ function toDiffLineInfo(
246
+ type: 'added' | 'removed' | 'context',
247
+ text: string,
248
+ key: string
249
+ ): IDiffLineInfo {
250
+ switch (type) {
251
+ case 'added':
252
+ return {
253
+ cssClass: 'jp-mod-added',
254
+ prefix: '+',
255
+ text,
256
+ key
257
+ };
258
+ case 'removed':
259
+ return {
260
+ cssClass: 'jp-mod-removed',
261
+ prefix: '-',
262
+ text,
263
+ key
264
+ };
265
+ case 'context':
266
+ return {
267
+ cssClass: 'jp-mod-context',
268
+ prefix: ' ',
269
+ text,
270
+ key
271
+ };
272
+ }
273
+ }
274
+
275
+ function buildDiffLinesFromHunk(
276
+ hunk: StructuredPatchHunk,
277
+ hunkIndex: number
278
+ ): IDiffLineInfo[] {
279
+ return hunk.lines
280
+ .filter(line => !line.startsWith('\\'))
281
+ .map((line, lineIndex) => {
282
+ const prefix = line[0] ?? ' ';
283
+ const text = line.slice(1);
284
+ const key = `${hunkIndex}-${hunk.oldStart}-${hunk.newStart}-${lineIndex}`;
285
+
286
+ if (prefix === '+') {
287
+ return toDiffLineInfo('added', text, key);
288
+ }
289
+
290
+ if (prefix === '-') {
291
+ return toDiffLineInfo('removed', text, key);
292
+ }
293
+
294
+ return toDiffLineInfo('context', text, key);
295
+ });
296
+ }
297
+
298
+ function buildDiffLines(diff: IToolCallDiff): IDiffLineInfo[] {
299
+ const patch = structuredPatch(
300
+ diff.path,
301
+ diff.path,
302
+ diff.oldText ?? '',
303
+ diff.newText,
304
+ undefined,
305
+ undefined,
306
+ { context: Infinity }
307
+ );
308
+
309
+ return patch.hunks.reduce<IDiffLineInfo[]>((lines, hunk, index) => {
310
+ lines.push(...buildDiffLinesFromHunk(hunk, index));
311
+ return lines;
312
+ }, []);
313
+ }
314
+
315
+ /**
316
+ * A labeled section with a capped height and show-all/show-less toggle.
317
+ */
318
+ function ToolCallSection({
319
+ label,
320
+ children,
321
+ trans
322
+ }: {
323
+ label: string;
324
+ children: React.ReactNode;
325
+ trans: TranslationBundle;
326
+ }): JSX.Element {
327
+ const [expanded, setExpanded] = React.useState(false);
328
+ const [isOverflowing, setIsOverflowing] = React.useState(false);
329
+ const preRef = React.useRef<HTMLPreElement>(null);
330
+
331
+ React.useEffect(() => {
332
+ const details = preRef.current?.closest('details');
333
+ if (!details) {
334
+ return;
335
+ }
336
+ const handleToggle = () => {
337
+ if (!details.open) {
338
+ setExpanded(false);
339
+ }
340
+ };
341
+ details.addEventListener('toggle', handleToggle);
342
+ return () => details.removeEventListener('toggle', handleToggle);
343
+ }, []);
344
+
345
+ React.useLayoutEffect(() => {
346
+ const el = preRef.current;
347
+ if (!el) {
348
+ return;
349
+ }
350
+ const measure = () => {
351
+ if (!expanded) {
352
+ setIsOverflowing(el.scrollHeight > el.clientHeight);
353
+ }
354
+ };
355
+ const observer = new ResizeObserver(measure);
356
+ observer.observe(el);
357
+ measure();
358
+ return () => observer.disconnect();
359
+ }, [expanded]);
360
+
361
+ return (
362
+ <div className="jp-ai-tool-call-detail-section">
363
+ <div className="jp-ai-tool-call-detail-label">{label}</div>
364
+ <pre
365
+ ref={preRef}
366
+ className="jp-ai-tool-call-detail-code"
367
+ style={{
368
+ maxHeight: expanded
369
+ ? undefined
370
+ : `calc(${MAX_DETAIL_LINES} * var(--jp-content-line-height) * var(--jp-ui-font-size1))`
371
+ }}
372
+ >
373
+ <code>{children}</code>
374
+ </pre>
375
+ {!expanded && isOverflowing && (
376
+ <button
377
+ className="jp-ai-tool-call-diff-toggle"
378
+ onClick={() => setExpanded(true)}
379
+ type="button"
380
+ >
381
+ {trans.__('Show all')}
382
+ </button>
383
+ )}
384
+ {expanded && (
385
+ <button
386
+ className="jp-ai-tool-call-diff-toggle"
387
+ onClick={() => setExpanded(false)}
388
+ type="button"
389
+ >
390
+ {trans.__('Show less')}
391
+ </button>
392
+ )}
393
+ </div>
394
+ );
395
+ }
396
+
397
+ /**
398
+ * Expandable detail view for a completed or failed tool call.
399
+ */
400
+ function ToolCallDetail({
401
+ toolCall,
402
+ trans
403
+ }: {
404
+ toolCall: IToolCallsEntry;
405
+ trans: TranslationBundle;
406
+ }): JSX.Element {
407
+ const { kind, locations, rawInput, rawOutput } = toolCall;
408
+ const hasInput = rawInput !== null && rawInput !== undefined;
409
+ const hasOutput = rawOutput !== null && rawOutput !== undefined;
410
+
411
+ if (kind && FILE_KINDS.has(kind) && locations?.length) {
412
+ return (
413
+ <div className="jp-ai-tool-call-item-detail">
414
+ {locations.map((loc, i) => (
415
+ <div key={i} className="jp-ai-tool-call-item-detail-path">
416
+ {toServerRelativePath(loc)}
417
+ </div>
418
+ ))}
419
+ </div>
420
+ );
421
+ }
422
+
423
+ return (
424
+ <div className="jp-ai-tool-call-item-detail">
425
+ {hasInput && (
426
+ <ToolCallSection label={trans.__('Input')} trans={trans}>
427
+ {formatToolCallIO(rawInput)}
428
+ </ToolCallSection>
429
+ )}
430
+ {hasOutput && (
431
+ <ToolCallSection label={trans.__('Output')} trans={trans}>
432
+ {formatToolCallIO(rawOutput)}
433
+ </ToolCallSection>
434
+ )}
435
+ </div>
436
+ );
437
+ }
438
+
439
+ function ToolCallDiffBlock({
440
+ diff,
441
+ trans,
442
+ openToolCallPath,
443
+ pendingPermission
444
+ }: {
445
+ diff: IToolCallDiff;
446
+ trans: TranslationBundle;
447
+ openToolCallPath?: OpenToolCallPath;
448
+ pendingPermission?: boolean;
449
+ }): JSX.Element {
450
+ const [expanded, setExpanded] = React.useState(false);
451
+ const allLines = React.useMemo(() => buildDiffLines(diff), [diff]);
452
+ const canTruncate = allLines.length > MAX_DIFF_LINES;
453
+ const visibleLines =
454
+ canTruncate && !expanded ? allLines.slice(0, MAX_DIFF_LINES) : allLines;
455
+ const hiddenCount = allLines.length - MAX_DIFF_LINES;
456
+ const displayPath = toServerRelativePath(diff.path);
457
+ const canOpenPath =
458
+ !!openToolCallPath && !(pendingPermission && !diff.oldText);
459
+
460
+ return (
461
+ <div className="jp-ai-tool-call-diff-block">
462
+ <div
463
+ className={`jp-ai-tool-call-diff-header${
464
+ canOpenPath ? ' jp-ai-tool-call-diff-header-clickable' : ''
465
+ }`}
466
+ onClick={canOpenPath ? () => openToolCallPath!(displayPath) : undefined}
467
+ title={displayPath}
468
+ >
469
+ {displayPath}
470
+ </div>
471
+ <div className="jp-ai-tool-call-diff-content">
472
+ {visibleLines.length ? (
473
+ visibleLines.map(line => (
474
+ <div
475
+ key={line.key}
476
+ className={`jp-ai-tool-call-diff-line ${line.cssClass}`}
477
+ >
478
+ <span className="jp-ai-tool-call-diff-line-prefix">
479
+ {line.prefix}
480
+ </span>
481
+ <span className="jp-ai-tool-call-diff-line-text">
482
+ {line.text}
483
+ </span>
484
+ </div>
485
+ ))
486
+ ) : (
487
+ <div className="jp-ai-tool-call-diff-empty">
488
+ {trans.__('No changes')}
489
+ </div>
490
+ )}
491
+ {canTruncate && !expanded && (
492
+ <button
493
+ className="jp-ai-tool-call-diff-toggle"
494
+ onClick={() => setExpanded(true)}
495
+ type="button"
496
+ >
497
+ {trans.__('... %1 more lines', hiddenCount)}
498
+ </button>
499
+ )}
500
+ {canTruncate && expanded && (
501
+ <button
502
+ className="jp-ai-tool-call-diff-toggle"
503
+ onClick={() => setExpanded(false)}
504
+ type="button"
505
+ >
506
+ {trans.__('Show less')}
507
+ </button>
508
+ )}
509
+ </div>
510
+ </div>
511
+ );
512
+ }
513
+
514
+ function ToolCallDiffView({
515
+ diffs,
516
+ trans,
517
+ openToolCallPath,
518
+ pendingPermission
519
+ }: {
520
+ diffs: IToolCallDiff[];
521
+ trans: TranslationBundle;
522
+ openToolCallPath?: OpenToolCallPath;
523
+ pendingPermission?: boolean;
524
+ }): JSX.Element {
525
+ return (
526
+ <div className="jp-ai-tool-call-diff-container">
527
+ {diffs.map((diff, index) => (
528
+ <ToolCallDiffBlock
529
+ key={`${diff.path}-${index}`}
530
+ diff={diff}
531
+ trans={trans}
532
+ openToolCallPath={openToolCallPath}
533
+ pendingPermission={pendingPermission}
534
+ />
535
+ ))}
536
+ </div>
537
+ );
538
+ }
539
+
540
+ function PermissionLabel({
541
+ toolCall
542
+ }: {
543
+ toolCall: IToolCallsEntry;
544
+ }): JSX.Element | null {
545
+ if (toolCall.permissionStatus !== 'resolved' || !toolCall.selectedOptionId) {
546
+ return null;
547
+ }
548
+
549
+ const selectedName = toolCall.permissionOptions?.find(
550
+ option => option.optionId === toolCall.selectedOptionId
551
+ )?.name;
552
+
553
+ if (!selectedName) {
554
+ return null;
555
+ }
556
+
557
+ return (
558
+ <span className="jp-ai-tool-call-permission-label"> - {selectedName}</span>
559
+ );
560
+ }
561
+
562
+ function PermissionButtons({
563
+ toolCall,
564
+ trans,
565
+ toolCallPermissionDecision
566
+ }: {
567
+ toolCall: IToolCallsEntry;
568
+ trans: TranslationBundle;
569
+ toolCallPermissionDecision?: ToolCallPermissionDecision;
570
+ }): JSX.Element | null {
571
+ const [submitting, setSubmitting] = React.useState(false);
572
+
573
+ if (
574
+ !toolCall.permissionOptions?.length ||
575
+ toolCall.permissionStatus !== 'pending'
576
+ ) {
577
+ return null;
578
+ }
579
+
580
+ const canSubmit =
581
+ !!toolCallPermissionDecision &&
582
+ !!toolCall.sessionId &&
583
+ !!toolCall.toolCallId;
584
+
585
+ const handleClick = async (optionId: string) => {
586
+ if (!canSubmit) {
587
+ return;
588
+ }
589
+
590
+ setSubmitting(true);
591
+
592
+ try {
593
+ await toolCallPermissionDecision!(
594
+ toolCall.sessionId!,
595
+ toolCall.toolCallId,
596
+ optionId
597
+ );
598
+ } catch (error) {
599
+ console.error('Failed to submit tool call permission decision:', error);
600
+ setSubmitting(false);
601
+ }
602
+ };
603
+
604
+ return (
605
+ <div className="jp-ai-tool-call-permission-buttons">
606
+ <span className="jp-ai-tool-call-permission-tree">{'\u2514\u2500'}</span>
607
+ <span>{trans.__('Allow?')}</span>
608
+ {toolCall.permissionOptions.map(option => {
609
+ const optionClass = option.kind
610
+ ? ` jp-ai-tool-call-permission-btn-${option.kind.replace(/_/g, '-')}`
611
+ : '';
612
+
613
+ return (
614
+ <button
615
+ key={option.optionId}
616
+ className={`jp-ai-tool-call-permission-btn${optionClass}`}
617
+ onClick={() => handleClick(option.optionId)}
618
+ disabled={submitting || !canSubmit}
619
+ title={option.kind}
620
+ type="button"
621
+ >
622
+ {option.name}
623
+ </button>
624
+ );
625
+ })}
626
+ </div>
627
+ );
628
+ }
629
+
630
+ function ToolCallRow({
631
+ toolCall,
632
+ trans,
633
+ openToolCallPath,
634
+ toolCallPermissionDecision
635
+ }: {
636
+ toolCall: IToolCallsEntry;
637
+ trans: TranslationBundle;
638
+ openToolCallPath?: OpenToolCallPath;
639
+ toolCallPermissionDecision?: ToolCallPermissionDecision;
640
+ }): JSX.Element {
641
+ const displayTitle = getToolCallDisplayTitle(toolCall);
642
+ const selectedOption = toolCall.permissionOptions?.find(
643
+ option => option.optionId === toolCall.selectedOptionId
644
+ );
645
+ const status = toolCall.status ?? 'in_progress';
646
+
647
+ const isRejected =
648
+ toolCall.permissionStatus === 'resolved' &&
649
+ (!!selectedOption?.kind?.includes('reject') || status === 'rejected');
650
+ const hasPendingPermission = toolCall.permissionStatus === 'pending';
651
+ const isInProgress =
652
+ !isRejected &&
653
+ (status === 'in_progress' || status === 'pending' || hasPendingPermission);
654
+ const isCompleted = status === 'completed';
655
+ const isFailed = status === 'failed' || isRejected;
656
+ const icon = isInProgress
657
+ ? '\u2022'
658
+ : isCompleted
659
+ ? '\u2713'
660
+ : isFailed
661
+ ? '\u2717'
662
+ : '\u2022';
663
+ const effectiveStatus = (isRejected ? 'failed' : status).replace(/_/g, '-');
664
+
665
+ const hasDiffs = !!toolCall.diffs?.length;
666
+ const hasExpandableContent =
667
+ hasDiffs ||
668
+ (!hasDiffs && (isCompleted || isFailed) && hasDetailContent(toolCall));
669
+
670
+ const cssClass = `jp-ai-tool-call-item jp-ai-tool-call-item-${effectiveStatus}${!hasExpandableContent && !hasPendingPermission ? ' jp-ai-tool-call-item-no-detail' : ''}`;
671
+
672
+ if (hasDiffs && hasPendingPermission) {
673
+ return (
674
+ <div className={cssClass}>
675
+ <details open>
676
+ <summary>
677
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
678
+ <div className="jp-ai-tool-call-item-title">
679
+ {displayTitle}
680
+ {toolCall.summary && (
681
+ <span className="jp-ai-tool-call-item-summary">
682
+ {' '}
683
+ {toolCall.summary}
684
+ </span>
685
+ )}
686
+ </div>
687
+ </summary>
688
+ <ToolCallDiffView
689
+ diffs={toolCall.diffs!}
690
+ trans={trans}
691
+ openToolCallPath={openToolCallPath}
692
+ pendingPermission
693
+ />
694
+ </details>
695
+ <PermissionButtons
696
+ toolCall={toolCall}
697
+ trans={trans}
698
+ toolCallPermissionDecision={toolCallPermissionDecision}
699
+ />
700
+ </div>
701
+ );
702
+ }
703
+
704
+ if (!hasDiffs && hasPendingPermission) {
705
+ const permissionDetail = buildPermissionDetail(toolCall);
706
+
707
+ if (permissionDetail !== null) {
708
+ return (
709
+ <div className={cssClass}>
710
+ <details open>
711
+ <summary>
712
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
713
+ <div className="jp-ai-tool-call-item-title">
714
+ {displayTitle}
715
+ {toolCall.summary && (
716
+ <span className="jp-ai-tool-call-item-summary">
717
+ {' '}
718
+ {toolCall.summary}
719
+ </span>
720
+ )}
721
+ </div>
722
+ </summary>
723
+ <div className="jp-ai-tool-call-item-detail">
724
+ {permissionDetail}
725
+ </div>
726
+ </details>
727
+ <PermissionButtons
728
+ toolCall={toolCall}
729
+ trans={trans}
730
+ toolCallPermissionDecision={toolCallPermissionDecision}
731
+ />
732
+ </div>
733
+ );
734
+ }
735
+ }
736
+
737
+ if ((isCompleted || isFailed) && hasExpandableContent) {
738
+ return (
739
+ <details className={cssClass}>
740
+ <summary>
741
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
742
+ <div className="jp-ai-tool-call-item-title">
743
+ {displayTitle}
744
+ {toolCall.summary && (
745
+ <span className="jp-ai-tool-call-item-summary">
746
+ {' '}
747
+ {toolCall.summary}
748
+ </span>
749
+ )}
750
+ </div>
751
+ <PermissionLabel toolCall={toolCall} />
752
+ </summary>
753
+ {hasDiffs ? (
754
+ <ToolCallDiffView
755
+ diffs={toolCall.diffs!}
756
+ trans={trans}
757
+ openToolCallPath={openToolCallPath}
758
+ />
759
+ ) : (
760
+ <ToolCallDetail toolCall={toolCall} trans={trans} />
761
+ )}
762
+ </details>
763
+ );
764
+ }
765
+
766
+ if (isInProgress) {
767
+ return (
768
+ <div className={cssClass}>
769
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>{' '}
770
+ <div className="jp-ai-tool-call-item-title">
771
+ {displayTitle}
772
+ {toolCall.summary && (
773
+ <span className="jp-ai-tool-call-item-summary">
774
+ {' '}
775
+ {toolCall.summary}
776
+ </span>
777
+ )}
778
+ </div>
779
+ <PermissionButtons
780
+ toolCall={toolCall}
781
+ trans={trans}
782
+ toolCallPermissionDecision={toolCallPermissionDecision}
783
+ />
784
+ </div>
785
+ );
786
+ }
787
+
788
+ return (
789
+ <div className={cssClass}>
790
+ <span className="jp-ai-tool-call-item-icon">{icon}</span>
791
+ <div className="jp-ai-tool-call-item-title">
792
+ {displayTitle}
793
+ {toolCall.summary && (
794
+ <span className="jp-ai-tool-call-item-summary">
795
+ {' '}
796
+ {toolCall.summary}
797
+ </span>
798
+ )}
799
+ </div>
800
+ <PermissionLabel toolCall={toolCall} />
801
+ </div>
802
+ );
803
+ }
804
+
805
+ /**
806
+ * React component for rendering grouped tool calls.
807
+ */
808
+ export const GroupedToolCalls: React.FC<IGroupedToolCallsProps> = props => {
809
+ const trans = props.trans ?? nullTranslator.load('jupyterlab');
810
+ const { toolCalls } = props;
811
+
812
+ if (!toolCalls.length) {
813
+ return null;
814
+ }
815
+
816
+ return (
817
+ <div className="jp-ai-tool-calls">
818
+ {toolCalls.map(toolCall => (
819
+ <ToolCallRow
820
+ key={toolCall.toolCallId}
821
+ toolCall={toolCall}
822
+ trans={trans}
823
+ openToolCallPath={props.openToolCallPath}
824
+ toolCallPermissionDecision={props.toolCallPermissionDecision}
825
+ />
826
+ ))}
827
+ </div>
828
+ );
829
+ };