| ... | @@ -70,11 +70,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | ... | @@ -70,11 +70,6 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 70 | // --------------------------------------------------------------------------- | 70 | // --------------------------------------------------------------------------- |
| 71 | | 71 | |
| 72 | interface TimelineRowSharedState { | 72 | interface TimelineRowSharedState { |
| 73 | activeTurnInProgress: boolean; | | |
| 74 | activeTurnId: TurnId | null | undefined; | | |
| 75 | isWorking: boolean; | | |
| 76 | isRevertingCheckpoint: boolean; | | |
| 77 | completionSummary: string | null; | | |
| 78 | timestampFormat: TimestampFormat; | 73 | timestampFormat: TimestampFormat; |
| 79 | routeThreadKey: string; | 74 | routeThreadKey: string; |
| 80 | markdownCwd: string | undefined; | 75 | markdownCwd: string | undefined; |
| ... | @@ -86,7 +81,16 @@ interface TimelineRowSharedState { | ... | @@ -86,7 +81,16 @@ interface TimelineRowSharedState { |
| 86 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 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 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); | 92 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| | 93 | const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); |
| 90 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; | 94 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 91 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; | 95 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; |
| 92 | | 96 | |
| ... | @@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -191,15 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 191 | }; | 195 | }; |
| 192 | }, [listRef, onIsAtEndChange, rows.length]); | 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 | const sharedState = useMemo<TimelineRowSharedState>( | 198 | const sharedState = useMemo<TimelineRowSharedState>( |
| 197 | () => ({ | 199 | () => ({ |
| 198 | activeTurnInProgress, | | |
| 199 | activeTurnId: activeTurnId ?? null, | | |
| 200 | isWorking, | | |
| 201 | isRevertingCheckpoint, | | |
| 202 | completionSummary, | | |
| 203 | timestampFormat, | 200 | timestampFormat, |
| 204 | routeThreadKey, | 201 | routeThreadKey, |
| 205 | markdownCwd, | 202 | markdownCwd, |
| ... | @@ -211,11 +208,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -211,11 +208,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 211 | onOpenTurnDiff, | 208 | onOpenTurnDiff, |
| 212 | }), | 209 | }), |
| 213 | [ | 210 | [ |
| 214 | activeTurnInProgress, | | |
| 215 | activeTurnId, | | |
| 216 | isWorking, | | |
| 217 | isRevertingCheckpoint, | | |
| 218 | completionSummary, | | |
| 219 | timestampFormat, | 211 | timestampFormat, |
| 220 | routeThreadKey, | 212 | routeThreadKey, |
| 221 | markdownCwd, | 213 | markdownCwd, |
| ... | @@ -227,6 +219,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -227,6 +219,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 227 | onOpenTurnDiff, | 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 | // Stable renderItem — no closure deps. Row components read shared state | 233 | // Stable renderItem — no closure deps. Row components read shared state |
| 232 | // from TimelineRowCtx, which propagates through LegendList's memo. | 234 | // from TimelineRowCtx, which propagates through LegendList's memo. |
| ... | @@ -251,21 +253,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -251,21 +253,23 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 251 | | 253 | |
| 252 | return ( | 254 | return ( |
| 253 | <TimelineRowCtx.Provider value={sharedState}> | 255 | <TimelineRowCtx.Provider value={sharedState}> |
| 254 | <LegendList<MessagesTimelineRow> | 256 | <TimelineRowActivityCtx.Provider value={activityState}> |
| 255 | ref={listRef} | 257 | <LegendList<MessagesTimelineRow> |
| 256 | data={rows} | 258 | ref={listRef} |
| 257 | keyExtractor={keyExtractor} | 259 | data={rows} |
| 258 | renderItem={renderItem} | 260 | keyExtractor={keyExtractor} |
| 259 | estimatedItemSize={90} | 261 | renderItem={renderItem} |
| 260 | initialScrollAtEnd | 262 | estimatedItemSize={90} |
| 261 | maintainScrollAtEnd | 263 | initialScrollAtEnd |
| 262 | maintainScrollAtEndThreshold={0.1} | 264 | maintainScrollAtEnd |
| 263 | maintainVisibleContentPosition | 265 | maintainScrollAtEndThreshold={0.1} |
| 264 | onScroll={handleScroll} | 266 | maintainVisibleContentPosition |
| 265 | className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" | 267 | onScroll={handleScroll} |
| 266 | ListHeaderComponent={TIMELINE_LIST_HEADER} | 268 | className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" |
| 267 | ListFooterComponent={TIMELINE_LIST_FOOTER} | 269 | ListHeaderComponent={TIMELINE_LIST_HEADER} |
| 268 | /> | 270 | ListFooterComponent={TIMELINE_LIST_FOOTER} |
| | 271 | /> |
| | 272 | </TimelineRowActivityCtx.Provider> |
| 269 | </TimelineRowCtx.Provider> | 273 | </TimelineRowCtx.Provider> |
| 270 | ); | 274 | ); |
| 271 | }); | 275 | }); |
| ... | @@ -283,9 +287,7 @@ type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | ... | @@ -283,9 +287,7 @@ type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; |
| 283 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; | 287 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; |
| 284 | type TimelineRow = MessagesTimelineRow; | 288 | type TimelineRow = MessagesTimelineRow; |
| 285 | | 289 | |
| 286 | function TimelineRowContent({ row }: { row: TimelineRow }) { | 290 | const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { |
| 287 | const ctx = use(TimelineRowCtx); | | |
| 288 | | | |
| 289 | return ( | 291 | return ( |
| 290 | <div | 292 | <div |
| 291 | className={cn( | 293 | className={cn( |
| ... | @@ -297,187 +299,222 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { | ... | @@ -297,187 +299,222 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { |
| 297 | data-message-id={row.kind === "message" ? row.message.id : undefined} | 299 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| 298 | data-message-role={row.kind === "message" ? row.message.role : undefined} | 300 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| 299 | > | 301 | > |
| 300 | {row.kind === "work" && <WorkGroupSection groupedEntries={row.groupedEntries} />} | 302 | {row.kind === "work" ? <WorkGroupSection groupedEntries={row.groupedEntries} /> : null} |
| 301 | | 303 | {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null} |
| 302 | {row.kind === "message" && | 304 | {row.kind === "message" && row.message.role === "assistant" ? ( |
| 303 | row.message.role === "user" && | 305 | <AssistantTimelineRow row={row} /> |
| 304 | (() => { | 306 | ) : null} |
| 305 | const userImages = row.message.attachments ?? []; | 307 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 306 | const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); | 308 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 307 | const terminalContexts = displayedUserMessage.contexts; | 309 | </div> |
| 308 | const canRevertAgentWork = typeof row.revertTurnCount === "number"; | 310 | ); |
| 309 | return ( | 311 | }); |
| 310 | <div className="flex justify-end"> | 312 | |
| 311 | <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | 313 | function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 312 | {userImages.length > 0 && ( | 314 | const ctx = use(TimelineRowCtx); |
| 313 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | 315 | const userImages = row.message.attachments ?? []; |
| 314 | {userImages.map( | 316 | const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); |
| 315 | (image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | 317 | const terminalContexts = displayedUserMessage.contexts; |
| 316 | <div | 318 | const canRevertAgentWork = typeof row.revertTurnCount === "number"; |
| 317 | key={image.id} | 319 | |
| 318 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | 320 | return ( |
| 319 | > | 321 | <div className="flex justify-end"> |
| 320 | {image.previewUrl ? ( | 322 | <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> |
| 321 | <button | 323 | {userImages.length > 0 && ( |
| 322 | type="button" | 324 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 323 | className="h-full w-full cursor-zoom-in" | 325 | {userImages.map((image: NonNullable<TimelineMessage["attachments"]>[number]) => ( |
| 324 | aria-label={`Preview ${image.name}`} | 326 | <div |
| 325 | onClick={() => { | 327 | key={image.id} |
| 326 | const preview = buildExpandedImagePreview(userImages, image.id); | 328 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" |
| 327 | if (!preview) return; | 329 | > |
| 328 | ctx.onImageExpand(preview); | 330 | {image.previewUrl ? ( |
| 329 | }} | 331 | <button |
| 330 | > | 332 | type="button" |
| 331 | <img | 333 | className="h-full w-full cursor-zoom-in" |
| 332 | src={image.previewUrl} | 334 | aria-label={`Preview ${image.name}`} |
| 333 | alt={image.name} | 335 | onClick={() => { |
| 334 | className="block h-auto max-h-[220px] w-full object-cover" | 336 | const preview = buildExpandedImagePreview(userImages, image.id); |
| 335 | /> | 337 | if (!preview) return; |
| 336 | </button> | 338 | ctx.onImageExpand(preview); |
| 337 | ) : ( | 339 | }} |
| 338 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | 340 | > |
| 339 | {image.name} | 341 | <img |
| 340 | </div> | 342 | src={image.previewUrl} |
| 341 | )} | 343 | alt={image.name} |
| 342 | </div> | 344 | className="block h-auto max-h-[220px] w-full object-cover" |
| 343 | ), | 345 | /> |
| 344 | )} | 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 | </div> | 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 | </div> | 352 | </div> |
| 377 | </div> | 353 | ))} |
| 378 | ); | 354 | </div> |
| 379 | })()} | 355 | )} |
| 380 | | 356 | {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( |
| 381 | {row.kind === "message" && | 357 | <UserMessageBody |
| 382 | row.message.role === "assistant" && | 358 | text={displayedUserMessage.visibleText} |
| 383 | (() => { | 359 | terminalContexts={terminalContexts} |
| 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} | | |
| 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 | </div> | 372 | </div> |
| 459 | )} | 373 | </div> |
| | 374 | </div> |
| | 375 | ); |
| | 376 | } |
| 460 | | 377 | |
| 461 | {row.kind === "working" && ( | 378 | function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { |
| 462 | <div className="py-0.5 pl-1.5"> | 379 | const ctx = use(TimelineRowCtx); |
| 463 | <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70"> | 380 | const activity = use(TimelineRowActivityCtx); |
| 464 | <span className="inline-flex items-center gap-[3px]"> | 381 | |
| 465 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | 382 | return ( |
| 466 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | 383 | <Button |
| 467 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | 384 | type="button" |
| 468 | </span> | 385 | size="xs" |
| 469 | <span> | 386 | variant="outline" |
| 470 | {row.createdAt ? ( | 387 | disabled={activity.isRevertingCheckpoint || activity.isWorking} |
| 471 | <> | 388 | onClick={() => ctx.onRevertUserMessage(messageId)} |
| 472 | Working for <WorkingTimer createdAt={row.createdAt} /> | 389 | title="Revert to this message" |
| 473 | </> | 390 | > |
| 474 | ) : ( | 391 | <Undo2Icon className="size-3" /> |
| 475 | "Working..." | 392 | </Button> |
| 476 | )} | 393 | ); |
| 477 | </span> | 394 | } |
| 478 | </div> | 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 | </div> | 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 | </div> | 518 | </div> |
| 482 | ); | 519 | ); |
| 483 | } | 520 | } |