mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage agents at work. > - Task threads show a time below each message. > - Own messages align with the right edge. > - The copy control added in #19442 sits to the right of the time. > - This shifts the time inward even when the control is hidden. > - This pull request moves the control to the left of the time. > - The time stays at the outside edge. ## Linked Issues or Issue Description Refs #19442. **What happened?** The copy control occupies space to the right of the time on own messages. **Expected behavior** The copy control sits to the left of the time. The time stays at the right edge. **Steps to reproduce** Open a task with your own sent message. Move the pointer over its footer. ## What Changed - Move TaskChatBubbleActions before the time in the own-message footer. - Keep the layout change inside TaskChatBubble. No shared contract change is needed. ## Verification - All 36 TaskChatBubble tests pass. - Design-token gates pass. - UI typecheck and production build pass. - Browser layout checks pass on desktop and touch: copy is left of the time, the time ends at the bubble edge, and the time does not move on hover. - Clipboard contents and copied feedback pass. - Screenshots cover idle, hover, copied feedback, and touch using the production component in the existing Storybook fixture. - Current-head CI passed: build, typecheck, selected general tests, all eight e2e shards, policy, security, and aggregate verification. - Greptile and Paperclip reviews score the current commit 5/5. No open findings or review threads remain. - The first Greptile review requested an order assertion. The existing copy test now checks that the control precedes the time and the time is the last footer element. All 36 component tests pass. The review thread is resolved. - Initial test and Storybook commands used the wrong working directory or argument form. Corrected commands run successfully. - Local typecheck and Storybook initially could not resolve mdast-util-from-markdown. A frozen dependency install restored the missing package. Typecheck, build, and browser checks then passed. An early browser retry reached the server before it was ready; the ready-server run passed. ## Risks - Low risk. This change only moves the existing control before the time. ## Model Used - OpenAI GPT-6 Codex. Exact deployed model ID and context window are not exposed in this runtime. The agent used reasoning, tools, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with Fixes / Closes / Refs OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id or instance details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable (existing copy tests cover unchanged behavior; browser checks cover position) - [x] I have updated relevant documentation to reflect my changes (the behavior is described here) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
505 lines
22 KiB
TypeScript
505 lines
22 KiB
TypeScript
import { CoordinatorRelativeTime } from "@/components/coordinator/CoordinatorRelativeTime";
|
|
import { useAgentChatRead } from "@/hooks/useAgentChatRead";
|
|
import { Link } from "@/lib/router";
|
|
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
|
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
|
|
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
|
|
import { ArtifactRow, ArtifactRowGroup, inlineRowStyle, subjectFromAttachmentRef } from "@/components/artifacts/v1";
|
|
import { isTextAttachment } from "@/lib/issue-attachments";
|
|
import { ArtifactPreview } from "@/components/artifacts/ArtifactCard";
|
|
import { isVideoLikeOutput } from "@/lib/issue-output";
|
|
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
|
|
import { Identity } from "../Identity";
|
|
import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author";
|
|
import { useCallback, useContext, useState, type ReactNode } from "react";
|
|
import { useEmailComment } from "@/components/EmailMessageCard";
|
|
import type { IssueAttachment } from "@paperclipai/shared";
|
|
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
|
import { cn } from "@/lib/utils";
|
|
import { agentDetailHref } from "@/pages/agent-detail-navigation";
|
|
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
|
import { MarkdownBody } from "@/components/MarkdownBody";
|
|
import {
|
|
ImageGalleryModal,
|
|
type GalleryMediaItem,
|
|
} from "@/components/ImageGalleryModal";
|
|
import {
|
|
Attachment,
|
|
AttachmentContent,
|
|
AttachmentDescription,
|
|
AttachmentGroup,
|
|
AttachmentMedia,
|
|
AttachmentTitle,
|
|
AttachmentTrigger,
|
|
} from "@/components/ui/attachment";
|
|
import {
|
|
extractAttachmentRefs,
|
|
extractImageRefs,
|
|
fileKindForAttachment,
|
|
formatFileSize,
|
|
hydrateAttachmentRefs,
|
|
isImageAttachment,
|
|
stripStandaloneImageEmbeds,
|
|
type AttachmentRef,
|
|
} from "./task-chat-attachments";
|
|
import { TaskChatBubbleActions } from "./TaskChatBubbleActions";
|
|
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
|
|
import type { TaskChatMessageItem } from "./task-chat-model";
|
|
|
|
interface TaskChatBubbleProps {
|
|
item: TaskChatMessageItem;
|
|
/** Requeues a blocked task after a no-live-execution-path recovery notice. */
|
|
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
|
|
tryAgainNoLiveExecutionPathPending?: boolean;
|
|
/** Disable the entrance animation when replacing an already-visible live response. */
|
|
animateEntry?: boolean;
|
|
/** Action shown beside the queued state for an interruptible message. */
|
|
queuedAction?: ReactNode;
|
|
/**
|
|
* The settled run turn rendered on this bubble's footer line (round 9):
|
|
* replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools"
|
|
* (the timestamp leads the summary), expandable to the nested tool history.
|
|
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
|
|
*/
|
|
attachedTurn?: ReactNode;
|
|
/** New-runner Worked header, rendered above the durable final response. */
|
|
beforeTurn?: ReactNode;
|
|
/** The durable runner turn already owns the shared agent identity row. */
|
|
hideAgentIdentity?: boolean;
|
|
/**
|
|
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
|
|
* Rendered here only for a runless reply (leading the bare timestamp); when
|
|
* an attached turn is present it owns these via its `leading` slot instead,
|
|
* so this bubble skips them. Human/system bubbles pass nothing.
|
|
*/
|
|
actions?: ReactNode;
|
|
attachments?: IssueAttachment[];
|
|
}
|
|
|
|
function initialsForName(name: string) {
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) {
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
return name.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
export function TaskChatAgentIdentity({
|
|
agentName,
|
|
agentIcon,
|
|
agent,
|
|
}: {
|
|
agentName: string;
|
|
agentIcon?: string | null;
|
|
agent?: AvatarAgent;
|
|
}) {
|
|
const identity = (
|
|
<>
|
|
<span data-testid="task-chat-agent-avatar"><AgentAvatar agent={agent} name={agentName} size={24} /></span>
|
|
<span className="text-sm font-semibold text-foreground">{agentName}</span>
|
|
</>
|
|
);
|
|
const className = "flex items-center gap-2 px-1";
|
|
return agent?.id ? (
|
|
<Link
|
|
to={agentDetailHref(agent.id)}
|
|
className={cn(className, "no-underline hover:no-underline")}
|
|
data-testid="task-chat-agent-identity"
|
|
>
|
|
{identity}
|
|
</Link>
|
|
) : (
|
|
<span className={className} data-testid="task-chat-agent-identity">
|
|
{identity}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Viewer-relative message row — the primary legibility signal. Own messages sit
|
|
* right in a solid accent bubble; agents and other humans sit on the left
|
|
* directly on the page surface with their own avatar and name;
|
|
* system notices are centered and recede.
|
|
*/
|
|
function galleryItemForMedia(
|
|
src: string,
|
|
name?: string,
|
|
attachment?: ReturnType<typeof hydrateAttachmentRefs>[number],
|
|
): GalleryMediaItem {
|
|
return {
|
|
id: attachment?.id ?? src,
|
|
contentPath: attachment?.openPath ?? src,
|
|
openPath: attachment?.openPath,
|
|
downloadPath: attachment?.downloadPath,
|
|
contentType: attachment?.contentType ?? "",
|
|
originalFilename: name?.trim() ? name : "image",
|
|
};
|
|
}
|
|
|
|
function uniqueAttachmentRefs(refs: AttachmentRef[]): AttachmentRef[] {
|
|
return refs.filter(
|
|
(ref, index) =>
|
|
refs.findIndex(
|
|
(candidate) =>
|
|
(ref.id && candidate.id === ref.id) || candidate.url === ref.url,
|
|
) === index,
|
|
);
|
|
}
|
|
|
|
export function TaskChatBubble(props: TaskChatBubbleProps) {
|
|
const email = useEmailComment(props.item.id);
|
|
return email ?? <TaskChatBubbleContent {...props} />;
|
|
}
|
|
function TaskChatBubbleContent({
|
|
item,
|
|
animateEntry = true,
|
|
queuedAction,
|
|
attachedTurn,
|
|
beforeTurn,
|
|
hideAgentIdentity = false,
|
|
actions,
|
|
attachments = [],
|
|
onTryAgainNoLiveExecutionPath,
|
|
tryAgainNoLiveExecutionPathPending,
|
|
}: TaskChatBubbleProps) {
|
|
const readRef = useAgentChatRead(item.id, item.createdAtIso, item.author === "agent" && !item.optimistic);
|
|
const streamlined = useStreamlinedTaskChatPresentation();
|
|
const coordinator = useCoordinatorPresentation();
|
|
const recordedTimestamp = item.createdAtIso ?? (typeof item.atMs === "number" && Number.isFinite(item.atMs) ? new Date(item.atMs) : null);
|
|
const timestamp = coordinator && recordedTimestamp && !item.optimistic
|
|
? <CoordinatorRelativeTime value={recordedTimestamp} /> : item.timestamp;
|
|
const { enabled: artifactsV1 } = useArtifactsV1Enabled();
|
|
const openText = useContext(TextAttachmentContext);
|
|
// Task attachments share the page gallery; standalone images retain the bubble viewer.
|
|
const openIssueGallery = useContext(IssueGalleryContext);
|
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
|
// Keep MarkdownBody's memo boundary intact when only the live tail changes.
|
|
// A fresh callback here reparses every historical response on every update.
|
|
const openImage = useCallback((src: string) => {
|
|
if (!openIssueGallery?.(src)) setLightboxSrc(src);
|
|
}, [openIssueGallery]);
|
|
if (item.interstitial) {
|
|
// Interstitial updates are ephemeral (PAP-361): while streaming the text
|
|
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
|
|
// once finished it renders nowhere — the run log / classic transcript
|
|
// remain the archive. Never rendered as a bubble.
|
|
return null;
|
|
}
|
|
|
|
if (item.author === "system") {
|
|
// Collapsed humanized one-liner, expandable to the full detail (PAP-443).
|
|
return (
|
|
<TaskChatSystemNotice
|
|
item={item}
|
|
onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath}
|
|
tryAgainNoLiveExecutionPathPending={tryAgainNoLiveExecutionPathPending}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const isHuman = item.author === "human";
|
|
const isOwnMessage = isHuman && item.isCurrentUser === true;
|
|
const sentFromIMessage = isHuman && item.sourceChannel === "imessage-photon";
|
|
// Non-image file references ("[name](/api/attachments/…/content)") render as
|
|
// attachment chips under the bubble; link-only lines leave the body text.
|
|
const { refs: linkedRefs, text: bodyWithoutAttachmentLinks } =
|
|
extractAttachmentRefs(item.text);
|
|
const embeddedImageRefs = extractImageRefs(bodyWithoutAttachmentLinks);
|
|
const bodyText = stripStandaloneImageEmbeds(bodyWithoutAttachmentLinks);
|
|
const hydratedLinkedRefs = hydrateAttachmentRefs(linkedRefs, attachments);
|
|
const hydratedEmbeddedRefs = hydrateAttachmentRefs(
|
|
embeddedImageRefs,
|
|
attachments,
|
|
);
|
|
const boundAttachmentRefs: AttachmentRef[] = attachments
|
|
.filter((attachment) => attachment.issueCommentId === item.id)
|
|
.map((attachment) => ({
|
|
id: attachment.id,
|
|
name: attachment.originalFilename?.trim() || "attachment",
|
|
url: attachment.contentPath,
|
|
contentType: attachment.contentType,
|
|
byteSize: attachment.byteSize,
|
|
openPath: attachment.openPath,
|
|
downloadPath: attachment.downloadPath,
|
|
}));
|
|
const imageRefs = uniqueAttachmentRefs([
|
|
...hydratedEmbeddedRefs,
|
|
...hydratedLinkedRefs.filter(isImageAttachment),
|
|
...boundAttachmentRefs.filter(isImageAttachment),
|
|
]);
|
|
const attachmentRefs = uniqueAttachmentRefs([
|
|
...hydratedLinkedRefs.filter((ref) => !isImageAttachment(ref)),
|
|
...boundAttachmentRefs.filter((ref) => !isImageAttachment(ref)),
|
|
]);
|
|
const mediaRefs = [...imageRefs, ...attachmentRefs.filter((ref) => isVideoLikeOutput(ref.contentType, ref.name))];
|
|
const galleryItems: GalleryMediaItem[] =
|
|
lightboxSrc !== null && !mediaRefs.some((ref) => ref.url === lightboxSrc)
|
|
? // A clicked image the extractor missed (e.g. inline HTML) still gets a
|
|
// single-item lightbox rather than nothing.
|
|
[galleryItemForMedia(lightboxSrc)]
|
|
: mediaRefs.map((ref) => galleryItemForMedia(ref.url, ref.name, ref));
|
|
const lightboxIndex =
|
|
lightboxSrc === null
|
|
? -1
|
|
: Math.max(0, mediaRefs.findIndex((ref) => ref.url === lightboxSrc));
|
|
return (
|
|
<div
|
|
data-conversation-message={isHuman ? "human" : "agent"}
|
|
ref={readRef}
|
|
className={cn(
|
|
"flex w-full flex-col gap-1",
|
|
animateEntry && "tc-enter-bubble",
|
|
isOwnMessage ? "items-end" : "items-start",
|
|
)}
|
|
>
|
|
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
|
|
{item.chatMessage && <Link to={`/issues/${item.chatMessage.sourceTaskIdentifier}`} className="px-1 text-xs text-muted-foreground underline underline-offset-4">
|
|
From {item.chatMessage.sourceTaskIdentifier}
|
|
</Link>}
|
|
{isHuman && !item.isCurrentUser && item.authorName ? (
|
|
<span className="px-1 text-sm font-semibold text-foreground" data-testid="task-chat-human-identity" data-author-user-id={item.authorUserId}>
|
|
<Identity name={item.authorName} avatarUrl={paperclipHumanAvatarUrl(item.authorAvatarUrl)} size="default" className="gap-2" />
|
|
</span>
|
|
) : null}
|
|
{!isHuman && item.authorName && !hideAgentIdentity ? (
|
|
<TaskChatAgentIdentity
|
|
agentName={item.authorName}
|
|
agentIcon={item.agentIcon}
|
|
agent={item.agent}
|
|
/>
|
|
) : null}
|
|
{bodyText.length > 0 ? (
|
|
<div
|
|
data-conversation-text={isHuman ? (isOwnMessage ? "human" : "progress") : item.channel === "progress" ? "progress" : "final"}
|
|
// Stable hook so the TaskChatLab bubble-treatment explorations
|
|
// (PAP-501) can scope background/border overrides to the agent
|
|
// bubble body without touching the live thread.
|
|
data-testid={
|
|
isHuman ? "task-chat-human-bubble" : "task-chat-agent-bubble"
|
|
}
|
|
className={cn(
|
|
"break-words py-2 text-sm",
|
|
isOwnMessage
|
|
? "max-w-(--pct-85) rounded-2xl rounded-br-sm bg-(--liveness-blue) px-3.5 text-white"
|
|
: "w-full bg-transparent px-1 text-foreground",
|
|
)}
|
|
>
|
|
<MarkdownBody
|
|
// The own-message bubble sits on the solid --liveness-blue accent, so the
|
|
// prose body text must follow the bubble's `text-white` rather than
|
|
// the default light-mode prose color (which reads as black on blue).
|
|
// `paperclip-markdown-on-accent` flips prose tokens to currentColor
|
|
// (== inherited white) in both themes; dark mode was already correct
|
|
// only because `prose-invert` happened to lighten the text.
|
|
className={isOwnMessage ? "paperclip-markdown-on-accent" : undefined}
|
|
softBreaks
|
|
linkIssueReferences
|
|
onImageClick={openImage}
|
|
>
|
|
{bodyText}
|
|
</MarkdownBody>
|
|
</div>
|
|
) : null}
|
|
{!artifactsV1 && imageRefs.length > 0 ? (
|
|
<div
|
|
className="flex max-w-(--pct-85) flex-col gap-2"
|
|
data-testid="task-chat-bubble-media"
|
|
>
|
|
<span className="text-xs text-muted-foreground">
|
|
Images · {imageRefs.length}
|
|
</span>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{imageRefs
|
|
.slice(0, imageRefs.length > 4 ? 3 : 4)
|
|
.map((ref, index) => (
|
|
<button
|
|
key={ref.url}
|
|
type="button"
|
|
className="group aspect-video min-w-0 overflow-hidden rounded-md bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={`Open ${ref.name || `image ${index + 1}`}`}
|
|
onClick={() => openImage(ref.url)}
|
|
>
|
|
<img
|
|
src={ref.openPath ?? ref.url}
|
|
alt={ref.name}
|
|
loading="lazy"
|
|
className="h-full w-full object-cover transition-transform group-hover:scale-(--s-1_02)"
|
|
/>
|
|
</button>
|
|
))}
|
|
{imageRefs.length > 4 ? (
|
|
<button
|
|
type="button"
|
|
className="aspect-video min-w-0 rounded-md bg-muted text-sm font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={`Open ${imageRefs.length - 3} more screenshots`}
|
|
onClick={() => openImage(imageRefs[3].url)}
|
|
>
|
|
+{imageRefs.length - 3}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{imageRefs.map((ref) => ref.name || "image").join(" · ")}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{!artifactsV1 && attachmentRefs.length > 0 ? (
|
|
<div className="flex max-w-(--pct-85) flex-col gap-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
Files · {attachmentRefs.length}
|
|
</span>
|
|
<AttachmentGroup data-testid="task-chat-bubble-attachments">
|
|
{attachmentRefs.map((ref) => {
|
|
const kind = fileKindForAttachment(ref);
|
|
const KindIcon = kind.icon;
|
|
const size = formatFileSize(ref.byteSize);
|
|
const video = isVideoLikeOutput(ref.contentType, ref.name);
|
|
return (
|
|
<Attachment key={ref.url} size="sm">
|
|
<AttachmentMedia className={video ? "aspect-video group-data-[size=sm]/attachment:w-20" : undefined}>
|
|
{video ? <ArtifactPreview artifact={{ title: ref.name, contentPath: ref.openPath ?? ref.url, mediaKind: "video" }} /> : <KindIcon aria-hidden />}
|
|
</AttachmentMedia>
|
|
<AttachmentContent>
|
|
<AttachmentTitle className="max-w-48">
|
|
{ref.name}
|
|
</AttachmentTitle>
|
|
<AttachmentDescription className="max-w-48">
|
|
{size ? `${kind.label} · ${size}` : kind.label}
|
|
</AttachmentDescription>
|
|
</AttachmentContent>
|
|
<AttachmentTrigger
|
|
aria-label={`Open ${ref.name}`}
|
|
onClick={video ? () => openImage(ref.url) : undefined}
|
|
render={video ? <button type="button" /> :
|
|
<a
|
|
href={ref.openPath ?? ref.url}
|
|
onClick={(event) => {
|
|
if (openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name }) && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) {
|
|
event.preventDefault(); openText(ref.id, ref.name);
|
|
}
|
|
}}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
/>
|
|
}
|
|
/>
|
|
</Attachment>
|
|
);
|
|
})}
|
|
</AttachmentGroup>
|
|
</div>
|
|
) : null}
|
|
{!isHuman && item.verificationCaveats?.length ? (
|
|
<div
|
|
className="mx-1 w-(--sz-calc-7) rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2 text-xs"
|
|
data-testid="task-chat-verification-caveats"
|
|
>
|
|
<p className="font-medium text-amber-800 dark:text-amber-200">
|
|
Verification caveat
|
|
</p>
|
|
<ul className="mt-1 space-y-1 text-muted-foreground">
|
|
{item.verificationCaveats.map((caveat, index) => (
|
|
<li key={`${caveat.commandOrCheck}:${index}`}>
|
|
<span className="font-mono text-foreground">
|
|
{caveat.commandOrCheck}
|
|
</span>
|
|
{caveat.reasonCode
|
|
? ` · ${caveat.reasonCode.replaceAll("_", " ")}`
|
|
: ""}
|
|
{caveat.detail ? (
|
|
<span className="block">{caveat.detail}</span>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
{coordinator && recordedTimestamp && attachedTurn ? <span className="px-1 text-(length:--text-micro) text-muted-foreground"><CoordinatorRelativeTime value={recordedTimestamp!} /></span> : null}
|
|
{item.optimistic ? (
|
|
<span className="flex items-center gap-1 px-1 text-(length:--text-micro) text-muted-foreground">
|
|
<span>{item.optimistic === "queued" ? "Queued" : "Sending…"}</span>
|
|
{item.optimistic === "queued" ? queuedAction : null}
|
|
</span>
|
|
) : attachedTurn ? (
|
|
// The settled turn takes over the footer line: timestamp + "✓ Worked"
|
|
// summary, always visible; expanding stretches beneath the bubble. The
|
|
// copy/👍/👎 actions (PAP-413) ride the turn's summary row via its
|
|
// `leading` slot — not this wrapper — so they stay anchored to the
|
|
// summary line when the tool history expands beneath it.
|
|
<div
|
|
className="self-stretch"
|
|
data-testid="task-chat-bubble-attached-turn"
|
|
>
|
|
{attachedTurn}
|
|
</div>
|
|
) : isOwnMessage ? (
|
|
<div className="group/message-toolbar flex items-center gap-1">
|
|
<div className="transition-opacity motion-reduce:transition-none sm:opacity-0 sm:group-hover/message-toolbar:opacity-100 sm:group-focus-within/message-toolbar:opacity-100 pointer-coarse:opacity-100">
|
|
<TaskChatBubbleActions copyText={item.text} />
|
|
</div>
|
|
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
|
{sentFromIMessage ? "Sent from iMessage" : null}
|
|
{sentFromIMessage && timestamp ? " · " : null}
|
|
{timestamp}
|
|
</span>
|
|
</div>
|
|
) : actions ? (
|
|
streamlined ? (
|
|
<div className="flex w-full items-center justify-between gap-2 px-1">
|
|
{timestamp ? (
|
|
<span className="text-(length:--text-micro) text-muted-foreground">
|
|
{timestamp}
|
|
</span>
|
|
) : null}
|
|
{actions}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1">
|
|
{actions}
|
|
{timestamp ? (
|
|
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
|
{timestamp}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)
|
|
) : timestamp || sentFromIMessage ? (
|
|
// Timestamps are always visible (round 9) — no longer hover-revealed.
|
|
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
|
{sentFromIMessage ? "Sent from iMessage" : null}
|
|
{sentFromIMessage && timestamp ? " · " : null}
|
|
{timestamp}
|
|
</span>
|
|
) : null}
|
|
{artifactsV1 && imageRefs.length + attachmentRefs.length > 0 ? (
|
|
// Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): the files a message produced hang under its
|
|
// footer, 12px below, as rows inline at equal widths, wrapping past three. Pictures and video open the
|
|
// gallery, text opens a text tab when the panel offers one, anything else opens in a new tab.
|
|
<div className="w-full pt-2" data-testid="task-chat-bubble-artifacts-v1">
|
|
<ArtifactRowGroup>
|
|
{[...imageRefs, ...attachmentRefs].map((ref) => {
|
|
const subject = subjectFromAttachmentRef(ref);
|
|
const picture = isImageAttachment(ref) || isVideoLikeOutput(ref.contentType, ref.name);
|
|
const textTab = Boolean(openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name }));
|
|
if (picture) return <ArtifactRow key={ref.url} subject={subject} onClick={() => openImage(ref.url)} style={inlineRowStyle} />;
|
|
// A text file keeps its link (modifier clicks, copying the address) while a plain click opens the text tab.
|
|
if (textTab) return <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external onClick={() => openText?.(ref.id!, ref.name)} style={inlineRowStyle} />;
|
|
return <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external style={inlineRowStyle} />;
|
|
})}
|
|
</ArtifactRowGroup>
|
|
</div>
|
|
) : null}
|
|
{lightboxSrc !== null && lightboxIndex >= 0 ? (
|
|
<ImageGalleryModal
|
|
items={galleryItems}
|
|
initialIndex={lightboxIndex}
|
|
open
|
|
onOpenChange={(open) => {
|
|
if (!open) setLightboxSrc(null);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|