Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
editMessages: false,
newOnboarding: false,
toolsHub: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
data: {
experiments: {
claudeCodeMockCliTraffic: false,
editMessages: false,
newOnboarding: false,
toolsHub: true,
},
Expand Down
36 changes: 21 additions & 15 deletions apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ export interface FollowUpComposerProps {
onChangeMessage: (value: string, mentionRanges: PromptTextMention[]) => void;
onModifierSubmit: () => void;
onSubmit: () => void;
/** Accessible label and tooltip for the primary submit action. */
submitTitle?: string;
compactPromptPlaceholder: string;
promptPlaceholder: string;
canModifierSubmit: boolean;
Expand Down Expand Up @@ -716,21 +718,25 @@ function FollowUpPromptBoxWithComposer({
composer.isFollowUpSubmitting ||
(steerOnPrimarySubmit && !composer.canModifierSubmit),
onModifierSubmit,
title: canQueueFollowUp
? steerOnPrimarySubmit
? "Steer current run (Enter)"
: "Queue follow-up (Enter)"
: isStopping
? "Stopping run..."
: isLoadingExecutionOptions
? "Loading models..."
: isLoadingPendingInteractions
? "Checking pending interactions..."
: isProvisioning
? "Provisioning..."
: isUnavailable
? "Unavailable"
: "Submit (Enter)",
title: composer.isFollowUpSubmitting
? "Submitting..."
: canSubmit && composer.submitTitle !== undefined
? composer.submitTitle
: canQueueFollowUp
? steerOnPrimarySubmit
? "Steer current run (Enter)"
: "Queue follow-up (Enter)"
: isStopping
? "Stopping run..."
: isLoadingExecutionOptions
? "Loading models..."
: isLoadingPendingInteractions
? "Checking pending interactions..."
: isProvisioning
? "Provisioning..."
: isUnavailable
? "Unavailable"
: "Submit (Enter)",
isRunning: canStopRuntime,
}}
typeahead={typeahead}
Expand Down
71 changes: 71 additions & 0 deletions apps/app/src/components/promptbox/InlineMessageEditorFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { Button } from "@bb/shared-ui/button";
import { Icon } from "@bb/shared-ui/icon";
import { cn } from "@bb/shared-ui/lib/utils";

import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard";

interface InlineMessageEditorFrameProps {
cancelLabel: string;
children: ReactNode;
label: string;
onCancel: () => void;
variant?: "embedded" | "cap";
}

/** Shared chrome for compact editors shown inline with a message. */
export function InlineMessageEditorFrame({
cancelLabel,
children,
label,
onCancel,
variant = "embedded",
}: InlineMessageEditorFrameProps) {
const header = (
<div
className={cn(
"flex min-h-7 items-center gap-1.5 text-xs text-subtle-foreground",
variant === "cap" ? "h-8 px-3" : "mb-1.5 pl-1",
)}
>
<Icon name="Edit" className="size-3.5 shrink-0" aria-hidden />
<span className={cn("shrink-0", variant === "cap" && "font-medium")}>
{label}
</span>
<Button
type="button"
size="icon"
variant="ghost"
className="ml-auto size-6 shrink-0 text-subtle-foreground"
onClick={onCancel}
aria-label={cancelLabel}
>
<Icon name="X" className="size-3" aria-hidden />
</Button>
</div>
);

if (variant === "cap") {
return (
<div
className="relative z-20 space-y-2"
data-inline-message-editor-frame="cap"
>
<PromptStackCard
ariaLabel={label}
className="relative z-10 -mb-5 rounded-xl rounded-b-none border-b-0 bg-surface-raised-solid pb-3 shadow-lift"
>
{header}
</PromptStackCard>
<div className="relative z-20">{children}</div>
</div>
);
}

return (
<div className="relative z-20" data-inline-message-editor-frame="embedded">
{header}
{children}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,9 @@ describe("QueuedMessagesList", () => {
),
).toBe(true);
const editingLabel = getByText(/Editing queued message/u);
expect(
editingLabel.closest('[data-inline-message-editor-frame="embedded"]'),
).not.toBeNull();
const dismissButton = getByRole("button", {
name: "Stop editing queued message",
});
Expand Down
23 changes: 7 additions & 16 deletions apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { Icon } from "@bb/shared-ui/icon";
import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard";
import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState";
import { OverflowFade } from "@/components/ui/overflow-fade";
import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame";
import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body";
import {
Tooltip,
Expand Down Expand Up @@ -936,23 +937,13 @@ function QueuedMessageInlineEditorSlot({
className="relative z-10 border-b border-border/35 px-2.5 py-1 last:border-b-0"
>
<OverflowFade placement="above" tone="surface-raised" className="z-10" />
<div className="relative z-20">
<div className="mb-1.5 flex min-h-7 items-center gap-1.5 pl-1 text-xs text-subtle-foreground">
<Icon name="Edit" className="size-3.5" aria-hidden />
<span>Editing queued message {editor.queuedMessageIndex + 1}</span>
<Button
type="button"
size="icon"
variant="ghost"
className="ml-auto size-6 text-subtle-foreground"
onClick={editor.onDismiss}
aria-label="Stop editing queued message"
>
<Icon name="X" className="size-3" aria-hidden />
</Button>
</div>
<InlineMessageEditorFrame
cancelLabel="Stop editing queued message"
label={`Editing queued message ${editor.queuedMessageIndex + 1}`}
onCancel={editor.onDismiss}
>
{editor.content}
</div>
</InlineMessageEditorFrame>
<OverflowFade placement="below" tone="surface-raised" className="z-10" />
</li>
);
Expand Down
6 changes: 5 additions & 1 deletion apps/app/src/components/thread/embedded-chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export {
type InlineQueuedMessageEditState,
} from "./useInlineQueuedMessageEditing";
export { useActiveComposerDraft } from "./useActiveComposerDraft";
export { useComposerAttachmentUploads } from "./useComposerAttachmentUploads";
export {
useComposerAttachmentUploads,
useDraftAttachmentUploads,
type DraftAttachmentUploadTarget,
} from "./useComposerAttachmentUploads";
export { useComposerTypeahead } from "./useComposerTypeahead";
export { useQueuedMessageActions } from "./useQueuedMessageActions";
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { InlineQueuedMessageEditState } from "./useInlineQueuedMessageEditing";
import { useComposerAttachmentUploads } from "./useComposerAttachmentUploads";
import type { PromptDraftAttachment } from "@/lib/prompt-draft";
import {
useComposerAttachmentUploads,
useDraftAttachmentUploads,
} from "./useComposerAttachmentUploads";

const mocks = vi.hoisted(() => ({
upload: vi.fn(),
Expand Down Expand Up @@ -150,4 +154,56 @@ describe("useComposerAttachmentUploads", () => {
expect(result.current.inlineAttachmentError).toBeNull();
expect(commitInlineQueuedMessage).not.toHaveBeenCalled();
});

it("does not leak a dismissed upload into a later independent draft", async () => {
const oldUpload = deferred<PromptDraftAttachment>();
mocks.upload.mockReturnValueOnce(oldUpload.promise);
const addFirstAttachment = vi.fn();
const addSecondAttachment = vi.fn();
const { result, rerender } = renderHook(
({ target }) =>
useDraftAttachmentUploads({
projectId: "proj_1",
target,
}),
{
initialProps: {
target: {
key: "edit-1",
addAttachment: addFirstAttachment,
} as {
key: string;
addAttachment: (attachment: PromptDraftAttachment) => void;
} | null,
},
},
);

let uploadPromise!: Promise<void>;
act(() => {
uploadPromise = result.current.handleAttachFiles([
new File(["old"], "old.txt"),
]);
});
expect(result.current.isAttachingFiles).toBe(true);

rerender({ target: null });
rerender({
target: { key: "edit-2", addAttachment: addSecondAttachment },
});
await act(async () => {
oldUpload.resolve({
type: "localFile",
path: "uploads/old.txt",
name: "old.txt",
sizeBytes: 3,
});
await uploadPromise;
});

expect(addFirstAttachment).not.toHaveBeenCalled();
expect(addSecondAttachment).not.toHaveBeenCalled();
expect(result.current.attachmentError).toBeNull();
expect(result.current.isAttachingFiles).toBe(false);
});
});
Loading
Loading