|
1 | | -import { Check, Copy } from 'lucide-react' |
| 1 | +import { Check, Copy, GitFork } from 'lucide-react' |
2 | 2 | import { |
3 | 3 | memo, |
4 | 4 | useEffect, |
@@ -28,6 +28,7 @@ import { ThinkingIndicator } from './ThinkingIndicator' |
28 | 28 | import { ElapsedTime } from './ElapsedTime' |
29 | 29 | import { formatElapsedDuration, toMillis } from '../lib/formatTime' |
30 | 30 | import styles from './AgentResponse.module.css' |
| 31 | +import type { ForkFromHereAction } from './UserMessage' |
31 | 32 | import type { |
32 | 33 | EntityTimelineContentItem, |
33 | 34 | EntityTimelineRunRow, |
@@ -383,13 +384,15 @@ export const AgentResponseLive = memo(function AgentResponseLive({ |
383 | 384 | isStreaming, |
384 | 385 | timestamp, |
385 | 386 | renderWidth = 0, |
| 387 | + forkFromHere, |
386 | 388 | onSearchTextChange, |
387 | 389 | }: { |
388 | 390 | rowKey: string |
389 | 391 | run: EntityTimelineRunRow |
390 | 392 | isStreaming: boolean |
391 | 393 | timestamp?: number | null |
392 | 394 | renderWidth?: number |
| 395 | + forkFromHere?: ForkFromHereAction |
393 | 396 | onSearchTextChange?: (rowKey: string, text: string) => void |
394 | 397 | }): React.ReactElement { |
395 | 398 | const { data: items = [] } = useLiveQuery( |
@@ -433,6 +436,7 @@ export const AgentResponseLive = memo(function AgentResponseLive({ |
433 | 436 | isStreaming && !done && !failureText && !lastTextHasContent |
434 | 437 | const showTimestamp = timestamp != null && !isStreaming |
435 | 438 | const hasLeadingMeta = showThinking || done || Boolean(failureText) |
| 439 | + const showCopyAction = Boolean((done || failureText) && copyText) |
436 | 440 | const [copied, setCopied] = useState(false) |
437 | 441 | const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
438 | 442 |
|
@@ -542,39 +546,88 @@ export const AgentResponseLive = memo(function AgentResponseLive({ |
542 | 546 | <TimeText ts={timestamp} className={styles.timeText} /> |
543 | 547 | </> |
544 | 548 | )} |
545 | | - {(done || failureText) && copyText && ( |
546 | | - <Tooltip content={copied ? `Copied!` : `Copy response`} side="top"> |
| 549 | + <ResponseMetaActions |
| 550 | + showCopy={showCopyAction} |
| 551 | + copied={copied} |
| 552 | + onCopy={() => void copyResponseText()} |
| 553 | + forkFromHere={done ? forkFromHere : undefined} |
| 554 | + /> |
| 555 | + </Stack> |
| 556 | + </Stack> |
| 557 | + ) |
| 558 | +}) |
| 559 | + |
| 560 | +function ResponseMetaActions({ |
| 561 | + showCopy, |
| 562 | + copied, |
| 563 | + onCopy, |
| 564 | + forkFromHere, |
| 565 | +}: { |
| 566 | + showCopy: boolean |
| 567 | + copied: boolean |
| 568 | + onCopy: () => void |
| 569 | + forkFromHere?: ForkFromHereAction |
| 570 | +}): React.ReactElement | null { |
| 571 | + const showFork = forkFromHere !== undefined |
| 572 | + if (!showCopy && !showFork) return null |
| 573 | + |
| 574 | + const forkDisabled = forkFromHere?.disabled === true || !forkFromHere?.onFork |
| 575 | + const forkLabel = forkDisabled ? `Fork permission required` : `Fork from here` |
| 576 | + |
| 577 | + return ( |
| 578 | + <span className={styles.metaActions}> |
| 579 | + {showFork && ( |
| 580 | + <Tooltip content={forkLabel} side="top"> |
| 581 | + <span className={styles.tooltipTrigger}> |
547 | 582 | <IconButton |
548 | 583 | size={1} |
549 | 584 | variant="ghost" |
550 | 585 | tone="neutral" |
551 | | - className={styles.copyButton} |
552 | | - onClick={() => void copyResponseText()} |
553 | | - aria-label="Copy response text" |
| 586 | + className={styles.metaActionButton} |
| 587 | + disabled={forkDisabled} |
| 588 | + onClick={forkFromHere?.onFork} |
| 589 | + aria-label="Fork from here" |
| 590 | + title={forkLabel} |
554 | 591 | > |
555 | | - {copied ? ( |
556 | | - <Icon icon={Check} size={1} /> |
557 | | - ) : ( |
558 | | - <Icon icon={Copy} size={1} /> |
559 | | - )} |
| 592 | + <Icon icon={GitFork} size={1} /> |
560 | 593 | </IconButton> |
561 | | - </Tooltip> |
562 | | - )} |
563 | | - </Stack> |
564 | | - </Stack> |
| 594 | + </span> |
| 595 | + </Tooltip> |
| 596 | + )} |
| 597 | + {showCopy && ( |
| 598 | + <Tooltip content={copied ? `Copied!` : `Copy response`} side="top"> |
| 599 | + <IconButton |
| 600 | + size={1} |
| 601 | + variant="ghost" |
| 602 | + tone="neutral" |
| 603 | + className={styles.metaActionButton} |
| 604 | + onClick={onCopy} |
| 605 | + aria-label="Copy response text" |
| 606 | + > |
| 607 | + {copied ? ( |
| 608 | + <Icon icon={Check} size={1} /> |
| 609 | + ) : ( |
| 610 | + <Icon icon={Copy} size={1} /> |
| 611 | + )} |
| 612 | + </IconButton> |
| 613 | + </Tooltip> |
| 614 | + )} |
| 615 | + </span> |
565 | 616 | ) |
566 | | -}) |
| 617 | +} |
567 | 618 |
|
568 | 619 | export const AgentResponse = memo(function AgentResponse({ |
569 | 620 | section, |
570 | 621 | isStreaming, |
571 | 622 | timestamp, |
572 | 623 | renderWidth = 0, |
| 624 | + forkFromHere, |
573 | 625 | }: { |
574 | 626 | section: AgentResponseSection |
575 | 627 | isStreaming: boolean |
576 | 628 | timestamp?: number | null |
577 | 629 | renderWidth?: number |
| 630 | + forkFromHere?: ForkFromHereAction |
578 | 631 | }): React.ReactElement { |
579 | 632 | const canCache = !isStreaming && section.done === true |
580 | 633 | const [copied, setCopied] = useState(false) |
@@ -616,6 +669,7 @@ export const AgentResponse = memo(function AgentResponse({ |
616 | 669 | isStreaming && !section.done && !section.error && !lastTextHasContent |
617 | 670 | const showTimestamp = timestamp != null && !isStreaming |
618 | 671 | const hasLeadingMeta = showThinking || section.done || Boolean(section.error) |
| 672 | + const showCopyAction = Boolean(section.done && copyText) |
619 | 673 |
|
620 | 674 | // Mirror of the `sawStreamingRef` / `finalDurationMs` capture from |
621 | 675 | // `AgentResponseLive` — see the comment there for why we only |
@@ -696,36 +750,12 @@ export const AgentResponse = memo(function AgentResponse({ |
696 | 750 | <TimeText ts={timestamp} className={styles.timeText} /> |
697 | 751 | </> |
698 | 752 | )} |
699 | | - {section.done && copyText && ( |
700 | | - <Tooltip content={copied ? `Copied!` : `Copy response`} side="top"> |
701 | | - <IconButton |
702 | | - size={1} |
703 | | - variant="ghost" |
704 | | - tone="neutral" |
705 | | - className={styles.copyButton} |
706 | | - onClick={() => void copyResponseText()} |
707 | | - aria-label="Copy response text" |
708 | | - > |
709 | | - {copied ? ( |
710 | | - <Icon icon={Check} size={1} /> |
711 | | - ) : ( |
712 | | - <Icon icon={Copy} size={1} /> |
713 | | - )} |
714 | | - </IconButton> |
715 | | - </Tooltip> |
716 | | - )} |
717 | | - {section.done && copyText && ( |
718 | | - <button |
719 | | - type="button" |
720 | | - className={styles.copyButton} |
721 | | - onClick={() => void copyResponseText()} |
722 | | - aria-label="Copy response text" |
723 | | - title={copied ? `Copied` : `Copy all response text`} |
724 | | - > |
725 | | - {copied ? <Check size={12} /> : <Copy size={12} />} |
726 | | - <span>{copied ? `Copied` : `Copy`}</span> |
727 | | - </button> |
728 | | - )} |
| 753 | + <ResponseMetaActions |
| 754 | + showCopy={showCopyAction} |
| 755 | + copied={copied} |
| 756 | + onCopy={() => void copyResponseText()} |
| 757 | + forkFromHere={section.done ? forkFromHere : undefined} |
| 758 | + /> |
729 | 759 | </Stack> |
730 | 760 | </Stack> |
731 | 761 | ) |
|
0 commit comments