Unified · Split
| @@ -70,11 +70,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay"; · 69 unmodified lines | ||
| 70 | 70 | // --------------------------------------------------------------------------- |
| 71 | 71 | |
| 72 | 72 | interface TimelineRowSharedState { |
| 73 | − activeTurnInProgress: boolean; | |
| 74 | − activeTurnId: TurnId | null | undefined; | |
| 75 | − isWorking: boolean; | |
| 76 | − isRevertingCheckpoint: boolean; | |
| 77 | − completionSummary: string | null; | |
| 78 | 73 | timestampFormat: TimestampFormat; |
| 79 | 74 | routeThreadKey: string; |
| 80 | 75 | markdownCwd: string | undefined; |
| @@ -86,7 +81,16 @@ interface TimelineRowSharedState { · 5 unmodified lines | ||
| 86 | 81 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 87 | 82 | } |
| 88 | 83 | |
| 84 | +interface TimelineRowActivityState { | |
| 85 | + activeTurnInProgress: boolean; | |
| 86 | + activeTurnId: TurnId | null; | |
| 87 | + isWorking: boolean; | |
| 88 | + isRevertingCheckpoint: boolean; | |
| 89 | + completionSummary: string | null; | |
| 90 | +} | |
| 91 | + | |
| 89 | 92 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| 93 | +const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); | |
| 90 | 94 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 91 | 95 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; |
| 92 | 96 | |
| @@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 98 unmodified lines | ||
| 191 | 195 | }; |
| 192 | 196 | }, [listRef, onIsAtEndChange, rows.length]); |
| 193 | 197 | |
| 194 | − // Memoised context value — only changes on state transitions, NOT on | |
| 195 | − // every streaming chunk. Callbacks from ChatView are useCallback-stable. | |
| 196 | 198 | const sharedState = useMemo<TimelineRowSharedState>( |
| 197 | 199 | () => ({ |
| 198 | − activeTurnInProgress, | |
| 199 | − activeTurnId: activeTurnId ?? null, | |
| 200 | − isWorking, | |
| 201 | − isRevertingCheckpoint, | |
| 202 | − completionSummary, | |
| 203 | 200 | timestampFormat, |
| 204 | 201 | routeThreadKey, |
| 205 | 202 | markdownCwd, |
| @@ -211,11 +208,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 5 unmodified lines | ||
| 211 | 208 | onOpenTurnDiff, |
| 212 | 209 | }), |
| 213 | 210 | [ |
| 214 | − activeTurnInProgress, | |
| 215 | − activeTurnId, | |
| 216 | − isWorking, | |
| 217 | − isRevertingCheckpoint, | |
| 218 | − completionSummary, | |
| 219 | 211 | timestampFormat, |
| 220 | 212 | routeThreadKey, |
| 221 | 213 | markdownCwd, |
| @@ -227,6 +219,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 5 unmodified lines | ||
| 227 | 219 | onOpenTurnDiff, |
| 228 | 220 | ], |
| 229 | 221 | ); |
| 222 | + const activityState = useMemo<TimelineRowActivityState>( | |
| 223 | + () => ({ | |
| 224 | + activeTurnInProgress, | |
| 225 | + activeTurnId: activeTurnId ?? null, | |
| 226 | + isWorking, | |
| 227 | + isRevertingCheckpoint, | |
| 228 | + completionSummary, | |
| 229 | + }), | |
| 230 | + [activeTurnInProgress, activeTurnId, completionSummary, isRevertingCheckpoint, isWorking], | |
| 231 | + ); | |
| 230 | 232 | |
| 231 | 233 | // Stable renderItem — no closure deps. Row components read shared state |
| 232 | 234 | // from TimelineRowCtx, which propagates through LegendList's memo. |
| @@ -251,21 +253,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 18 unmodified lines | ||
| 251 | 253 | |
| 252 | 254 | return ( |
| 253 | 255 | <TimelineRowCtx.Provider value={sharedState}> |
| 254 | − <LegendList<MessagesTimelineRow> | |
| 255 | − ref={listRef} | |
| 256 | − data={rows} | |
| 257 | − keyExtractor={keyExtractor} | |
| 258 | − renderItem={renderItem} | |
| 259 | − estimatedItemSize={90} | |
| 260 | − initialScrollAtEnd | |
| 261 | − maintainScrollAtEnd | |
| 262 | − maintainScrollAtEndThreshold={0.1} | |
| 263 | − maintainVisibleContentPosition | |
| 264 | − onScroll={handleScroll} | |
| 265 | − className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" | |
| 266 | − ListHeaderComponent={TIMELINE_LIST_HEADER} | |
| 267 | − ListFooterComponent={TIMELINE_LIST_FOOTER} | |
| 268 | − /> | |
| 256 | + <TimelineRowActivityCtx.Provider value={activityState}> | |
| 257 | + <LegendList<MessagesTimelineRow> | |
| 258 | + ref={listRef} | |
| 259 | + data={rows} | |
| 260 | + keyExtractor={keyExtractor} | |
| 261 | + renderItem={renderItem} | |
| 262 | + estimatedItemSize={90} | |
| 263 | + initialScrollAtEnd | |
| 264 | + maintainScrollAtEnd | |
| 265 | + maintainScrollAtEndThreshold={0.1} | |
| 266 | + maintainVisibleContentPosition | |
| 267 | + onScroll={handleScroll} | |
| 268 | + className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" | |
| 269 | + ListHeaderComponent={TIMELINE_LIST_HEADER} | |
| 270 | + ListFooterComponent={TIMELINE_LIST_FOOTER} | |
| 271 | + /> | |
| 272 | + </TimelineRowActivityCtx.Provider> | |
| 269 | 273 | </TimelineRowCtx.Provider> |
| 270 | 274 | ); |
| 271 | 275 | }); |
| @@ -283,9 +287,7 @@ type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; · 11 unmodified lines | ||
| 283 | 287 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; |
| 284 | 288 | type TimelineRow = MessagesTimelineRow; |
| 285 | 289 | |
| 286 | −function TimelineRowContent({ row }: { row: TimelineRow }) { | |
| 287 | − const ctx = use(TimelineRowCtx); | |
| 288 | − | |
| 290 | +const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { | |
| 289 | 291 | return ( |
| 290 | 292 | <div |
| 291 | 293 | className={cn( |
| @@ -297,187 +299,222 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { · 5 unmodified lines | ||
| 297 | 299 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| 298 | 300 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 299 | 301 | > |
| 300 | − {row.kind === "work" && <WorkGroupSection groupedEntries={row.groupedEntries} />} | |
| 301 | − | |
| 302 | − {row.kind === "message" && | |
| 303 | − row.message.role === "user" && | |
| 304 | − (() => { | |
| 305 | − const userImages = row.message.attachments ?? []; | |
| 306 | − const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); | |
| 307 | − const terminalContexts = displayedUserMessage.contexts; | |
| 308 | − const canRevertAgentWork = typeof row.revertTurnCount === "number"; | |
| 309 | − return ( | |
| 310 | − <div className="flex justify-end"> | |
| 311 | − <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 312 | − {userImages.length > 0 && ( | |
| 313 | − <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 314 | − {userImages.map( | |
| 315 | − (image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | |
| 316 | − <div | |
| 317 | − key={image.id} | |
| 318 | − className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 319 | − > | |
| 320 | − {image.previewUrl ? ( | |
| 321 | − <button | |
| 322 | − type="button" | |
| 323 | − className="h-full w-full cursor-zoom-in" | |
| 324 | − aria-label={`Preview ${image.name}`} | |
| 325 | − onClick={() => { | |
| 326 | − const preview = buildExpandedImagePreview(userImages, image.id); | |
| 327 | − if (!preview) return; | |
| 328 | − ctx.onImageExpand(preview); | |
| 329 | − }} | |
| 330 | − > | |
| 331 | − <img | |
| 332 | − src={image.previewUrl} | |
| 333 | − alt={image.name} | |
| 334 | − className="block h-auto max-h-[220px] w-full object-cover" | |
| 335 | − /> | |
| 336 | − </button> | |
| 337 | − ) : ( | |
| 338 | − <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 339 | − {image.name} | |
| 340 | − </div> | |
| 341 | − )} | |
| 342 | − </div> | |
| 343 | − ), | |
| 344 | − )} | |
| 302 | + {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null} | |
| 303 | + {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} | |
| 304 | + {row.kind === "message" && row.message.role === "assistant" ? ( | |
| 305 | + <AssistantTimelineRow row={row} /> | |
| 306 | + ) : null} | |
| 307 | + {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} | |
| 308 | + {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} | |
| 309 | + </div> | |
| 310 | + ); | |
| 311 | +}); | |
| 312 | + | |
| 313 | +function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | |
| 314 | + const ctx = use(TimelineRowCtx); | |
| 315 | + const userImages = row.message.attachments ?? []; | |
| 316 | + const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); | |
| 317 | + const terminalContexts = displayedUserMessage.contexts; | |
| 318 | + const canRevertAgentWork = typeof row.revertTurnCount === "number"; | |
| 319 | + | |
| 320 | + return ( | |
| 321 | + <div className="flex justify-end"> | |
| 322 | + <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 323 | + {userImages.length > 0 && ( | |
| 324 | + <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 325 | + {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | |
| 326 | + <div | |
| 327 | + key={image.id} | |
| 328 | + className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 329 | + > | |
| 330 | + {image.previewUrl ? ( | |
| 331 | + <button | |
| 332 | + type="button" | |
| 333 | + className="h-full w-full cursor-zoom-in" | |
| 334 | + aria-label={`Preview ${image.name}`} | |
| 335 | + onClick={() => { | |
| 336 | + const preview = buildExpandedImagePreview(userImages, image.id); | |
| 337 | + if (!preview) return; | |
| 338 | + ctx.onImageExpand(preview); | |
| 339 | + }} | |
| 340 | + > | |
| 341 | + <img | |
| 342 | + src={image.previewUrl} | |
| 343 | + alt={image.name} | |
| 344 | + className="block h-auto max-h-[220px] w-full object-cover" | |
| 345 | + /> | |
| 346 | + </button> | |
| 347 | + ) : ( | |
| 348 | + <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 349 | + {image.name} | |
| 345 | 350 | </div> |
| 346 | 351 | )} |
| 347 | − {(displayedUserMessage.visibleText.trim().length > 0 || | |
| 348 | − terminalContexts.length > 0) && ( | |
| 349 | − <UserMessageBody | |
| 350 | − text={displayedUserMessage.visibleText} | |
| 351 | − terminalContexts={terminalContexts} | |
| 352 | − /> | |
| 353 | − )} | |
| 354 | − <div className="mt-1.5 flex items-center justify-end gap-2"> | |
| 355 | − <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | |
| 356 | − {displayedUserMessage.copyText && ( | |
| 357 | − <MessageCopyButton text={displayedUserMessage.copyText} /> | |
| 358 | − )} | |
| 359 | − {canRevertAgentWork && ( | |
| 360 | − <Button | |
| 361 | − type="button" | |
| 362 | − size="xs" | |
| 363 | − variant="outline" | |
| 364 | − disabled={ctx.isRevertingCheckpoint || ctx.isWorking} | |
| 365 | − onClick={() => ctx.onRevertUserMessage(row.message.id)} | |
| 366 | − title="Revert to this message" | |
| 367 | − > | |
| 368 | − <Undo2Icon className="size-3" /> | |
| 369 | − </Button> | |
| 370 | − )} | |
| 371 | − </div> | |
| 372 | − <p className="text-right text-xs text-muted-foreground/50"> | |
| 373 | − {formatTimestamp(row.message.createdAt, ctx.timestampFormat)} | |
| 374 | − </p> | |
| 375 | − </div> | |
| 376 | 352 | </div> |
| 377 | − </div> | |
| 378 | − ); | |
| 379 | − })()} | |
| 380 | − | |
| 381 | − {row.kind === "message" && | |
| 382 | − row.message.role === "assistant" && | |
| 383 | − (() => { | |
| 384 | − const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); | |
| 385 | − const assistantTurnStillInProgress = | |
| 386 | − ctx.activeTurnInProgress && | |
| 387 | − ctx.activeTurnId !== null && | |
| 388 | − ctx.activeTurnId !== undefined && | |
| 389 | − row.message.turnId === ctx.activeTurnId; | |
| 390 | − const assistantCopyState = resolveAssistantMessageCopyState({ | |
| 391 | − text: row.message.text ?? null, | |
| 392 | − showCopyButton: row.showAssistantCopyButton, | |
| 393 | − streaming: row.message.streaming || assistantTurnStillInProgress, | |
| 394 | − }); | |
| 395 | − return ( | |
| 396 | − <> | |
| 397 | − {row.showCompletionDivider && ( | |
| 398 | − <div className="my-3 flex items-center gap-3"> | |
| 399 | − <span className="h-px flex-1 bg-border" /> | |
| 400 | − <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 401 | − {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"} | |
| 402 | − </span> | |
| 403 | − <span className="h-px flex-1 bg-border" /> | |
| 404 | − </div> | |
| 405 | − )} | |
| 406 | − <div className="min-w-0 px-1 py-0.5"> | |
| 407 | − <ChatMarkdown | |
| 408 | − text={messageText} | |
| 409 | − cwd={ctx.markdownCwd} | |
| 410 | − isStreaming={Boolean(row.message.streaming)} | |
| 411 | − /> | |
| 412 | − <AssistantChangedFilesSection | |
| 413 | − turnSummary={row.assistantTurnDiffSummary} | |
| 414 | − routeThreadKey={ctx.routeThreadKey} | |
| 415 | − resolvedTheme={ctx.resolvedTheme} | |
| 416 | − onOpenTurnDiff={ctx.onOpenTurnDiff} | |
| 417 | − /> | |
| 418 | − <div className="mt-1.5 flex items-center gap-2"> | |
| 419 | − <p className="text-[10px] text-muted-foreground/30"> | |
| 420 | − {row.message.streaming ? ( | |
| 421 | − <LiveMessageMeta | |
| 422 | − createdAt={row.message.createdAt} | |
| 423 | − durationStart={row.durationStart} | |
| 424 | − timestampFormat={ctx.timestampFormat} | |
| 425 | − /> | |
| 426 | − ) : ( | |
| 427 | − formatMessageMeta( | |
| 428 | − row.message.createdAt, | |
| 429 | − formatElapsed(row.durationStart, row.message.completedAt), | |
| 430 | − ctx.timestampFormat, | |
| 431 | − ) | |
| 432 | − )} | |
| 433 | − </p> | |
| 434 | − {assistantCopyState.visible ? ( | |
| 435 | − <div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/assistant:opacity-100"> | |
| 436 | − <MessageCopyButton | |
| 437 | − text={assistantCopyState.text ?? ""} | |
| 438 | − size="icon-xs" | |
| 439 | − variant="outline" | |
| 440 | − className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70" | |
| 441 | − /> | |
| 442 | − </div> | |
| 443 | − ) : null} | |
| 444 | − </div> | |
| 445 | − </div> | |
| 446 | − </> | |
| 447 | − ); | |
| 448 | − })()} | |
| 449 | − | |
| 450 | − {row.kind === "proposed-plan" && ( | |
| 451 | − <div className="min-w-0 px-1 py-0.5"> | |
| 452 | − <ProposedPlanCard | |
| 453 | − planMarkdown={row.proposedPlan.planMarkdown} | |
| 454 | − environmentId={ctx.activeThreadEnvironmentId} | |
| 455 | − cwd={ctx.markdownCwd} | |
| 456 | − workspaceRoot={ctx.workspaceRoot} | |
| 353 | + ))} | |
| 354 | + </div> | |
| 355 | + )} | |
| 356 | + {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( | |
| 357 | + <UserMessageBody | |
| 358 | + text={displayedUserMessage.visibleText} | |
| 359 | + terminalContexts={terminalContexts} | |
| 457 | 360 | /> |
| 361 | + )} | |
| 362 | + <div className="mt-1.5 flex items-center justify-end gap-2"> | |
| 363 | + <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | |
| 364 | + {displayedUserMessage.copyText && ( | |
| 365 | + <MessageCopyButton text={displayedUserMessage.copyText} /> | |
| 366 | + )} | |
| 367 | + {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />} | |
| 368 | + </div> | |
| 369 | + <p className="text-right text-xs text-muted-foreground/50"> | |
| 370 | + {formatTimestamp(row.message.createdAt, ctx.timestampFormat)} | |
| 371 | + </p> | |
| 458 | 372 | </div> |
| 459 | − )} | |
| 373 | + </div> | |
| 374 | + </div> | |
| 375 | + ); | |
| 376 | +} | |
| 460 | 377 | |
| 461 | − {row.kind === "working" && ( | |
| 462 | − <div className="py-0.5 pl-1.5"> | |
| 463 | − <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70"> | |
| 464 | − <span className="inline-flex items-center gap-[3px]"> | |
| 465 | − <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 466 | − <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 467 | − <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 468 | − </span> | |
| 469 | − <span> | |
| 470 | − {row.createdAt ? ( | |
| 471 | − <> | |
| 472 | − Working for <WorkingTimer createdAt={row.createdAt} /> | |
| 473 | − </> | |
| 474 | − ) : ( | |
| 475 | − "Working..." | |
| 476 | − )} | |
| 477 | − </span> | |
| 478 | − </div> | |
| 378 | +function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { | |
| 379 | + const ctx = use(TimelineRowCtx); | |
| 380 | + const activity = use(TimelineRowActivityCtx); | |
| 381 | + | |
| 382 | + return ( | |
| 383 | + <Button | |
| 384 | + type="button" | |
| 385 | + size="xs" | |
| 386 | + variant="outline" | |
| 387 | + disabled={activity.isRevertingCheckpoint || activity.isWorking} | |
| 388 | + onClick={() => ctx.onRevertUserMessage(messageId)} | |
| 389 | + title="Revert to this message" | |
| 390 | + > | |
| 391 | + <Undo2Icon className="size-3" /> | |
| 392 | + </Button> | |
| 393 | + ); | |
| 394 | +} | |
| 395 | + | |
| 396 | +function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | |
| 397 | + const ctx = use(TimelineRowCtx); | |
| 398 | + const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); | |
| 399 | + | |
| 400 | + return ( | |
| 401 | + <> | |
| 402 | + {row.showCompletionDivider && <AssistantCompletionDivider />} | |
| 403 | + <div className="min-w-0 px-1 py-0.5"> | |
| 404 | + <ChatMarkdown | |
| 405 | + text={messageText} | |
| 406 | + cwd={ctx.markdownCwd} | |
| 407 | + isStreaming={Boolean(row.message.streaming)} | |
| 408 | + /> | |
| 409 | + <AssistantChangedFilesSection | |
| 410 | + turnSummary={row.assistantTurnDiffSummary} | |
| 411 | + routeThreadKey={ctx.routeThreadKey} | |
| 412 | + resolvedTheme={ctx.resolvedTheme} | |
| 413 | + onOpenTurnDiff={ctx.onOpenTurnDiff} | |
| 414 | + /> | |
| 415 | + <div className="mt-1.5 flex items-center gap-2"> | |
| 416 | + <p className="text-[10px] text-muted-foreground/30"> | |
| 417 | + {row.message.streaming ? ( | |
| 418 | + <LiveMessageMeta | |
| 419 | + createdAt={row.message.createdAt} | |
| 420 | + durationStart={row.durationStart} | |
| 421 | + timestampFormat={ctx.timestampFormat} | |
| 422 | + /> | |
| 423 | + ) : ( | |
| 424 | + formatMessageMeta( | |
| 425 | + row.message.createdAt, | |
| 426 | + formatElapsed(row.durationStart, row.message.completedAt), | |
| 427 | + ctx.timestampFormat, | |
| 428 | + ) | |
| 429 | + )} | |
| 430 | + </p> | |
| 431 | + <AssistantCopyButton row={row} /> | |
| 479 | 432 | </div> |
| 480 | − )} | |
| 433 | + </div> | |
| 434 | + </> | |
| 435 | + ); | |
| 436 | +} | |
| 437 | + | |
| 438 | +function AssistantCompletionDivider() { | |
| 439 | + const activity = use(TimelineRowActivityCtx); | |
| 440 | + | |
| 441 | + return ( | |
| 442 | + <div className="my-3 flex items-center gap-3"> | |
| 443 | + <span className="h-px flex-1 bg-border" /> | |
| 444 | + <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 445 | + {activity.completionSummary ? `Response • ${activity.completionSummary}` : "Response"} | |
| 446 | + </span> | |
| 447 | + <span className="h-px flex-1 bg-border" /> | |
| 448 | + </div> | |
| 449 | + ); | |
| 450 | +} | |
| 451 | + | |
| 452 | +function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { | |
| 453 | + const activity = use(TimelineRowActivityCtx); | |
| 454 | + const assistantTurnStillInProgress = | |
| 455 | + activity.activeTurnInProgress && | |
| 456 | + activity.activeTurnId !== null && | |
| 457 | + row.message.turnId === activity.activeTurnId; | |
| 458 | + const assistantCopyState = resolveAssistantMessageCopyState({ | |
| 459 | + text: row.message.text ?? null, | |
| 460 | + showCopyButton: row.showAssistantCopyButton, | |
| 461 | + streaming: row.message.streaming || assistantTurnStillInProgress, | |
| 462 | + }); | |
| 463 | + | |
| 464 | + if (!assistantCopyState.visible) { | |
| 465 | + return null; | |
| 466 | + } | |
| 467 | + | |
| 468 | + return ( | |
| 469 | + <div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/assistant:opacity-100"> | |
| 470 | + <MessageCopyButton | |
| 471 | + text={assistantCopyState.text ?? ""} | |
| 472 | + size="icon-xs" | |
| 473 | + variant="outline" | |
| 474 | + className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70" | |
| 475 | + /> | |
| 476 | + </div> | |
| 477 | + ); | |
| 478 | +} | |
| 479 | + | |
| 480 | +function ProposedPlanTimelineRow({ | |
| 481 | + row, | |
| 482 | +}: { | |
| 483 | + row: Extract<TimelineRow, { kind: "proposed-plan" }>; | |
| 484 | +}) { | |
| 485 | + const ctx = use(TimelineRowCtx); | |
| 486 | + | |
| 487 | + return ( | |
| 488 | + <div className="min-w-0 px-1 py-0.5"> | |
| 489 | + <ProposedPlanCard | |
| 490 | + planMarkdown={row.proposedPlan.planMarkdown} | |
| 491 | + environmentId={ctx.activeThreadEnvironmentId} | |
| 492 | + cwd={ctx.markdownCwd} | |
| 493 | + workspaceRoot={ctx.workspaceRoot} | |
| 494 | + /> | |
| 495 | + </div> | |
| 496 | + ); | |
| 497 | +} | |
| 498 | + | |
| 499 | +function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) { | |
| 500 | + return ( | |
| 501 | + <div className="py-0.5 pl-1.5"> | |
| 502 | + <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70"> | |
| 503 | + <span className="inline-flex items-center gap-[3px]"> | |
| 504 | + <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 505 | + <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 506 | + <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 507 | + </span> | |
| 508 | + <span> | |
| 509 | + {row.createdAt ? ( | |
| 510 | + <> | |
| 511 | + Working for <WorkingTimer createdAt={row.createdAt} /> | |
| 512 | + </> | |
| 513 | + ) : ( | |
| 514 | + "Working..." | |
| 515 | + )} | |
| 516 | + </span> | |
| 517 | + </div> | |
| 481 | 518 | </div> |
| 482 | 519 | ); |
| 483 | 520 | } |