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
22 changes: 14 additions & 8 deletions apps/code/src/main/services/git/create-pr-saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,21 @@ export class CreatePrSaga extends Saga<CreatePrSagaInput, CreatePrSagaOutput> {
let { commitMessage, prTitle, prBody } = input;

if (input.branchName) {
const branchName = input.branchName;
const currentBranch = await this.readOnlyStep("get-original-branch", () =>
this.deps.getCurrentBranch(directoryPath),
);

// on retry, do not attempt to re-create the branch
if (currentBranch !== input.branchName) {
if (currentBranch !== branchName) {
this.deps.onProgress(
"creating-branch",
`Creating branch ${input.branchName}...`,
`Creating branch ${branchName}...`,
);

await this.step({
name: "creating-branch",
execute: () =>
this.deps.createBranch(directoryPath, input.branchName!),
execute: () => this.deps.createBranch(directoryPath, branchName),
rollback: async () => {
if (currentBranch) {
await this.deps.checkoutBranch(directoryPath, currentBranch);
Expand Down Expand Up @@ -120,6 +120,8 @@ export class CreatePrSaga extends Saga<CreatePrSagaInput, CreatePrSagaOutput> {
throw new Error("Commit message is required.");
}

const finalCommitMessage = commitMessage;

this.deps.onProgress("committing", "Committing changes...");

const preCommitSha = await this.readOnlyStep("get-pre-commit-sha", () =>
Expand All @@ -129,10 +131,14 @@ export class CreatePrSaga extends Saga<CreatePrSagaInput, CreatePrSagaOutput> {
await this.step({
name: "committing",
execute: async () => {
const result = await this.deps.commit(directoryPath, commitMessage!, {
stagedOnly: input.stagedOnly,
taskId: input.taskId,
});
const result = await this.deps.commit(
directoryPath,
finalCommitMessage,
{
stagedOnly: input.stagedOnly,
taskId: input.taskId,
},
);
if (!result.success) throw new Error(result.message);
return result;
},
Expand Down
188 changes: 116 additions & 72 deletions apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import { Box, Flex, ScrollArea } from "@radix-ui/themes";
import type { SignalReportsQueryParams } from "@shared/types";
import { useNavigationStore } from "@stores/navigationStore";
import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { MultiSelectStack } from "./detail/MultiSelectStack";
import { ReportDetailPane } from "./detail/ReportDetailPane";
import { ReportListPane } from "./list/ReportListPane";
import { SignalsToolbar } from "./list/SignalsToolbar";
Expand Down Expand Up @@ -134,39 +135,78 @@ export function InboxSignalsTab() {
[allReports],
);

// ── Selection state ─────────────────────────────────────────────────────
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
// ── Selection state (unified — store is single source of truth) ─────────
const selectedReportIds = useInboxReportSelectionStore(
(s) => s.selectedReportIds ?? [],
(s) => s.selectedReportIds,
);
const setSelectedReportIds = useInboxReportSelectionStore(
(s) => s.setSelectedReportIds,
);
const toggleReportSelection = useInboxReportSelectionStore(
(s) => s.toggleReportSelection,
);
const selectRange = useInboxReportSelectionStore((s) => s.selectRange);
const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection);
const clearSelection = useInboxReportSelectionStore((s) => s.clearSelection);

useEffect(() => {
if (reports.length === 0) {
setSelectedReportId(null);
return;
}
if (!selectedReportId) {
return;
}
const selectedExists = reports.some(
(report) => report.id === selectedReportId,
);
if (!selectedExists) {
setSelectedReportId(null);
}
}, [reports, selectedReportId]);
// Stable refs so callbacks don't need re-registration on every render
const selectedReportIdsRef = useRef(selectedReportIds);
selectedReportIdsRef.current = selectedReportIds;
const reportsRef = useRef(reports);
reportsRef.current = reports;

// Prune selection when visible reports change (e.g. filter/search)
useEffect(() => {
pruneSelection(reports.map((report) => report.id));
}, [reports, pruneSelection]);

const selectedReport = useMemo(
() => reports.find((report) => report.id === selectedReportId) ?? null,
[reports, selectedReportId],
// The report to show in the detail pane (only when exactly 1 is selected)
const selectedReport = useMemo(() => {
if (selectedReportIds.length !== 1) return null;
return reports.find((r) => r.id === selectedReportIds[0]) ?? null;
}, [reports, selectedReportIds]);

// Reports for the multi-select stack (when 2+ selected)
const selectedReports = useMemo(() => {
if (selectedReportIds.length < 2) return [];
const idSet = new Set(selectedReportIds);
return reports.filter((r) => idSet.has(r.id));
}, [reports, selectedReportIds]);

// ── Click handler: plain / cmd / shift ──────────────────────────────────
const handleReportClick = useCallback(
(reportId: string, event: { metaKey: boolean; shiftKey: boolean }) => {
if (event.shiftKey) {
selectRange(
reportId,
reportsRef.current.map((r) => r.id),
);
} else if (event.metaKey) {
toggleReportSelection(reportId);
} else if (
selectedReportIdsRef.current.length === 1 &&
selectedReportIdsRef.current[0] === reportId
) {
// Plain click on the only selected report — deselect it
clearSelection();
} else {
// Plain click — select only this report
setSelectedReportIds([reportId]);
}
},
[selectRange, toggleReportSelection, setSelectedReportIds, clearSelection],
);

// Select-all checkbox
const handleToggleSelectAll = useCallback(
(checked: boolean) => {
if (checked) {
setSelectedReportIds(reportsRef.current.map((r) => r.id));
} else {
clearSelection();
}
},
[setSelectedReportIds, clearSelection],
);

// ── Sidebar resize ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -237,10 +277,6 @@ export function InboxSignalsTab() {
const showTwoPaneLayout = hasMountedTwoPaneRef.current;

// ── Arrow-key navigation between reports ──────────────────────────────
const reportsRef = useRef(reports);
reportsRef.current = reports;
const selectedReportIdRef = useRef(selectedReportId);
selectedReportIdRef.current = selectedReportId;
const leftPaneRef = useRef<HTMLDivElement>(null);

const focusListPane = useCallback(() => {
Expand All @@ -252,41 +288,46 @@ export function InboxSignalsTab() {
// Auto-focus the list pane when the two-pane layout appears
useEffect(() => {
if (showTwoPaneLayout) {
// Small delay to ensure the ref is mounted after conditional render
focusListPane();
}
}, [focusListPane, showTwoPaneLayout]);

const navigateReport = useCallback((direction: 1 | -1) => {
const list = reportsRef.current;
if (list.length === 0) return;

const currentId = selectedReportIdRef.current;
const currentIndex = currentId
? list.findIndex((r) => r.id === currentId)
: -1;
const nextIndex =
currentIndex === -1
? 0
: Math.max(0, Math.min(list.length - 1, currentIndex + direction));
const nextId = list[nextIndex].id;

setSelectedReportId(nextId);

const container = leftPaneRef.current;
const row = container?.querySelector<HTMLElement>(
`[data-report-id="${nextId}"]`,
);
const stickyHeader = container?.querySelector<HTMLElement>(
"[data-inbox-sticky-header]",
);

if (!row) return;

const stickyHeaderHeight = stickyHeader?.offsetHeight ?? 0;
row.style.scrollMarginTop = `${stickyHeaderHeight}px`;
row.scrollIntoView({ block: "nearest" });
}, []);
const navigateReport = useCallback(
(direction: 1 | -1) => {
const list = reportsRef.current;
if (list.length === 0) return;

// Find the current position based on the last selected report
const currentIds = selectedReportIdsRef.current;
const currentId =
currentIds.length > 0 ? currentIds[currentIds.length - 1] : null;
const currentIndex = currentId
? list.findIndex((r) => r.id === currentId)
: -1;
const nextIndex =
currentIndex === -1
? 0
: Math.max(0, Math.min(list.length - 1, currentIndex + direction));
const nextId = list[nextIndex].id;

setSelectedReportIds([nextId]);

const container = leftPaneRef.current;
const row = container?.querySelector<HTMLElement>(
`[data-report-id="${nextId}"]`,
);
const stickyHeader = container?.querySelector<HTMLElement>(
"[data-inbox-sticky-header]",
);

if (!row) return;

const stickyHeaderHeight = stickyHeader?.offsetHeight ?? 0;
row.style.scrollMarginTop = `${stickyHeaderHeight}px`;
row.scrollIntoView({ block: "nearest" });
},
[setSelectedReportIds],
);

// Window-level keyboard handler so arrow keys work regardless of which
// pane has focus — only suppressed inside interactive widgets.
Expand All @@ -310,14 +351,17 @@ export function InboxSignalsTab() {
} else if (e.key === "ArrowUp") {
e.preventDefault();
navigateReport(-1);
} else if (e.key === " " && selectedReportIdRef.current) {
} else if (
e.key === "Escape" &&
selectedReportIdsRef.current.length > 0
) {
e.preventDefault();
toggleReportSelection(selectedReportIdRef.current);
clearSelection();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [navigateReport, toggleReportSelection]);
}, [navigateReport, clearSelection]);

const searchDisabledReason =
!hasReports && !searchQuery.trim()
Expand Down Expand Up @@ -366,11 +410,7 @@ export function InboxSignalsTab() {
) {
return;
}
if (
target.closest(
"[data-report-id], button, [role='checkbox']",
)
) {
if (target.closest("[data-report-id], button")) {
focusListPane();
}
}}
Expand All @@ -387,9 +427,7 @@ export function InboxSignalsTab() {
}
if (
target !== leftPaneRef.current &&
target.closest(
"[data-report-id], button, [role='checkbox']",
)
target.closest("[data-report-id], button")
) {
focusListPane();
}
Expand All @@ -413,6 +451,8 @@ export function InboxSignalsTab() {
processingCount={processingCount}
pipelinePausedUntil={signalProcessingState?.paused_until}
reports={reports}
effectiveBulkIds={selectedReportIds}
onToggleSelectAll={handleToggleSelectAll}
/>
</Box>
<ReportListPane
Expand All @@ -428,9 +468,8 @@ export function InboxSignalsTab() {
hasSignalSources={hasSignalSources}
searchQuery={searchQuery}
hasActiveFilters={hasActiveFilters}
selectedReportId={selectedReportId}
selectedReportIds={selectedReportIds}
onSelectReport={setSelectedReportId}
onReportClick={handleReportClick}
onToggleReportSelection={toggleReportSelection}
/>
</Flex>
Expand Down Expand Up @@ -463,10 +502,15 @@ export function InboxSignalsTab() {
position: "relative",
}}
>
{selectedReport ? (
{selectedReports.length > 1 ? (
<MultiSelectStack
reports={selectedReports}
onClearSelection={clearSelection}
/>
) : selectedReport ? (
<ReportDetailPane
report={selectedReport}
onClose={() => setSelectedReportId(null)}
onClose={clearSelection}
/>
) : (
<SelectReportPane />
Expand Down
Loading
Loading