@plannotator/ui 0.55.0 → 0.56.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/HANDOFF.md +43 -1
- package/README.md +81 -4
- package/components/AnnotationPanel.tsx +2 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/CodeFilePopout.tsx +6 -4
- package/components/ComposerTokenTextarea.tsx +43 -2
- package/components/DocBadges.tsx +47 -35
- package/components/ImageZoomStage.tsx +32 -4
- package/components/Settings.tsx +2 -1
- package/components/TableOfContents.tsx +2 -1
- package/components/Viewer.tsx +43 -17
- package/components/chat/ChatComposer.tsx +192 -0
- package/components/chat/ChatFrame.tsx +524 -0
- package/components/chat/ChatTranscript.tsx +422 -0
- package/components/chat/chatMarkdown.ts +436 -0
- package/components/chat/chatRecipes.tsx +39 -0
- package/components/chat/chatWindowStore.ts +196 -0
- package/components/chat/index.ts +72 -0
- package/components/diagram/DiagramCanvas.tsx +7 -0
- package/components/diagram/DiagramComposer.tsx +15 -3
- package/components/diagram/DiagramViewer.tsx +1 -0
- package/components/docBadgesWidth.ts +29 -0
- package/components/imageZoom.ts +10 -0
- package/components/inbox-thread/FileName.tsx +118 -0
- package/components/inbox-thread/GuideCard.tsx +39 -0
- package/components/inbox-thread/ReaskNote.tsx +25 -0
- package/components/inbox-thread/SkillsLine.tsx +40 -0
- package/components/inbox-thread/Tip.tsx +105 -0
- package/components/inbox-thread/icons.tsx +262 -0
- package/components/inbox-thread/reask.ts +222 -0
- package/hooks/useActiveSection.ts +46 -63
- package/hooks/useAnnotationHighlighter.ts +40 -1
- package/hooks/useFileBrowser.ts +4 -3
- package/inbox-thread.css +4542 -0
- package/package.json +10 -3
- package/styles.css +1 -1
- package/theme.css +1 -0
- package/themes/tokyo-night-moon.css +34 -0
- package/utils/aiChatFormat.ts +32 -11
- package/utils/headingScrollSpy.ts +19 -6
- package/utils/syntaxTheme.ts +1 -0
- package/utils/themeRegistry.ts +11 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useMemo, useCallback } from 'react';
|
|
2
|
+
import { basenameFromPath } from '@plannotator/core/source-save';
|
|
2
3
|
import type { Block, Annotation } from '../types';
|
|
3
4
|
import {
|
|
4
5
|
buildTocHierarchy,
|
|
@@ -146,7 +147,7 @@ export function TableOfContents({
|
|
|
146
147
|
)}
|
|
147
148
|
</div>
|
|
148
149
|
<p className="text-[11px] text-foreground/70 truncate mt-0.5" title={linkedDocFilepath}>
|
|
149
|
-
{linkedDocFilepath
|
|
150
|
+
{basenameFromPath(linkedDocFilepath)}
|
|
150
151
|
</p>
|
|
151
152
|
</div>
|
|
152
153
|
)}
|
package/components/Viewer.tsx
CHANGED
|
@@ -86,9 +86,10 @@ import { type QuickLabel } from '../utils/quickLabels';
|
|
|
86
86
|
import type { SelectionAction } from '../utils/selectionActions';
|
|
87
87
|
import type { MentionSource } from '../utils/mentions';
|
|
88
88
|
import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
|
|
89
|
+
import { legacyBadgesMaxWidth } from './docBadgesWidth';
|
|
89
90
|
import { PinpointOverlay } from './PinpointOverlay';
|
|
90
91
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
91
|
-
import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
92
|
+
import { rangeInsideExcluded, useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
92
93
|
import { useVimSelection } from '../hooks/useVimSelection';
|
|
93
94
|
import {
|
|
94
95
|
getScrollViewportIntersectionRoot,
|
|
@@ -403,12 +404,14 @@ export interface ViewerProps {
|
|
|
403
404
|
* Opt-in host capability, with `answerOnly`: the reader may comment on the
|
|
404
405
|
* text. A text selection opens the selection toolbar, comment-only
|
|
405
406
|
* (Comment and the one-click 👍; no Delete, no label picker), and its
|
|
406
|
-
* comment composer; `annotations` are drawn as highlights.
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
407
|
+
* comment composer; `annotations` are drawn as highlights. A rendered
|
|
408
|
+
* diagram takes comments on its parts the same way (`onAddAnnotation` gets
|
|
409
|
+
* a row with `diagramAnchor`). Question cards are left out of selection
|
|
410
|
+
* (their text is not commented on), and nothing else of authoring comes
|
|
411
|
+
* back (no pinpoint, code-block toolbar, Global comment or Copy, no Vim).
|
|
412
|
+
* For a host that shows a message whose words can be commented on (the
|
|
413
|
+
* Plannotator Inbox's agent messages). Ignored without `answerOnly` and
|
|
414
|
+
* under `readOnly`. Absent → unchanged.
|
|
412
415
|
*/
|
|
413
416
|
textComments?: boolean;
|
|
414
417
|
/**
|
|
@@ -647,6 +650,7 @@ const StickyActionsLane: React.FC<{ className: string; children: React.ReactNode
|
|
|
647
650
|
{/* After the lane: the lane is a flex box, so a float placed before it would narrow it. */}
|
|
648
651
|
<div
|
|
649
652
|
data-print-hide
|
|
653
|
+
data-sticky-actions-spacer
|
|
650
654
|
aria-hidden="true"
|
|
651
655
|
className="float-right"
|
|
652
656
|
style={box ? { width: box.width, height: box.height, marginTop: box.marginTop, marginRight: box.marginRight } : undefined}
|
|
@@ -920,27 +924,41 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
920
924
|
// card's top padding. One row fits; a second row (diff badge) or mobile
|
|
921
925
|
// wrapping outgrows the padding and lands on the document's first heading.
|
|
922
926
|
// Measure the cluster and insert exactly the clearance it needs (0 when it fits).
|
|
927
|
+
// The cluster also stops where the action cluster starts on the same row, so
|
|
928
|
+
// a long file name truncates at the buttons instead of running under them;
|
|
929
|
+
// with no buttons on its row (the phone layout) it stops inside the card.
|
|
923
930
|
const docBadgesRef = useRef<HTMLDivElement | null>(null);
|
|
924
931
|
const [badgeClearance, setBadgeClearance] = useState(0);
|
|
932
|
+
const [badgeMaxWidth, setBadgeMaxWidth] = useState<number | null>(null);
|
|
925
933
|
useEffect(() => {
|
|
926
934
|
if (hasViewerAnnotationHeader) {
|
|
927
935
|
setBadgeClearance(0);
|
|
936
|
+
setBadgeMaxWidth(null);
|
|
928
937
|
return;
|
|
929
938
|
}
|
|
930
939
|
const el = docBadgesRef.current;
|
|
931
940
|
const article = containerRef.current;
|
|
932
|
-
if (!el || !article) { setBadgeClearance(0); return; }
|
|
941
|
+
if (!el || !article) { setBadgeClearance(0); setBadgeMaxWidth(null); return; }
|
|
942
|
+
// The static float that holds the actions' place: the sticky lane's spacer
|
|
943
|
+
// (the lane itself moves while stuck), or the plain float without one.
|
|
944
|
+
const actions = article.querySelector<HTMLElement>('[data-sticky-actions-spacer]')
|
|
945
|
+
?? article.querySelector<HTMLElement>('[data-sticky-actions]');
|
|
933
946
|
const measure = () => {
|
|
934
947
|
const pad = parseFloat(getComputedStyle(article).paddingTop) || 0;
|
|
935
948
|
const overflow = el.offsetTop + el.offsetHeight - pad;
|
|
936
949
|
setBadgeClearance(overflow > 1 ? overflow + 4 : 0);
|
|
950
|
+
setBadgeMaxWidth(legacyBadgesMaxWidth(el.getBoundingClientRect(), actions?.getBoundingClientRect() ?? null, article.getBoundingClientRect()));
|
|
937
951
|
};
|
|
938
952
|
measure();
|
|
939
953
|
const ro = new ResizeObserver(measure);
|
|
940
954
|
ro.observe(el);
|
|
955
|
+
// The card too: when it narrows (the sidebar opens, the layout settles after
|
|
956
|
+
// first paint) the actions float moves without changing size.
|
|
957
|
+
ro.observe(article);
|
|
958
|
+
if (actions) ro.observe(actions);
|
|
941
959
|
window.addEventListener('resize', measure);
|
|
942
960
|
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
|
943
|
-
}, [hasViewerAnnotationHeader, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
961
|
+
}, [hasViewerAnnotationHeader, stickyActions, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
|
|
944
962
|
|
|
945
963
|
// Per-doc heading slug map with dedup — computed once per blocks array so
|
|
946
964
|
// anchor ids stay stable across re-renders and duplicate heading texts get
|
|
@@ -1380,6 +1398,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1380
1398
|
const handleCopy = (e: ClipboardEvent) => {
|
|
1381
1399
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
1382
1400
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
1401
|
+
// A live selection where nothing is annotated (an Inbox action item's text) is
|
|
1402
|
+
// the person's own: the browser copies it, never an older toolbar's text.
|
|
1403
|
+
const live = window.getSelection();
|
|
1404
|
+
if (live && !live.isCollapsed && live.rangeCount > 0 && containerRef.current && rangeInsideExcluded(containerRef.current, live.getRangeAt(0))) return;
|
|
1383
1405
|
|
|
1384
1406
|
if (toolbarState?.selectionText) {
|
|
1385
1407
|
e.preventDefault();
|
|
@@ -1731,9 +1753,13 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1731
1753
|
...(vimModeActive ? { outline: 'none' } : {}),
|
|
1732
1754
|
} as React.CSSProperties}
|
|
1733
1755
|
>
|
|
1734
|
-
{/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
|
|
1735
1756
|
{!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
|
|
1736
|
-
<div
|
|
1757
|
+
<div
|
|
1758
|
+
ref={docBadgesRef}
|
|
1759
|
+
data-print-hide
|
|
1760
|
+
className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}
|
|
1761
|
+
style={badgeMaxWidth !== null ? { maxWidth: badgeMaxWidth } : undefined}
|
|
1762
|
+
>
|
|
1737
1763
|
<DocBadges
|
|
1738
1764
|
layout="column"
|
|
1739
1765
|
repoInfo={repoInfo}
|
|
@@ -1840,10 +1866,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1840
1866
|
annotations={annotations}
|
|
1841
1867
|
selectedAnnotationId={selectedAnnotationId}
|
|
1842
1868
|
onSelectAnnotation={onSelectAnnotation}
|
|
1843
|
-
onAddAnnotation={
|
|
1844
|
-
readOnly={
|
|
1869
|
+
onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
|
|
1870
|
+
readOnly={!selectionCommentsOn}
|
|
1845
1871
|
onRestoreReport={onRestoreReport}
|
|
1846
|
-
onAskAI={
|
|
1872
|
+
onAskAI={selectionCommentsOn ? onAskAI : undefined}
|
|
1847
1873
|
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1848
1874
|
/>
|
|
1849
1875
|
</Suspense>
|
|
@@ -1856,10 +1882,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1856
1882
|
annotations={annotations}
|
|
1857
1883
|
selectedAnnotationId={selectedAnnotationId}
|
|
1858
1884
|
onSelectAnnotation={onSelectAnnotation}
|
|
1859
|
-
onAddAnnotation={
|
|
1860
|
-
readOnly={
|
|
1885
|
+
onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
|
|
1886
|
+
readOnly={!selectionCommentsOn}
|
|
1861
1887
|
onRestoreReport={onRestoreReport}
|
|
1862
|
-
onAskAI={
|
|
1888
|
+
onAskAI={selectionCommentsOn ? onAskAI : undefined}
|
|
1863
1889
|
askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
|
|
1864
1890
|
/>
|
|
1865
1891
|
</Suspense>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Send, Square } from 'lucide-react';
|
|
2
|
+
import type * as React from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { isImeCompositionKey } from '../../utils/commentSubmitKey';
|
|
6
|
+
import { Button } from '../ui/button';
|
|
7
|
+
import { Textarea } from '../ui/textarea';
|
|
8
|
+
import { CHAT_SEND_BUTTON, CHAT_STOP_BUTTON, FOCUS_RING } from './chatRecipes';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The text box's own box: border, radius, padding and type size. Spelled once
|
|
12
|
+
* so a host's layer painted over the field (`overlay`) can share its metrics.
|
|
13
|
+
* The scrollbar gutter is reserved, so a field that scrolls wraps where the
|
|
14
|
+
* layer does. 16 px below md keeps iOS Safari from zooming on focus.
|
|
15
|
+
*/
|
|
16
|
+
export const CHAT_COMPOSER_FIELD_BOX =
|
|
17
|
+
'rounded-md border px-3 py-2 text-base md:text-sm [scrollbar-gutter:stable]';
|
|
18
|
+
|
|
19
|
+
export interface ChatComposerProps {
|
|
20
|
+
/** The field's text. The composer is controlled: it holds none itself, so
|
|
21
|
+
* `onChange` must set this in the same tick or typing and IME input are lost. */
|
|
22
|
+
readonly value: string;
|
|
23
|
+
readonly onChange: (value: string) => void;
|
|
24
|
+
/** Enter in the field, Mod+Enter included, and the Send button. It runs even
|
|
25
|
+
* while `busy` or with an empty box, so a host decides what those mean. */
|
|
26
|
+
readonly onSend: () => void;
|
|
27
|
+
/** False: plain Enter is a new line and Mod+Enter or the button sends. For a
|
|
28
|
+
* touch keyboard, which has no Shift to make a new line with. */
|
|
29
|
+
readonly sendOnEnter?: boolean;
|
|
30
|
+
/** Stop, while `busy`. */
|
|
31
|
+
readonly onStop?: () => void;
|
|
32
|
+
/** A reply is running: the Send button becomes Stop. */
|
|
33
|
+
readonly busy?: boolean;
|
|
34
|
+
readonly disabled?: boolean;
|
|
35
|
+
/** The field's accessible name. */
|
|
36
|
+
readonly label?: string;
|
|
37
|
+
readonly placeholder?: string;
|
|
38
|
+
readonly sendLabel?: string;
|
|
39
|
+
readonly stopLabel?: string;
|
|
40
|
+
/** The host's handle on the field. */
|
|
41
|
+
readonly composerRef?: React.RefObject<HTMLTextAreaElement | null>;
|
|
42
|
+
/** Above the box (errors, queued messages). */
|
|
43
|
+
readonly notices?: React.ReactNode;
|
|
44
|
+
/** The quiet context line under the box. */
|
|
45
|
+
readonly below?: React.ReactNode;
|
|
46
|
+
/** The row with Send, left of it. */
|
|
47
|
+
readonly toolbar?: React.ReactNode;
|
|
48
|
+
/** In the box's positioned wrapper, before the field (a host's own menu). */
|
|
49
|
+
readonly assist?: React.ReactNode;
|
|
50
|
+
/** Painted over the field, in the field's own box. */
|
|
51
|
+
readonly overlay?: React.ReactNode;
|
|
52
|
+
/** Extra field classes. */
|
|
53
|
+
readonly fieldClassName?: string;
|
|
54
|
+
/** The id of the element describing what the message carries. */
|
|
55
|
+
readonly describedBy?: string;
|
|
56
|
+
/** With a host's menu in `assist`: whether it is open and which row is active. */
|
|
57
|
+
readonly expanded?: boolean;
|
|
58
|
+
readonly activeDescendant?: string;
|
|
59
|
+
/** Files pasted into the field. Absent: the paste is left to the browser. */
|
|
60
|
+
readonly onFiles?: (files: readonly File[]) => void;
|
|
61
|
+
/** Pasted text, before the field takes it: true when the host took it. */
|
|
62
|
+
readonly onPasteText?: (text: string) => boolean;
|
|
63
|
+
/** The caret or the selection moved. */
|
|
64
|
+
readonly onCaret?: (field: HTMLTextAreaElement) => void;
|
|
65
|
+
/** An IME composition started (true) or ended (false). */
|
|
66
|
+
readonly onComposing?: (composing: boolean) => void;
|
|
67
|
+
/** Runs before the composer's own keys; `preventDefault` keeps Enter from sending. */
|
|
68
|
+
readonly onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The message box. It has no picker of its own; a host adds one through `assist`. */
|
|
72
|
+
export function ChatComposer({
|
|
73
|
+
value,
|
|
74
|
+
onChange,
|
|
75
|
+
onSend,
|
|
76
|
+
sendOnEnter = true,
|
|
77
|
+
onStop,
|
|
78
|
+
busy = false,
|
|
79
|
+
disabled,
|
|
80
|
+
label = 'Message',
|
|
81
|
+
placeholder,
|
|
82
|
+
sendLabel = 'Send',
|
|
83
|
+
stopLabel = 'Stop',
|
|
84
|
+
composerRef,
|
|
85
|
+
notices,
|
|
86
|
+
below,
|
|
87
|
+
toolbar,
|
|
88
|
+
assist,
|
|
89
|
+
overlay,
|
|
90
|
+
fieldClassName,
|
|
91
|
+
describedBy,
|
|
92
|
+
expanded,
|
|
93
|
+
activeDescendant,
|
|
94
|
+
onFiles,
|
|
95
|
+
onPasteText,
|
|
96
|
+
onCaret,
|
|
97
|
+
onComposing,
|
|
98
|
+
onKeyDown,
|
|
99
|
+
}: ChatComposerProps) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex max-h-[60%] shrink-0 flex-col gap-2 border-t border-border px-3 pb-2 pt-2.5">
|
|
102
|
+
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto empty:hidden">{notices}</div>
|
|
103
|
+
<div className="relative shrink-0">
|
|
104
|
+
{assist}
|
|
105
|
+
<Textarea
|
|
106
|
+
ref={composerRef}
|
|
107
|
+
rows={2}
|
|
108
|
+
value={value}
|
|
109
|
+
disabled={disabled}
|
|
110
|
+
placeholder={placeholder}
|
|
111
|
+
aria-label={label}
|
|
112
|
+
aria-autocomplete={expanded === undefined ? undefined : 'list'}
|
|
113
|
+
aria-expanded={expanded}
|
|
114
|
+
aria-activedescendant={activeDescendant}
|
|
115
|
+
aria-describedby={describedBy}
|
|
116
|
+
// The box grows with its text up to 128 px, then scrolls inside itself.
|
|
117
|
+
className={cn(
|
|
118
|
+
CHAT_COMPOSER_FIELD_BOX,
|
|
119
|
+
'field-sizing-content max-h-32 min-h-16 resize-none bg-card shadow-none',
|
|
120
|
+
FOCUS_RING,
|
|
121
|
+
fieldClassName,
|
|
122
|
+
)}
|
|
123
|
+
onChange={(event) => onChange(event.target.value)}
|
|
124
|
+
onSelect={(event) => onCaret?.(event.currentTarget)}
|
|
125
|
+
onClick={(event) => onCaret?.(event.currentTarget)}
|
|
126
|
+
onKeyUp={(event) => {
|
|
127
|
+
if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') {
|
|
128
|
+
onCaret?.(event.currentTarget);
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
onCompositionStart={() => onComposing?.(true)}
|
|
132
|
+
onCompositionEnd={() => onComposing?.(false)}
|
|
133
|
+
onPaste={(event) => {
|
|
134
|
+
const files = Array.from(event.clipboardData?.files ?? []);
|
|
135
|
+
if (files.length > 0 && onFiles !== undefined) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
onFiles(files);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
141
|
+
if (text !== '' && onPasteText?.(text) === true) event.preventDefault();
|
|
142
|
+
}}
|
|
143
|
+
onKeyDown={(event) => {
|
|
144
|
+
// Enter, in any chord, does not bubble past the React root, so a
|
|
145
|
+
// bubble listener on window or document (a Send elsewhere on the
|
|
146
|
+
// page) does not hear it. Capture listeners and native listeners on
|
|
147
|
+
// the box's ancestors still do, and keyup is not stopped.
|
|
148
|
+
const enter = event.key === 'Enter';
|
|
149
|
+
if (enter) event.stopPropagation();
|
|
150
|
+
// Safari's Enter that commits a composition has isComposing false
|
|
151
|
+
// and keyCode 229; this checks both.
|
|
152
|
+
if (isImeCompositionKey(event)) return;
|
|
153
|
+
onKeyDown?.(event);
|
|
154
|
+
if (!enter || event.defaultPrevented) return;
|
|
155
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
156
|
+
if (!mod && (event.shiftKey || !sendOnEnter)) return; // a new line
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
if (!event.shiftKey) onSend();
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
{overlay}
|
|
162
|
+
</div>
|
|
163
|
+
{below}
|
|
164
|
+
<div className="flex min-w-0 shrink-0 items-center gap-1" data-chat-composer-toolbar>
|
|
165
|
+
{toolbar}
|
|
166
|
+
<div className="ml-auto shrink-0">
|
|
167
|
+
{busy ? (
|
|
168
|
+
<Button
|
|
169
|
+
variant="ghost"
|
|
170
|
+
size="icon"
|
|
171
|
+
aria-label={stopLabel}
|
|
172
|
+
className={CHAT_STOP_BUTTON}
|
|
173
|
+
onClick={onStop}
|
|
174
|
+
>
|
|
175
|
+
<Square aria-hidden="true" className="size-3" />
|
|
176
|
+
</Button>
|
|
177
|
+
) : (
|
|
178
|
+
<Button
|
|
179
|
+
size="icon"
|
|
180
|
+
aria-label={sendLabel}
|
|
181
|
+
className={CHAT_SEND_BUTTON}
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
onClick={onSend}
|
|
184
|
+
>
|
|
185
|
+
<Send aria-hidden="true" className="size-3.5" />
|
|
186
|
+
</Button>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|