Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
df8ae34
Preserve queued drafts until server acceptance
brsbl Sep 17, 2026
b0eada1
Set active runtime in queued draft regression fixture
brsbl Sep 17, 2026
646a88c
Await queue acceptance in embedded composer test
brsbl Sep 17, 2026
8f5e023
Preserve queued submission locks across composer mounts
brsbl Sep 17, 2026
f1af6c7
Lock message drafts until submission settles
brsbl Sep 18, 2026
4437488
Avoid restoring submitted text when the editor unlocks
brsbl Sep 18, 2026
f6c16bc
Persist message submissions and safely retry after reconnecting
brsbl Sep 18, 2026
2d9d4e5
Align durable delivery integration with cache and migration contracts
brsbl Sep 18, 2026
a4171df
Preserve delivery timeout reasons and version additive SDK fields
brsbl Sep 18, 2026
c57f446
Load persistent message delivery outside the initial app bundle
brsbl Sep 18, 2026
2393014
Integrate current queue provenance and regenerate delivery migration
brsbl Sep 18, 2026
d47d5bc
Keep local delivery rows out of server sender metadata rendering
brsbl Sep 18, 2026
f88ba94
Load embedded delivery composer when a plugin opens its chat
brsbl Sep 18, 2026
740c6d0
Keep composer loading stable and budget the small draft handoff addition
brsbl Sep 18, 2026
da56efb
Trim duplicate delivery wiring and unrelated formatting churn
brsbl Sep 18, 2026
e4981f5
Merge remote-tracking branch 'origin/main' into bb/fix-queued-message…
brsbl Sep 18, 2026
e42dab3
Preserve explicit Send now for durable queued messages
brsbl Sep 18, 2026
c06ae78
Merge main and preserve submission IDs alongside message file inputs
brsbl Sep 19, 2026
916827d
Reuse shared exponential backoff for message delivery
brsbl Sep 19, 2026
64da9d2
Remove redundant message delivery validation
brsbl Sep 19, 2026
a2959ea
Merge main and advance plugin SDK patch version
brsbl Sep 19, 2026
7a9a48c
Reduce queue failure fix to existing draft and mutation handling
brsbl Sep 19, 2026
8153448
Wait for queue acceptance before asserting draft clear
brsbl Sep 19, 2026
9ae913a
Retain pending submissions in the existing queue drawer and accept re…
brsbl Sep 19, 2026
8207008
Integrate pending delivery with existing cache ownership and contract…
brsbl Sep 19, 2026
e23b447
Keep pending message plumbing out of the boot bundle and update migra…
brsbl Sep 19, 2026
9e54323
Keep draft recovery conservative and avoid clearing newer composer work
brsbl Sep 19, 2026
485a830
Mark generated schema snapshots and remove stray draft-store diff
brsbl Sep 19, 2026
43538a6
Retain steering messages through the shared submission recovery path
brsbl Sep 20, 2026
cc9a1dc
test: cover keyed send resume and overlapping steering
brsbl Sep 20, 2026
b926457
fix: dispatch fresh keyed submissions through single-row claims
brsbl Sep 20, 2026
7171103
Clarify queued messages awaiting server connection
brsbl Sep 20, 2026
878c27a
Use the standard loading icon for pending messages
brsbl Sep 20, 2026
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
* text=auto eol=lf
packages/db/drizzle/meta/*_snapshot.json linguist-generated=true
9 changes: 9 additions & 0 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provi
import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay";
import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton";

const PendingThreadMessagesSync = lazy(() =>
import("@/lib/PendingThreadMessagesSync").then((module) => ({
default: module.PendingThreadMessagesSync,
})),
);

const SettingsView = lazy(() =>
import("./views/SettingsView").then((m) => ({
default: m.SettingsView,
Expand Down Expand Up @@ -433,6 +439,9 @@ export function App() {
<HashNavigationScroll />
<NativeShellReporter />
<UiPreferencesSync />
<Suspense fallback={null}>
<PendingThreadMessagesSync />
</Suspense>
<Routes>
<Route
path={AUTH_CALLBACK_ROUTE_PATH}
Expand Down
71 changes: 55 additions & 16 deletions apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Transform } from "@dnd-kit/utilities";
import {
usePendingThreadMessages,
removePendingThreadMessage,
getPendingThreadMessages,
} from "@/lib/pending-thread-messages";
import type {
PromptInput,
PromptTextMention,
Expand Down Expand Up @@ -199,6 +204,9 @@ function getDrawerHeight({
total +
DRAWER_ROW_HEIGHT +
(queuedMessage.initiator !== "user" ||
getPendingThreadMessages().some(
(entry) => entry.row.id === queuedMessage.id,
) ||
queuedMessageHasWaitLine(queuedMessage) ||
queuedMessage.id === processingMessageId
? queuedMessage.initiator === "agent" &&
Expand Down Expand Up @@ -703,18 +711,27 @@ function QueuedMessageWaitLine({
pluginDisplayName: string;
queuedMessage: ThreadQueuedMessage;
}) {
const pending = usePendingThreadMessages().find(
(entry) => entry.row.id === queuedMessage.id,
);
const now = useSecondTick();
const label = describeQueuedMessageWait({
failureReason: queuedMessage.failureReason,
now,
payload: queuedMessage.payload,
pluginDisplayName,
sendAt: queuedMessage.sendAt,
waitingOn: queuedMessage.waitingOn,
});
const label = pending
? (pending.error ?? "Connecting to server")
: describeQueuedMessageWait({
failureReason: queuedMessage.failureReason,
now,
payload: queuedMessage.payload,
pluginDisplayName,
sendAt: queuedMessage.sendAt,
waitingOn: queuedMessage.waitingOn,
});
if (label === null) return null;
const failed = queuedMessage.failureReason !== null;
const icon = queuedMessageWaitIcon(queuedMessage);
const failed = pending?.error != null || queuedMessage.failureReason !== null;
const icon = pending
? failed
? "AlertCircle"
: "Loading"
: queuedMessageWaitIcon(queuedMessage);
const countdownInstant = queuedMessageCountdownInstant(queuedMessage);
const countdown =
countdownInstant === null
Expand All @@ -730,7 +747,11 @@ function QueuedMessageWaitLine({
)}
>
{icon !== null ? (
<Icon name={icon} className="size-3 shrink-0" aria-hidden />
<Icon
name={icon}
className={cn("size-3 shrink-0", pending && !failed && "animate-spin")}
aria-hidden
/>
) : queuedMessage.waitingOn?.kind === "plugin" ? (
<PluginIcon
pluginId={queuedMessage.waitingOn.pluginId}
Expand Down Expand Up @@ -782,6 +803,14 @@ const QueuedMessageRow = memo(function QueuedMessageRow({
compact,
isGroupBoundary,
}: QueuedMessageRowProps) {
const pending = usePendingThreadMessages().find(
(entry) => entry.row.id === queuedMessage.id,
);
sendDisabled ||= pending !== undefined;
dragDisabled ||= pending !== undefined;
const deleteMessage = pending
? () => removePendingThreadMessage(queuedMessage.id)
: () => onDelete(queuedMessage.id);
const actionsRef = useRef<HTMLDivElement>(null);
const focusActionsOnExpandRef = useRef(false);
useLayoutEffect(() => {
Expand All @@ -800,7 +829,8 @@ const QueuedMessageRow = memo(function QueuedMessageRow({
? queuedMessage.waitingOn.pluginId
: "",
);
const hasWaitLine = queuedMessageHasWaitLine(queuedMessage);
const hasWaitLine =
pending !== undefined || queuedMessageHasWaitLine(queuedMessage);
const sendAllowed =
sendAction === "steer-when-ready" ||
isQueuedMessageSendNowAllowed(queuedMessage.waitingOn);
Expand Down Expand Up @@ -997,7 +1027,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({
"shrink-0 text-muted-foreground",
compact ? "size-7" : "size-8",
)}
disabled={actionDisabled}
disabled={actionDisabled || pending !== undefined}
onClick={() =>
onEdit({
queuedMessageId: queuedMessage.id,
Expand All @@ -1024,8 +1054,11 @@ const QueuedMessageRow = memo(function QueuedMessageRow({
"shrink-0 text-muted-foreground hover:text-destructive max-md:text-destructive",
compact ? "size-7" : "size-8",
)}
disabled={actionDisabled}
onClick={() => onDelete(queuedMessage.id)}
disabled={
actionDisabled ||
(pending !== undefined && pending.error === null)
}
onClick={deleteMessage}
aria-label={`Delete queued message ${index + 1}`}
>
<Icon name="Trash2" className="size-4" aria-hidden />
Expand Down Expand Up @@ -1240,6 +1273,7 @@ export function QueuedMessagesList({
onEdit,
onDelete,
}: QueuedMessagesListProps) {
const pendingMessages = usePendingThreadMessages();
const senderThreadMetadataById = useSenderThreadMetadataById();
const processingLabel =
processingAction === "edit"
Expand Down Expand Up @@ -1455,7 +1489,12 @@ export function QueuedMessagesList({
];
}, [groupBoundaryIndex, orderedMessages]);
const sortingDisabled =
actionDisabled || processingMessageId !== null || queuedMessages.length < 2;
actionDisabled ||
processingMessageId !== null ||
queuedMessages.length < 2 ||
queuedMessages.some((row) =>
pendingMessages.some((entry) => entry.row.id === row.id),
);
const sortableIds = useMemo(
() =>
inlineEditor
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { createDeferredPromise } from "@bb/test-helpers";
import { useEffect, useLayoutEffect, type ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FollowUpComposerProps } from "@/components/promptbox/FollowUpPromptBox";
Expand Down Expand Up @@ -295,6 +302,9 @@ vi.mock("@/hooks/queries/system-queries", () => ({
}),
}));

vi.mock("@/hooks/useRetainThreadMessage", () => ({
useRetainThreadMessage: () => ({ connected: true, retain: () => false }),
}));
vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({
useCreateThreadQueuedMessage: () => ({
mutateAsync: mocks.createQueuedMessageMutateAsync,
Expand Down Expand Up @@ -512,9 +522,39 @@ describe("EmbeddedThreadChat", () => {
}),
);
expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled();
await vi.waitFor(() => {
expect(
screen.getByTestId<HTMLInputElement>("embedded-chat-composer").value,
).toBe("");
});
});

it("keeps a queued draft when the request fails after the composer closes", async () => {
mocks.threadRuntimeDisplayStatus = "active";
const pending = createDeferredPromise<void>();
mocks.createQueuedMessageMutateAsync.mockReturnValueOnce(pending.promise);
const first = renderEmbeddedChat();
fireEvent.change(screen.getByTestId("embedded-chat-composer"), {
target: { value: "Do not lose this" },
});
fireEvent.click(screen.getByText("Send"));
expect(
screen.getByTestId<HTMLInputElement>("embedded-chat-composer").value,
).toBe("");
).toBe("Do not lose this");
first.unmount();
await act(async () => {
pending.reject(new Error("Connection lost"));
});
renderEmbeddedChat();
expect(
screen.getByTestId<HTMLInputElement>("embedded-chat-composer").value,
).toBe("Do not lose this");
fireEvent.click(screen.getByText("Send"));
await vi.waitFor(() => {
expect(
screen.getByTestId<HTMLInputElement>("embedded-chat-composer").value,
).toBe("");
});
});

it("sends directly when the thread runtime is idle", async () => {
Expand Down
Loading
Loading