Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions site/src/pages/AgentsPage/AgentChatPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
content: renderTabContent(tab.id),
}));

const isEditing =
editing.editingMessageId !== null ||
editing.editingQueuedMessageID !== null;

const titleElement = (
<title>
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
Expand Down Expand Up @@ -502,6 +506,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
initialEditorState={editing.initialEditorState}
remountKey={editing.remountKey}
onContentChange={editing.handleContentChange}
isEditing={isEditing}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
Expand Down
18 changes: 11 additions & 7 deletions site/src/pages/AgentsPage/components/AgentChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { useOverflowCount } from "../hooks/useOverflowCount";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
import { formatProviderLabel } from "../utils/modelOptions";
import type { UploadState } from "./AttachmentPreview";
import { AttachmentPreview } from "./AttachmentPreview";
import {
AttachmentPreview,
isUploadInProgress,
type UploadState,
} from "./AttachmentPreview";
import { ModelSelector, type ModelSelectorOption } from "./ChatElements";
import {
ChatMessageInput,
Expand All @@ -71,6 +74,7 @@ import { WorkspacePill } from "./WorkspacePill";

export {
ImageThumbnail,
isUploadInProgress,
type UploadState,
} from "./AttachmentPreview";
export type { ChatMessageInputRef } from "./ChatMessageInput/ChatMessageInput";
Expand Down Expand Up @@ -578,8 +582,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
internalRef.current?.focus();
}
}, [isLoading]);
const isUploading = attachments.some(
(f) => uploadStates?.get(f)?.status === "uploading",
const hasActiveUploads = attachments.some((file) =>
isUploadInProgress(uploadStates?.get(file)),
);
const hasUploadedAttachments = attachments.some(
(f) => uploadStates?.get(f)?.status === "uploaded",
Expand All @@ -594,7 +598,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
!isLoading &&
hasModelOptions &&
hasSendableContent &&
!isUploading;
!hasActiveUploads;
const handleSubmit = () => {
const text = internalRef.current?.getValue()?.trim() ?? "";

Expand All @@ -606,7 +610,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
!hasFileReferences &&
!isDisabled &&
!isLoading &&
!isUploading &&
!hasActiveUploads &&
queuedMessages.length > 0 &&
onPromoteQueuedMessage
) {
Expand All @@ -618,7 +622,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
(!text && !hasUploadedAttachments && !hasFileReferences) ||
isDisabled ||
isLoading ||
isUploading ||
hasActiveUploads ||
!hasModelOptions
) {
return;
Expand Down
35 changes: 35 additions & 0 deletions site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,41 @@ export const Uploading: Story = {
},
};

export const PendingUpload: Story = {
args: (() => {
const file = createMockFile("pending.png", "image/png");
return {
attachments: [file],
uploadStates: new Map<File, UploadState>([[file, { status: "pending" }]]),
previewUrls: new Map<File, string>([[file, TINY_PNG]]),
};
})(),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByTitle("Loading spinner")).toBeInTheDocument();
},
};

export const DraftWarning: Story = {
args: (() => {
const file = createMockFile("large-draft.txt", "text/plain");
const warning =
"This file is attached for now, but it could not be saved as a draft.";
return {
attachments: [file],
uploadStates: new Map<File, UploadState>([
[file, { status: "uploading", draftWarning: warning }],
]),
};
})(),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText(/could not be saved as a draft/i),
).toBeInTheDocument();
},
};

export const UploadError: Story = {
args: (() => {
const file = createMockFile("broken.png", "image/png");
Expand Down
224 changes: 125 additions & 99 deletions site/src/pages/AgentsPage/components/AttachmentPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ import {
} from "../utils/fetchTextAttachment";

export type UploadState = {
status: "uploading" | "uploaded" | "error";
status: "pending" | "uploading" | "uploaded" | "error";
fileId?: string;
error?: string;
draftWarning?: string;
};

export const isUploadInProgress = (state: UploadState | undefined): boolean =>
state?.status === "pending" || state?.status === "uploading";

/** Renders an image thumbnail from a pre-created preview URL. */
export const ImageThumbnail: FC<{
previewUrl: string;
Expand Down Expand Up @@ -106,109 +110,131 @@ export const AttachmentPreview: FC<{
}
};

const draftWarnings = Array.from(
new Set(
attachments.flatMap((file) => {
const warning = uploadStates?.get(file)?.draftWarning;
return warning ? [warning] : [];
}),
),
);

return (
<div className="flex gap-2 overflow-x-auto border-b border-border-default/50 px-3 py-2">
{attachments.map((file, index) => {
const uploadState = uploadStates?.get(file);
const previewUrl = previewUrls?.get(file) ?? "";
const textContent = textContents?.get(file);
const textFileId =
uploadState?.status === "uploaded" ? uploadState.fileId : undefined;
const hasTextAttachment =
file.type === "text/plain" &&
(textContent !== undefined || textFileId !== undefined);
return (
<div
// Key combines file metadata with index as a fallback for
// duplicate names. Acceptable for a small, append-only list.
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
className="group relative"
>
{file.type.startsWith("image/") && previewUrl ? (
<button
type="button"
className="border-0 bg-transparent p-0 cursor-pointer transition-opacity hover:opacity-80"
onClick={() => onPreview?.(previewUrl)}
>
<ImageThumbnail previewUrl={previewUrl} name={file.name} />
</button>
) : hasTextAttachment ? (
<button
type="button"
aria-label={`View ${file.name}`}
className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
if (nextContent !== undefined) {
onTextPreview?.(nextContent, file.name);
}
}}
>
<span className="line-clamp-3 w-full font-mono text-2xs text-content-secondary">
{formatTextAttachmentPreview(textContent ?? "")}
</span>
</button>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-md border border-border-default bg-surface-secondary text-xs text-content-secondary">
{file.name.split(".").pop()?.toUpperCase() || "FILE"}
</div>
)}
{hasTextAttachment && (
<div className="border-b border-border-default/50">
<div className="flex gap-2 overflow-x-auto px-3 py-2">
{attachments.map((file, index) => {
const uploadState = uploadStates?.get(file);
const previewUrl = previewUrls?.get(file) ?? "";
const textContent = textContents?.get(file);
const textFileId =
uploadState?.status === "uploaded" ? uploadState.fileId : undefined;
const hasTextAttachment =
file.type === "text/plain" &&
(textContent !== undefined || textFileId !== undefined);
return (
<div
// Key combines file metadata with index as a fallback for
// duplicate names. Acceptable for a small, append-only list.
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
className="group relative"
>
{file.type.startsWith("image/") && previewUrl ? (
<button
type="button"
className="border-0 bg-transparent p-0 cursor-pointer transition-opacity hover:opacity-80"
onClick={() => onPreview?.(previewUrl)}
>
<ImageThumbnail previewUrl={previewUrl} name={file.name} />
</button>
) : hasTextAttachment ? (
<button
type="button"
aria-label={`View ${file.name}`}
className="flex h-16 w-28 flex-col items-start justify-start overflow-hidden rounded-md border-0 bg-surface-tertiary p-2 text-left transition-colors hover:bg-surface-quaternary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
if (nextContent !== undefined) {
onTextPreview?.(nextContent, file.name);
}
}}
>
<span className="line-clamp-3 w-full font-mono text-2xs text-content-secondary">
{formatTextAttachmentPreview(textContent ?? "")}
</span>
</button>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-md border border-border-default bg-surface-secondary text-xs text-content-secondary">
{file.name.split(".").pop()?.toUpperCase() || "FILE"}
</div>
)}
{hasTextAttachment && (
<button
type="button"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
onInlineText?.(file, nextContent);
}}
className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label="Paste inline"
>
<ClipboardPasteIcon
aria-hidden="true"
className="h-3.5 w-3.5"
/>
</button>
)}
{(uploadState?.status === "pending" ||
uploadState?.status === "uploading") && (
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay">
<Spinner className="h-5 w-5 text-white" loading />
</div>
)}
{uploadState?.status === "error" && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay"
role="img"
aria-label="Upload error"
>
<AlertTriangleIcon className="h-5 w-5 text-content-warning" />
</div>
</TooltipTrigger>
<TooltipContent side="top">
<p className="max-w-xs text-xs">
{uploadState.error ?? "Upload failed"}
</p>
</TooltipContent>
</Tooltip>
)}
<button
type="button"
onClick={async () => {
const nextContent = await loadTextAttachmentContent(
textContent,
textFileId,
);
onInlineText?.(file, nextContent);
}}
className="absolute -bottom-2 -right-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label="Paste inline"
onClick={() => onRemove(file)}
className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label={`Remove ${file.name}`}
>
<ClipboardPasteIcon
aria-hidden="true"
className="h-3.5 w-3.5"
/>
<XIcon aria-hidden="true" className="h-3.5 w-3.5" />
</button>
)}
{uploadState?.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay">
<Spinner className="h-5 w-5 text-white" loading />
</div>
)}
{uploadState?.status === "error" && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="absolute inset-0 flex items-center justify-center rounded-md bg-overlay"
role="img"
aria-label="Upload error"
>
<AlertTriangleIcon className="h-5 w-5 text-content-warning" />
</div>
</TooltipTrigger>
<TooltipContent side="top">
<p className="max-w-xs text-xs">
{uploadState.error ?? "Upload failed"}
</p>
</TooltipContent>
</Tooltip>
)}
<button
type="button"
onClick={() => onRemove(file)}
className="absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-0 bg-surface-primary text-content-secondary shadow-sm opacity-0 transition-opacity hover:bg-surface-secondary hover:text-content-primary group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
aria-label={`Remove ${file.name}`}
>
<XIcon aria-hidden="true" className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
);
})}
</div>
{draftWarnings.length > 0 && (
<div className="space-y-1 px-3 pb-2 text-xs text-content-warning">
{draftWarnings.map((warning) => (
<div key={warning} className="flex items-start gap-1.5">
<AlertTriangleIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
)}
</div>
);
};
Loading