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
97 changes: 76 additions & 21 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
endThreadContextDrag,
moveThreadContextDrag as moveThreadContextDragGhost,
} from "./chat/threadContextDrag";
import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
import { discardComposerDraft } from "../lib/discardComposerDraft";
import { requestCustomSnooze } from "./CustomSnoozeDialog";
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
Expand Down Expand Up @@ -169,7 +169,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { cn } from "~/lib/utils";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
animateSidebarLayoutChanges,
applySidebarThreadDrop,
Expand Down Expand Up @@ -798,8 +798,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
isActive: boolean;
onNavigate: (draftId: DraftId) => void;
onDiscard: (draftId: DraftId) => void;
onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
}) {
const { composer, draftId, onDiscard, onNavigate } = props;
const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props;
const promptPreview =
replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
.trim()
Expand Down Expand Up @@ -829,12 +830,23 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
// preventDefault here would swallow Space's synthesized click and
// navigate instead of discarding.
if ((event.target as HTMLElement).closest("button")) return;
if (event.key === "Enter" || event.key === " ") {
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
onContextMenu(draftId, { x: rect.left, y: rect.bottom });
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onNavigate(draftId);
}
},
[draftId, onNavigate],
[draftId, onContextMenu, onNavigate],
);
const handleContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
onContextMenu(draftId, { x: event.clientX, y: event.clientY });
},
[draftId, onContextMenu],
);
const handleDiscard = useCallback(
(event: ReactMouseEvent) => {
Expand All @@ -857,6 +869,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
props.isActive ? "bg-sidebar-row-active" : draftSurfaceClassName,
)}
onClick={handleActivate}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
>
<span className="sr-only">{preview}</span>
Expand Down Expand Up @@ -912,10 +925,10 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
scopedProjectKeys: ReadonlySet<string> | null;
routeDraftId: string | null;
onNavigateToDraft: (draftId: DraftId) => void;
onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void;
}) {
const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey);
const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
// The open draft's row is FROZEN at the moment the draft became the route:
// it stays visible (like a thread row) but never repaints while the user
// types. A draft that was never navigated away from has no snapshot to
Expand Down Expand Up @@ -979,16 +992,6 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
props.routeDraftId,
props.scopedProjectKeys,
]);
const handleDiscard = useCallback(
(draftId: DraftId) => {
// The /draft/$draftId route redirects home on its own when the draft
// it renders disappears, so discarding the open draft needs no
// special-casing here.
releaseComposerDraftUploads(draftId);
clearDraftThread(draftId);
},
[clearDraftThread],
);
if (drafts.length === 0) {
return null;
}
Expand All @@ -1005,7 +1008,11 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null}
isActive={draftId === props.routeDraftId}
onNavigate={props.onNavigateToDraft}
onDiscard={handleDiscard}
// The /draft/$draftId route redirects home on its own when the
// draft it renders disappears, so discarding the open draft needs
// no special-casing here.
onDiscard={discardComposerDraft}
onContextMenu={props.onDraftContextMenu}
/>
);
})}
Expand Down Expand Up @@ -1164,15 +1171,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// Unsent composer text on this thread. The open thread shows its own
// composer, so the marker only decorates rows you have navigated away from.
const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive;
const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent);
const handleDiscardDraftClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
releaseComposerDraftUploads(threadRef);
clearComposerContent(threadRef);
discardComposerDraft(threadRef);
},
[clearComposerContent, threadRef],
[threadRef],
);

const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null;
Expand Down Expand Up @@ -4227,6 +4232,55 @@ export default function Sidebar() {
],
);

const handleDraftContextMenu = useCallback(
(draftId: DraftId, position: { x: number; y: number }) => {
void (async () => {
const api = readLocalApi();
const session = useComposerDraftStore.getState().getDraftSession(draftId);
if (!api || !session || session.promotedTo) return;
const projectGroup = projectGroupsRef.current.find((group) =>
group.memberProjectRefs.some(
(ref) =>
ref.environmentId === session.environmentId && ref.projectId === session.projectId,
),
);
const workspacePath =
session.worktreePath ??
projectByKey.get(`${session.environmentId}:${session.projectId}`)?.workspaceRoot;
const clicked = await settlePromise(() =>
api.contextMenu.show(
buildDraftActionMenuItems({
hasPath: Boolean(workspacePath),
hasBranch: Boolean(session.branch),
hasProject: projectGroup != null,
}),
position,
),
);
if (clicked._tag === "Failure") return;
switch (clicked.value) {
case "project-settings":
if (projectGroup) openProjectSettings(projectGroup);
return;
case "copy-path":
if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath });
return;
case "copy-branch":
if (session.branch) copyBranchToClipboard(session.branch, { branch: session.branch });
return;
case "discard": {
// The menu can stay open while the draft sends; discarding a
// promoting draft would strand the send.
const current = useComposerDraftStore.getState().getDraftSession(draftId);
if (current && !current.promotedTo) discardComposerDraft(draftId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/Sidebar.tsx:4275

Discarded draft attachments leak when the app reloads or closes before the five-second undo window expires. discardComposerDraft clears the persisted attachment references immediately, while releaseDraftAttachments runs only from the in-memory undo commit; after reload that timer is gone and no record remains to release the queued/server uploads. Persist or recover the pending cleanup, or retain the attachment references until cleanup is committed.

Also found in 1 other location(s)

apps/web/src/lib/discardComposerDraft.ts:77

Deferring releaseDraftAttachments exclusively to the five-second undo commit leaks discarded uploads when the app is reloaded or closed during that window. clearDraftThread/clearComposerContent has already removed the attachment references, and the in-memory timer never runs after navigation; on the next load there is no record from which to release the pending/server upload. Previously the sidebar released uploads before clearing. Persist/recover pending cleanup, or avoid removing the only cleanup references before the commit can run.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/Sidebar.tsx around line 4275:

Discarded draft attachments leak when the app reloads or closes before the five-second undo window expires. `discardComposerDraft` clears the persisted attachment references immediately, while `releaseDraftAttachments` runs only from the in-memory undo `commit`; after reload that timer is gone and no record remains to release the queued/server uploads. Persist or recover the pending cleanup, or retain the attachment references until cleanup is committed.

Also found in 1 other location(s):
- apps/web/src/lib/discardComposerDraft.ts:77 -- Deferring `releaseDraftAttachments` exclusively to the five-second undo `commit` leaks discarded uploads when the app is reloaded or closed during that window. `clearDraftThread`/`clearComposerContent` has already removed the attachment references, and the in-memory timer never runs after navigation; on the next load there is no record from which to release the pending/server upload. Previously the sidebar released uploads before clearing. Persist/recover pending cleanup, or avoid removing the only cleanup references before the commit can run.

return;
}
}
})();
},
[copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey],
);

const handleThreadContextMenu = useCallback(
(threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
void (async () => {
Expand Down Expand Up @@ -5048,6 +5102,7 @@ export default function Sidebar() {
scopedProjectKeys={scopedProjectKeys}
routeDraftId={routeDraftIdForRows}
onNavigateToDraft={navigateToDraft}
onDraftContextMenu={handleDraftContextMenu}
/>,
];
for (const item of sidebarListItems) {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ export function SidebarThreadUndoNotice() {

if (!notice) return null;
const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");
const noun = `${notice.action === "Discarded" ? "draft" : "thread"}${notice.count === 1 ? "" : "s"}`;

return (
<Alert role="status" variant="sidebar">
<AlertDescription>
{notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}
{notice.action} {notice.count} {noun},{" "}
<InlineButton onClick={undoLatestThreadAction}>
{shortcut ? `${shortcut} to undo` : "Undo"}
</InlineButton>
Expand Down
27 changes: 26 additions & 1 deletion apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vite-plus/test";

import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./threadActionMenu.logic";
import {
buildDraftActionMenuItems,
buildThreadActionMenuItems,
type ThreadActionMenuState,
} from "./threadActionMenu.logic";

const baseState: ThreadActionMenuState = {
branch: null,
Expand Down Expand Up @@ -167,3 +171,24 @@ describe("buildThreadActionMenuItems", () => {
expect(archiveItem?.disabled).toBe(true);
});
});

describe("buildDraftActionMenuItems", () => {
it("offers only the copy values the draft has", () => {
const items = buildDraftActionMenuItems({ hasPath: false, hasBranch: true, hasProject: true });
expect(items[0]).toMatchObject({ id: "copy", disabled: false });
expect(items[0]?.children?.map((item) => item.id)).toEqual(["copy-branch"]);

const noCopy = buildDraftActionMenuItems({
hasPath: false,
hasBranch: false,
hasProject: true,
});
expect(noCopy[0]).toMatchObject({ id: "copy", disabled: true, children: [] });
});

it("drops project settings without a project and keeps discard last", () => {
const items = buildDraftActionMenuItems({ hasPath: true, hasBranch: false, hasProject: false });
expect(items.map((item) => item.id)).toEqual(["copy", "discard"]);
expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true });
});
});
39 changes: 39 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,45 @@ export type ThreadActionMenuId =
| "archive"
| "delete";

export type DraftActionMenuId =
| "copy"
| "copy-path"
| "copy-branch"
| "project-settings"
| "discard";

/** Right-click menu for an unsent draft row in the sidebar. */
export function buildDraftActionMenuItems(options: {
readonly hasPath: boolean;
readonly hasBranch: boolean;
readonly hasProject: boolean;
}): ReadonlyArray<ContextMenuItem<DraftActionMenuId>> {
return [
{
id: "copy",
label: "Copy",
icon: "copy",
disabled: !options.hasPath && !options.hasBranch,
children: [
...(options.hasPath ? [{ id: "copy-path" as const, label: "Path", icon: "folder" }] : []),
...(options.hasBranch
? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }]
: []),
],
},
...(options.hasProject
? [{ id: "project-settings" as const, label: "Project settings", icon: "settings" }]
: []),
{
id: "discard",
label: "Discard draft",
icon: "trash",
destructive: true,
separatorBefore: true,
},
];
}

export interface ThreadActionMenuState {
readonly branch: string | null;
/**
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1504,7 +1504,7 @@ function normalizeComposerTarget(
return target;
}

function resolveComposerDraftKey(
export function resolveComposerDraftKey(
state: ComposerThreadLookupState,
target: ComposerThreadTarget,
): string | null {
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/hooks/showThreadUndoNotice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast";
import * as ThreadUndo from "./threadUndo";

type UndoOptions = {
action: "Settled" | "Snoozed" | "Unpinned" | "Archived";
action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded";
undo: () => Promise<AtomCommandResult<unknown, unknown>>;
failureTitle: string;
claim: ReturnType<typeof ThreadUndo.begin>;
/** Runs once the action can no longer be undone. */
commit?: () => void;
};

type UndoNotice = {
Expand All @@ -29,7 +31,9 @@ let liveUndos: UndoOptions[] = [];
let expiry: ReturnType<typeof setTimeout> | undefined;

function refreshNotice() {
liveUndos = liveUndos.filter(({ claim }) => claim.isCurrent());
const stale = liveUndos.filter(({ claim }) => !claim.isCurrent());
liveUndos = liveUndos.filter((entry) => !stale.includes(entry));
for (const entry of stale) entry.commit?.();
const latest = liveUndos.at(-1);
if (!latest) {
clearTimeout(expiry);
Expand Down Expand Up @@ -100,7 +104,10 @@ export function showThreadUndoNotice(options: UndoOptions) {
expiry = setTimeout(() => {
const expired = liveUndos;
liveUndos = [];
for (const { claim } of expired) claim.finish();
for (const { claim, commit } of expired) {
claim.finish();
commit?.();
}
refreshNotice();
}, 5_000);
}
Loading
Loading