Home

mweinbach / agent-coworker

publicmweinbach/agent-coworker
Code Branches Pull requestsIssuesInsights
main
Home Code PRsIssues
mweinbach/agent-coworker/apps/desktop/src/ui/chat/FeedRow.tsx
1import {2  AlertCircleIcon,3  CheckIcon,4  CopyIcon,5  FileAudioIcon,6  FileIcon,7  FileImageIcon,8  FileSpreadsheetIcon,9  FileTextIcon,10  FileVideoIcon,11  Table2Icon,12} 
from
"lucide-react"
;
13import { memo, useEffect, useRef, useState } from "react";
14import type { CitationSource } from "../../../../../src/shared/displayCitationMarkers";
15import { extractCitationUrlsFromAnnotations } from "../../../../../src/shared/displayCitationMarkers";
16import type { FeedItem } from "../../app/types";
17import {
18 Attachment,
19 AttachmentContent,
20 AttachmentDescription,
21 AttachmentGroup,
22 AttachmentMedia,
23 AttachmentTitle,
24} from "../../components/ui/attachment";
25import { Bubble, BubbleContent } from "../../components/ui/bubble";
26import { Button } from "../../components/ui/button";
27import { Card, CardContent } from "../../components/ui/card";
28import { Marker, MarkerContent } from "../../components/ui/marker";
29import { Message, MessageContent } from "../../components/ui/message";
30import { copyText as writeClipboardText } from "../../lib/desktopCommands";
31import {
32 encodeDesktopMediaUrl,
33 isAbsoluteDesktopPath,
34 isDesktopMediaImagePath,
35} from "../../lib/mediaProtocol";
36import { openExternalSource } from "../../lib/openExternalSource";
37import { cn } from "../../lib/utils";
38import { DesktopMarkdown, rewriteDesktopImageUrl } from "../markdown";
39import { recordDesktopRenderMetric } from "../renderDiagnostics";
40import { useChatViewContext } from "./ChatViewContext";
41import { CitationSourcesCarousel } from "./CitationSourcesCarousel";
42import type { MentionCatalog } from "./composerMentions";
43import {
44 buildVisibleUserMessage,
45 type CanvasRequest,
46 canvasFallbackName,
47 type VisibleUserAttachment,
48} from "./feedMessageParsing";
49import { MentionText } from "./MentionText";
50import { ToolCard } from "./toolCards/ToolCard";
51 
52type CopyStatus = "idle" | "copied" | "failed";
53 
54function copyStatusLabel(status: CopyStatus, idleLabel: string): string {
55 switch (status) {
56 case "idle":
57 return idleLabel;
58 case "copied":
59 return "Copied";
60 case "failed":
61 return "Copy failed. Retry.";
62 default: {
63 const _exhaustive: never = status;
64 return _exhaustive;
65 }
66 }
67}
68 
69function copyButtonCaption(status: CopyStatus): string {
70 switch (status) {
71 case "idle":
72 return "Copy";
73 case "copied":
74 return "Copied";
75 case "failed":
76 return "Retry";
77 default: {
78 const _exhaustive: never = status;
79 return _exhaustive;
80 }
81 }
82}
83 
84function copyStatusSrOnly(status: CopyStatus): string {
85 switch (status) {
86 case "idle":
87 return "Copy";
88 case "copied":
89 return "Copied";
90 case "failed":
91 return "Copy failed. Retry.";
92 default: {
93 const _exhaustive: never = status;
94 return _exhaustive;
95 }
96 }
97}
98function copyLiveAnnouncement(status: CopyStatus): string {
99 switch (status) {
100 case "idle":
101 return "";
102 case "copied":
103 return "Copied";
104 case "failed":
105 return "Couldn't copy message. Try again.";
106 default: {
107 const _exhaustive: never = status;
108 return _exhaustive;
109 }
110 }
111}
112function CopyStatusIcon(props: { status: CopyStatus }) {
113 switch (props.status) {
114 case "copied":
115 return <CheckIcon data-icon="inline-start" className="text-success" />;
116 case "failed":
117 return <AlertCircleIcon data-icon="inline-start" className="text-destructive" />;
118 case "idle":
119 return <CopyIcon data-icon="inline-start" />;
120 default: {
121 const _exhaustive: never = props.status;
122 return _exhaustive;
123 }
124 }
125}
126 
127function useClipboardCopy() {
128 const [status, setStatus] = useState<CopyStatus>("idle");
129 const copyTimeoutRef = useRef<number | null>(null);
130 
131 useEffect(() => {
132 return () => {
133 if (copyTimeoutRef.current !== null) {
134 window.clearTimeout(copyTimeoutRef.current);
135 }
136 };
137 }, []);
138 
139 const copy = async (text: string) => {
140 try {
141 await writeClipboardText(text);
142 setStatus("copied");
143 if (copyTimeoutRef.current !== null) {
144 window.clearTimeout(copyTimeoutRef.current);
145 }
146 copyTimeoutRef.current = window.setTimeout(() => setStatus("idle"), 1500);
147 } catch {
148 if (copyTimeoutRef.current !== null) {
149 window.clearTimeout(copyTimeoutRef.current);
150 copyTimeoutRef.current = null;
151 }
152 setStatus("failed");
153 }
154 };
155 
156 return { status, copy };
157}
158 
159function MessageCopyAction(props: { text: string; className?: string }) {
160 const { status, copy } = useClipboardCopy();
161 const label = copyStatusLabel(status, "Copy message");
162 
163 return (
164 <Button
165 type="button"
166 variant="ghost"
167 size="icon-xs"
168 onClick={() => {
169 void copy(props.text);
170 }}
171 aria-label={label}
172 title={label}
173 className={cn(
174 "opacity-0 transition-opacity duration-150 focus-visible:opacity-100 group-hover/message:opacity-100 group-focus-within/message:opacity-100",
175 status !== "idle" && "opacity-100",
176 props.className,
177 )}
178 >
179 <CopyStatusIcon status={status} />
180 <span className="sr-only">{copyStatusSrOnly(status)}</span>
181 <span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
182 {copyLiveAnnouncement(status)}
183 </span>
184 </Button>
185 );
186}
187 
188function ErrorFeedRow(props: { message: string }) {
189 const { status, copy } = useClipboardCopy();
190 const [expanded, setExpanded] = useState(false);
191 const copyLabel = copyStatusLabel(status, "Copy error");
192 return (
193 <Card
194 role="alert"
195 aria-live="assertive"
196 aria-atomic="true"
197 className="w-full min-w-0 overflow-hidden border-destructive/40 bg-destructive/10"
198 >
199 <CardContent className="select-text p-3 text-sm">
200 <div className="mb-1 flex items-center justify-between gap-2">
201 <div className="font-semibold uppercase tracking-wide text-destructive">Error</div>
202 <div className="flex items-center gap-1">
203 <button
204 type="button"
205 onClick={() => {
206 void copy(props.message);
207 }}
208 aria-label={copyLabel}
209 title={copyLabel}
210 className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
211 >
212 {status === "copied" ? (
213 <CheckIcon className="size-3 text-success" />
214 ) : status === "failed" ? (
215 <AlertCircleIcon className="size-3 text-destructive" />
216 ) : (
217 <CopyIcon className="size-3" />
218 )}
219 {copyButtonCaption(status)}
220 </button>
221 <button
222 type="button"
223 onClick={() => setExpanded((e) => !e)}
224 aria-label={expanded ? "Collapse" : "Show full error"}
225 aria-expanded={expanded}
226 className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
227 >
228 {expanded ? "Less" : "More"}
229 </button>
230 </div>
231 </div>
232 <div
233 className={cn(
234 "whitespace-pre-wrap break-words [overflow-wrap:anywhere]",
235 expanded ? "max-h-none" : "max-h-72 overflow-auto",
236 )}
237 >
238 {props.message}
239 </div>
240 </CardContent>
241 </Card>
242 );
243}
244 
245export function CanvasRequestBody(props: { request: CanvasRequest; catalog: MentionCatalog }) {
246 const { request, catalog } = props;
247 const FileGlyph = request.surface === "spreadsheet" ? FileSpreadsheetIcon : FileTextIcon;
248 const fallbackName = canvasFallbackName(request.surface);
249 
250 return (
251 <div className="flex flex-col gap-2">
252 <div className="flex flex-wrap items-center gap-1.5 select-none">
253 <span className="inline-flex min-w-0 items-center gap-1 rounded-md border border-primary/25 bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-foreground/90">
254 <FileGlyph className="size-3 shrink-0 text-primary/80" />
255 <span className="max-w-[200px] truncate" title={request.fileName ?? fallbackName}>
256 {request.fileName ?? fallbackName}
257 </span>
258 </span>
259 {request.sheet ? (
260 <span className="inline-flex items-center gap-1 rounded-md bg-muted/50 px-1.5 py-0.5 text-xs text-muted-foreground">
261 <Table2Icon className="size-3 shrink-0" />
262 {request.sheet}
263 </span>
264 ) : null}
265 {request.region ? (
266 <span className="inline-flex items-center rounded-md bg-muted/50 px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
267 {request.region}
268 </span>
269 ) : null}
270 </div>
271 {request.selectionText ? (
272 <div
273 className="line-clamp-3 rounded-md border border-border/40 bg-muted/30 px-2 py-1 text-xs italic text-muted-foreground"
274 title={request.selectionText}
275 >
276 {`\u201C${request.selectionText}\u201D`}
277 </div>
278 ) : null}
279 {request.userRequest ? (
280 <div className="text-foreground">
281 <MentionText text={request.userRequest} catalog={catalog} />
282 </div>
283 ) : null}
284 </div>
285 );
286}
287 
288function attachmentIconForFilename(fileName: string) {
289 if (/\.(mp3|wav|ogg|m4a|aac|flac)$/i.test(fileName)) return FileAudioIcon;
290 if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(fileName)) return FileImageIcon;
291 if (/\.(mp4|mov|avi|mkv|webm)$/i.test(fileName)) return FileVideoIcon;
292 if (/\.pdf$/i.test(fileName)) return FileTextIcon;
293 return FileIcon;
294}
295 
296function attachmentTypeForFilename(fileName: string): string {
297 const extension = fileName.trim().split(".").at(-1);
298 return extension && extension !== fileName ? extension.toUpperCase() : "FILE";
299}
300 
301const WORKSPACE_UPLOADS_DIR = "User Uploads";
302const URL_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
303 
304function hasUnsafeAttachmentPreviewScheme(fileName: string): boolean {
305 const trimmed = fileName.trim();
306 if (!trimmed) return true;
307 return URL_SCHEME_RE.test(trimmed) && !isAbsoluteDesktopPath(trimmed);
308}
309 
310function isSafeAttachmentPreviewSrc(src: string): boolean {
311 return src.startsWith("cowork-media:");
312}
313 
314export function resolveUserAttachmentPreviewSrc(
315 fileName: string,
316 desktopBasePath?: string | null,
317): string | null {
318 if (!isDesktopMediaImagePath(fileName) || hasUnsafeAttachmentPreviewScheme(fileName)) {
319 return null;
320 }
321 
322 const absolute = encodeDesktopMediaUrl(fileName);
323 if (absolute && isSafeAttachmentPreviewSrc(absolute)) return absolute;
324 if (!desktopBasePath) return null;
325 
326 const normalized = fileName.replace(/\\/g, "/");
327 const candidates =
328 normalized.includes("/") || normalized.includes(":")
329 ? [normalized]
330 : [`${WORKSPACE_UPLOADS_DIR}/${normalized}`, normalized];
331 
332 for (const candidate of candidates) {
333 const rewritten = rewriteDesktopImageUrl(candidate, desktopBasePath);
334 if (rewritten && isSafeAttachmentPreviewSrc(rewritten)) return rewritten;
335 }
336 return null;
337}
338 
339function keyedAttachmentFileNames(fileNames: readonly string[]) {
340 const occurrences = new Map<string, number>();
341 return fileNames.map((fileName) => {
342 const occurrence = occurrences.get(fileName) ?? 0;
343 occurrences.set(fileName, occurrence + 1);
344 return { fileName, key: `${fileName}:${occurrence}` };
345 });
346}
347 
348function UserAttachmentGroup(props: {
349 attachments: readonly VisibleUserAttachment[];
350 desktopBasePath?: string | null;
351}) {
352 if (props.attachments.length === 0) return null;
353 const fileNames = props.attachments.map((attachment) => attachment.fileName);
354 return (
355 <AttachmentGroup className="max-w-full" aria-label="Attached files">
356 {keyedAttachmentFileNames(fileNames).map(({ fileName, key }, index) => {
357 const attachment = props.attachments[index];
358 const displayName = attachment?.displayName ?? fileName;
359 const previewSrc = resolveUserAttachmentPreviewSrc(fileName, props.desktopBasePath);
360 const IconComponent = attachmentIconForFilename(displayName);
361 return (
362 <Attachment key={key} size="sm">
363 <AttachmentMedia variant={previewSrc ? "image" : "icon"}>
364 {previewSrc ? (
365 <img src={previewSrc} alt="" className="size-full object-cover" draggable={false} />
366 ) : (
367 <IconComponent />
368 )}
369 </AttachmentMedia>
370 <AttachmentContent>
371 <AttachmentTitle title={displayName}>{displayName}</AttachmentTitle>
372 <AttachmentDescription>
373 {attachmentTypeForFilename(displayName)}
374 </AttachmentDescription>
375 </AttachmentContent>
376 </Attachment>
377 );
378 })}
379 </AttachmentGroup>
380 );
381}
382 
383export const FeedRow = memo(function FeedRow(props: {
384 item: FeedItem;
385 citationUrlsByIndex?: ReadonlyMap<number, string>;
386 citationSources?: CitationSource[];
387 desktopBasePath?: string | null;
388 isStreaming?: boolean;
389}) {
390 const { developerMode, mentionCatalog } = useChatViewContext();
391 const item = props.item;
392 recordDesktopRenderMetric("feed-row", item.id);
393 const hasSources = props.citationSources && props.citationSources.length > 0;
394 const hasInlineCitationChip =
395 item.kind === "message" &&
396 item.role === "assistant" &&
397 extractCitationUrlsFromAnnotations(item.annotations).size > 0;
398 
399 if (item.kind === "message") {
400 if (item.role === "user") {
401 // action special rendering removed (feature fully stripped)
402 }
403 
404 const visibleUserMessage = item.role === "user" ? buildVisibleUserMessage(item.text) : null;
405 const copyText = visibleUserMessage?.copyText ?? item.text;
406 const isStreamingAssistant = item.role === "assistant" && props.isStreaming === true;
407 if (isStreamingAssistant) {
408 recordDesktopRenderMetric("streaming-markdown", item.id);
409 }
410 
411 return (
412 <Message
413 role="article"
414 aria-label={item.role === "user" ? "Message from you" : "Message from Cowork"}
415 aria-busy={isStreamingAssistant || undefined}
416 align={item.role === "user" ? "end" : "start"}
417 >
418 <MessageContent className="relative">
419 {item.role === "assistant" ? (
420 <Bubble variant="ghost" align="start">
421 <BubbleContent className="text-[15px] leading-[1.65]">
422 <div data-slot={isStreamingAssistant ? "streaming-markdown" : "markdown"}>
423 <DesktopMarkdown
424 citationAnnotations={item.annotations}
425 citationSources={props.citationSources}
426 citationUrlsByIndex={props.citationUrlsByIndex}
427 caret="block"
428 desktopBasePath={props.desktopBasePath}
429 normalizeDisplayCitations
430 fallbackToSourcesFooter={!hasSources}
431 isAnimating={isStreamingAssistant}
432 mode={isStreamingAssistant ? "streaming" : "static"}
433 parseIncompleteMarkdown={isStreamingAssistant}
434 >
435 {item.text}
436 </DesktopMarkdown>
437 </div>
438 </BubbleContent>
439 </Bubble>
440 ) : (
441 <Bubble
442 variant="tinted"
443 align="end"
444 className="*:data-[slot=bubble-content]:border-primary/15 *:data-[slot=bubble-content]:bg-primary/[0.07] dark:*:data-[slot=bubble-content]:border-primary/20 dark:*:data-[slot=bubble-content]:bg-primary/[0.10]"
445 >
446 <BubbleContent className="cursor-text select-text rounded-2xl rounded-br-md px-3.5 py-2.5 text-[15px] leading-relaxed whitespace-pre-wrap selection:bg-primary/20">
447 <div className="flex flex-col gap-2">
448 {visibleUserMessage?.canvas ? (
449 <CanvasRequestBody
450 request={visibleUserMessage.canvas}
451 catalog={mentionCatalog}
452 />
453 ) : visibleUserMessage?.bodyText ? (
454 <MentionText text={visibleUserMessage.bodyText} catalog={mentionCatalog} />
455 ) : null}
456 {visibleUserMessage && visibleUserMessage.attachments.length > 0 ? (
457 <UserAttachmentGroup
458 attachments={visibleUserMessage.attachments}
459 desktopBasePath={props.desktopBasePath}
460 />
461 ) : null}
462 </div>
463 </BubbleContent>
464 </Bubble>
465 )}
466 
467 {hasSources && !hasInlineCitationChip && props.citationSources ? (
468 <CitationSourcesCarousel
469 sources={props.citationSources}
470 onOpenSource={openExternalSource}
471 />
472 ) : null}
473 
474 {copyText ? (
475 <div
476 className={cn(
477 "pointer-events-none -mt-2 flex h-6 items-center",
478 item.role === "user" ? "justify-end" : "justify-start",
479 )}
480 data-slot="message-actions"
481 >
482 <div className="pointer-events-auto">
483 <MessageCopyAction text={copyText} />
484 </div>
485 </div>
486 ) : null}
487 </MessageContent>
488 </Message>
489 );
490 }
491 
492 if (item.kind === "reasoning") {
493 return null;
494 }
495 
496 if (item.kind === "todos") {
497 return null;
498 }
499 
500 if (item.kind === "tool") {
501 return (
502 <ToolCard
503 name={item.name}
504 args={item.args}
505 approval={item.approval}
506 result={item.result}
507 state={item.state}
508 />
509 );
510 }
511 
512 if (item.kind === "log") {
513 if (!developerMode) return null;
514 return (
515 <Marker variant="border" className="select-text items-start">
516 <MarkerContent
517 role="log"
518 aria-label="Developer log"
519 aria-live="off"
520 className="flex flex-col gap-1 text-xs"
521 >
522 <span className="font-semibold uppercase tracking-wide text-primary">Log</span>
523 <span className="whitespace-pre-wrap">{item.line}</span>
524 </MarkerContent>
525 </Marker>
526 );
527 }
528 
529 if (item.kind === "error") {
530 return <ErrorFeedRow message={item.message} />;
531 }
532 
533 if (item.kind === "system") {
534 return (
535 <Marker variant="border" className="select-text items-start">
536 <MarkerContent role="status" className="flex flex-col gap-1 text-xs">
537 <span className="font-semibold uppercase tracking-wide text-primary">System</span>
538 <span className="whitespace-pre-wrap">{item.line}</span>
539 </MarkerContent>
540 </Marker>
541 );
542 }
543 
544 return null;
545});
546