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
12 changes: 0 additions & 12 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,6 @@ import {
import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import { isEditableFocused } from "../lib/editableFocus";
import { undoLatestThreadAction } from "../hooks/showUndoToast";
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
import {
AlarmClockIcon,
Expand Down Expand Up @@ -6731,17 +6730,6 @@ export default function ChatView(props: ChatViewProps) {
return;
}

if (command === "thread.undo") {
// Only claim the chord when there is an Undo to run; otherwise the
// page keeps its native behavior for the key.
if (event.repeat) return;
if (undoLatestThreadAction()) {
event.preventDefault();
event.stopPropagation();
}
return;
}

if (command === "thread.pin") {
event.preventDefault();
event.stopPropagation();
Expand Down
40 changes: 7 additions & 33 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3077,9 +3077,7 @@ export default function Sidebar() {
settlingThreadKeysRef.current.add(threadKey);
try {
const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys);
const result = await settleThread(threadRef, {
undoToast: opts.coSettlingKeys === undefined,
});
const result = await settleThread(threadRef);
if (result._tag === "Failure") {
// Never navigate away from a thread that did not settle.
if (!isAtomCommandInterrupted(result)) {
Expand Down Expand Up @@ -3731,9 +3729,7 @@ export default function Sidebar() {
// Snoozing the open thread moves you forward, same as settle —
// both park the thread you're done with for now.
const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys);
const result = await snoozeThread(threadRef, preset.snoozedUntil, {
undoToast: opts.coSnoozingKeys === undefined,
});
const result = await snoozeThread(threadRef, preset.snoozedUntil);
if (result._tag === "Failure") {
// Never navigate away from a thread that did not snooze.
return isAtomCommandInterrupted(result)
Expand Down Expand Up @@ -3887,35 +3883,15 @@ export default function Sidebar() {
outcome.status === "failure" ? [outcome.error] : [],
);

if (snoozedThreadRefs.length > 0) {
const snoozedCount = snoozedThreadRefs.length;
const failedCount = failures.length;
toastManager.add(
stackedThreadToast({
type: failedCount > 0 ? "warning" : "success",
title:
failedCount > 0
? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads`
: `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`,
description:
failedCount > 0
? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.`
: undefined,
timeout: 5_000,
actionProps: {
children: "Undo",
onClick: () => {
for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef);
},
},
}),
);
} else if (failures.length > 0) {
if (failures.length > 0) {
const firstError = failures[0];
toastManager.add(
stackedThreadToast({
type: "error",
title: "Failed to snooze threads",
title:
snoozedThreadRefs.length > 0
? `Failed to snooze ${failures.length} thread${failures.length === 1 ? "" : "s"}`
: "Failed to snooze threads",
description:
firstError instanceof Error ? firstError.message : "An error occurred.",
}),
Expand Down Expand Up @@ -4019,7 +3995,6 @@ export default function Sidebar() {
},
[
attemptSettle,
attemptSnooze,
attemptUnpin,
clearSelection,
confirmThreadDelete,
Expand All @@ -4028,7 +4003,6 @@ export default function Sidebar() {
performSnooze,
removeFromSelection,
serverConfigs,
attemptUnsnooze,
updateThreadMetadata,
timestampFormat,
],
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
Expand Down Expand Up @@ -221,6 +222,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
return (
<SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">
<SidebarThreadUndoNotice />
<SidebarProviderUpdatePill />
<SidebarUpdateArchitectureWarning />
<SidebarUtilityMenu />
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useAtomValue } from "@effect/atom-react";

import { undoLatestThreadAction, useThreadUndoNotice } from "../../hooks/showThreadUndoNotice";
import { shortcutLabelForCommand } from "../../keybindings";
import { primaryServerKeybindingsAtom } from "../../state/server";
import { Alert, AlertDescription } from "../ui/alert";
import { InlineButton } from "../ui/button";

export function SidebarThreadUndoNotice() {
const notice = useThreadUndoNotice((state) => state.notice);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);

if (!notice) return null;
const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");

return (
<Alert role="status" variant="sidebar">
<AlertDescription>
{notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}
<InlineButton
underline
onClick={undoLatestThreadAction}
className="hover:text-sidebar-foreground"
>
{shortcut ? `${shortcut} to undo` : "Undo"}
</InlineButton>
</AlertDescription>
</Alert>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-fore
variants: {
variant: {
default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground",
sidebar:
"rounded-lg border-sidebar-border bg-sidebar-control-surface px-2 py-1.5 text-[11px] leading-4 [&_[data-slot=alert-description]]:block [&_[data-slot=alert-description]]:text-sidebar-muted-foreground",
error:
"border-error/32 bg-error-surface text-error-foreground [&_[data-slot=alert-description]]:text-error-foreground/80 [&_svg]:text-error",
info: "border-info/32 bg-info/4 [&_svg]:text-info",
Expand Down
152 changes: 152 additions & 0 deletions apps/web/src/hooks/showThreadUndoNotice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { AsyncResult } from "effect/unstable/reactivity";
import * as Cause from "effect/Cause";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { toastManager } from "../components/ui/toast";
import {
showThreadUndoNotice,
undoLatestThreadAction,
useThreadUndoNotice,
} from "./showThreadUndoNotice";
import * as ThreadUndo from "./threadUndo";

beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});

function setup() {
const add = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
const undo = vi.fn(async () => AsyncResult.success(undefined));
const claim = ThreadUndo.begin("pin", "env/thread");
const options = { action: "Unpinned" as const, failureTitle: "Restore failed", undo, claim };
return { add, undo, claim, options };
}

function notice() {
const value = useThreadUndoNotice.getState().notice;
if (!value) throw new Error("Undo notice is missing");
return value;
}

describe("thread undo notice", () => {
it("aggregates consecutive actions without success toasts and restores the group once", async () => {
const { add, undo, options } = setup();
showThreadUndoNotice({
...options,
action: "Settled",
claim: ThreadUndo.begin("settle", "env/a"),
});
showThreadUndoNotice({
...options,
action: "Settled",
claim: ThreadUndo.begin("settle", "env/b"),
});
expect(notice()).toMatchObject({ action: "Settled", count: 2 });
expect(add).not.toHaveBeenCalled();
const group = notice();
await group.undo();
await group.undo();
expect(undo).toHaveBeenCalledTimes(2);
expect(undoLatestThreadAction()).toBe(false);
});

it("drops invalidated claims immediately and rejects a captured stale undo", async () => {
const { undo, options } = setup();
showThreadUndoNotice(options);
const stale = notice();
ThreadUndo.invalidate("pin", "env/thread");
expect(useThreadUndoNotice.getState().notice).toBeNull();
showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/thread") });
await stale.undo();
expect(undo).not.toHaveBeenCalled();
await notice().undo();
expect(undo).toHaveBeenCalledOnce();
});

it("does not show a notice for a late completion after a newer action", () => {
const { add, options } = setup();
ThreadUndo.invalidate("pin", "env/thread");
showThreadUndoNotice(options);
expect(useThreadUndoNotice.getState().notice).toBeNull();
expect(add).not.toHaveBeenCalled();
});

it("keeps the group available until five seconds after the latest action", async () => {
const { undo, claim, options } = setup();
showThreadUndoNotice(options);
vi.advanceTimersByTime(4_000);
const second = ThreadUndo.begin("pin", "env/second");
showThreadUndoNotice({ ...options, claim: second });
const group = notice();
vi.advanceTimersByTime(4_999);
expect(notice().count).toBe(2);
vi.advanceTimersByTime(1);
expect(useThreadUndoNotice.getState().notice).toBeNull();
expect(claim.isCurrent()).toBe(false);
expect(second.isCurrent()).toBe(false);
await group.undo();
expect(undo).not.toHaveBeenCalled();
});

it("reports a failed restore and releases its claim", async () => {
const { add, claim, options } = setup();
showThreadUndoNotice({
...options,
undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))),
});
await notice().undo();
expect(add).toHaveBeenLastCalledWith(
expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }),
);
expect(claim.isCurrent()).toBe(false);
});

it("reports a rejected restore promise", async () => {
const { add, options } = setup();
showThreadUndoNotice({
...options,
undo: async () => {
throw new Error("disconnected");
},
});
await notice().undo();
expect(add).toHaveBeenLastCalledWith(
expect.objectContaining({ type: "error", description: "disconnected" }),
);
});

it("does not report interrupted restores as errors", async () => {
const { add, options } = setup();
showThreadUndoNotice({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) });
await notice().undo();
expect(add).not.toHaveBeenCalled();
});

it("undoes the latest kind first, then reveals the preceding group", async () => {
const { options } = setup();
const older = vi.fn(async () => AsyncResult.success(undefined));
const newer = vi.fn(async () => AsyncResult.success(undefined));
showThreadUndoNotice({
...options,
action: "Settled",
undo: older,
claim: ThreadUndo.begin("settle", "env/a"),
});
showThreadUndoNotice({
...options,
action: "Snoozed",
undo: newer,
claim: ThreadUndo.begin("snooze", "env/b"),
});
expect(undoLatestThreadAction()).toBe(true);
expect(newer).toHaveBeenCalledOnce();
expect(older).not.toHaveBeenCalled();
expect(notice().action).toBe("Settled");
await notice().undo();
expect(older).toHaveBeenCalledOnce();
expect(undoLatestThreadAction()).toBe(false);
});
});
Loading
Loading