+ {checkpointDiffError && !renderablePatch && (
+
- ) : !isGitRepo ? (
-
- Turn diffs are unavailable because this project is not a git repository.
-
- ) : orderedTurnDiffSummaries.length === 0 ? (
-
- No completed turns yet.
-
- ) : (
- <>
-
- {checkpointDiffError && !renderablePatch && (
-
-
{checkpointDiffError}
-
- )}
- {!renderablePatch ? (
- isLoadingCheckpointDiff ? (
-
- ) : (
-
-
- {hasNoNetChanges
- ? "No net changes in this selection."
- : "No patch available for this selection."}
-
-
- )
- ) : renderablePatch.kind === "files" ? (
-
+ ) : (
+
+
+ {hasNoNetChanges
+ ? "No net changes in this selection."
+ : "No patch available for this selection."}
+
+
+ )
+ ) : renderablePatch.kind === "files" ? (
+
+ {renderableFiles.map((fileDiff) => {
+ const filePath = resolveFileDiffPath(fileDiff);
+ const fileKey = buildFileDiffRenderKey(fileDiff);
+ const themedFileKey = `${fileKey}:${resolvedTheme}`;
+ return (
+ {
+ const nativeEvent = event.nativeEvent as MouseEvent;
+ const composedPath = nativeEvent.composedPath?.() ?? [];
+ const clickedHeader = composedPath.some((node) => {
+ if (!(node instanceof Element)) return false;
+ return node.hasAttribute("data-title");
+ });
+ if (!clickedHeader) return;
+ openDiffFileInEditor(filePath);
}}
>
- {renderableFiles.map((fileDiff) => {
- const filePath = resolveFileDiffPath(fileDiff);
- const fileKey = buildFileDiffRenderKey(fileDiff);
- const themedFileKey = `${fileKey}:${resolvedTheme}`;
- return (
-
{
- const nativeEvent = event.nativeEvent as MouseEvent;
- const composedPath = nativeEvent.composedPath?.() ?? [];
- const clickedHeader = composedPath.some((node) => {
- if (!(node instanceof Element)) return false;
- return node.hasAttribute("data-title");
- });
- if (!clickedHeader) return;
- openDiffFileInEditor(filePath);
- }}
- >
-
-
- );
- })}
-
- ) : (
-
-
-
{renderablePatch.reason}
-
- {renderablePatch.text}
-
-
+
- )}
+ );
+ })}
+
+ ) : (
+
+
+
{renderablePatch.reason}
+
+ {renderablePatch.text}
+
- >
+
)}
+
+ );
+});
+
+export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
+ const navigate = useNavigate();
+ const settings = useSettings();
+ const [diffRenderMode, setDiffRenderMode] = useState("stacked");
+ const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
+ const previousDiffOpenRef = useRef(false);
+ const routeThreadRef = useParams({
+ strict: false,
+ select: (params) => resolveThreadRouteRef(params),
+ });
+ const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
+ const diffOpen = diffSearch.diff === "1";
+ const activeThreadId = routeThreadRef?.threadId ?? null;
+ const workspaceSnapshot = useStore(
+ useMemo(() => createThreadBranchToolbarSnapshotSelectorByRef(routeThreadRef), [routeThreadRef]),
+ );
+ const turnDiffSummaries = useStore(
+ useMemo(() => createThreadTurnDiffSummariesSelectorByRef(routeThreadRef), [routeThreadRef]),
+ );
+ const { inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(turnDiffSummaries);
+ const orderedTurnDiffSummaries = useMemo(
+ () =>
+ [...turnDiffSummaries].toSorted((left, right) => {
+ const leftTurnCount =
+ left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0;
+ const rightTurnCount =
+ right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0;
+ if (leftTurnCount !== rightTurnCount) {
+ return rightTurnCount - leftTurnCount;
+ }
+ return right.completedAt.localeCompare(left.completedAt);
+ }),
+ [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],
+ );
+
+ const selectedTurnId = diffSearch.diffTurnId ?? null;
+ const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;
+
+ useEffect(() => {
+ if (diffOpen && !previousDiffOpenRef.current) {
+ setDiffWordWrap(settings.diffWordWrap);
+ }
+ previousDiffOpenRef.current = diffOpen;
+ }, [diffOpen, settings.diffWordWrap]);
+
+ const selectTurn = useCallback(
+ (turnId: TurnId) => {
+ if (!routeThreadRef) return;
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(
+ scopeThreadRef(routeThreadRef.environmentId, routeThreadRef.threadId),
+ ),
+ search: (previous) => {
+ const rest = stripDiffSearchParams(previous);
+ return { ...rest, diff: "1", diffTurnId: turnId };
+ },
+ });
+ },
+ [navigate, routeThreadRef],
+ );
+ const selectWholeConversation = useCallback(() => {
+ if (!routeThreadRef) return;
+ void navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(
+ scopeThreadRef(routeThreadRef.environmentId, routeThreadRef.threadId),
+ ),
+ search: (previous) => {
+ const rest = stripDiffSearchParams(previous);
+ return { ...rest, diff: "1" };
+ },
+ });
+ }, [navigate, routeThreadRef]);
+
+ return (
+
+ }
+ >
+
);
}
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 8c742cbe9b..c056e1bb08 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -11,7 +11,7 @@ import { cn } from "../lib/utils";
import { isLatestTurnSettled } from "../session-logic";
export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
-export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
+export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 75;
export type SidebarNewThreadEnvMode = "local" | "worktree";
type SidebarProject = {
id: string;
@@ -20,6 +20,12 @@ type SidebarProject = {
updatedAt?: string | undefined;
};
+type SidebarThreadSortInput = Pick & {
+ projectId: string;
+ latestUserMessageAt?: string | null;
+ messages?: Pick[];
+};
+
export type ThreadTraversalDirection = "previous" | "next";
export interface ThreadStatusPill {
@@ -46,16 +52,28 @@ const THREAD_STATUS_PRIORITY: Record = {
type ThreadStatusInput = Pick<
SidebarThreadSummary,
- | "hasActionableProposedPlan"
- | "hasPendingApprovals"
- | "hasPendingUserInput"
- | "interactionMode"
- | "latestTurn"
- | "session"
+ "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode"
> & {
+ latestTurn: ThreadStatusLatestTurnSnapshot | null;
+ session: ThreadStatusSessionSnapshot | null;
lastVisitedAt?: string | undefined;
};
+type LatestTurnSnapshot = NonNullable;
+type SessionSnapshot = NonNullable;
+
+export interface ThreadStatusLatestTurnSnapshot {
+ turnId: LatestTurnSnapshot["turnId"];
+ startedAt: LatestTurnSnapshot["startedAt"];
+ completedAt: LatestTurnSnapshot["completedAt"];
+}
+
+export interface ThreadStatusSessionSnapshot {
+ orchestrationStatus: SessionSnapshot["orchestrationStatus"];
+ activeTurnId?: SessionSnapshot["activeTurnId"];
+ status: SessionSnapshot["status"];
+}
+
export interface ThreadJumpHintVisibilityController {
sync: (shouldShow: boolean) => void;
dispose: () => void;
@@ -469,6 +487,14 @@ export function getFallbackThreadIdAfterDelete<
)[0]?.id ?? null
);
}
+
+export function sortThreadsForSidebar & ThreadSortInput>(
+ threads: readonly TThread[],
+ sortOrder: SidebarThreadSortOrder,
+): TThread[] {
+ return sortThreads(threads, sortOrder);
+}
+
export function getProjectSortTimestamp(
project: SidebarProject,
projectThreads: readonly ThreadSortInput[],
@@ -489,7 +515,7 @@ export function getProjectSortTimestamp(
export function sortProjectsForSidebar<
TProject extends SidebarProject,
- TThread extends Pick & ThreadSortInput,
+ TThread extends SidebarThreadSortInput,
>(
projects: readonly TProject[],
threads: readonly TThread[],
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 03ae979017..ca59d30a73 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -36,19 +36,19 @@ import {
type DesktopUpdateState,
type EnvironmentId,
ProjectId,
- type ScopedProjectRef,
type ScopedThreadRef,
type ThreadEnvMode,
ThreadId,
type GitStatusResult,
} from "@t3tools/contracts";
import {
+ parseScopedThreadKey,
scopedProjectKey,
scopedThreadKey,
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime";
-import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
+import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router";
import {
type SidebarProjectSortOrder,
type SidebarThreadSortOrder,
@@ -56,38 +56,26 @@ import {
import { usePrimaryEnvironmentId } from "../environments/primary";
import { isElectron } from "../env";
import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
-import { isTerminalFocused } from "../lib/terminalFocus";
import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
import {
selectProjectByRef,
selectProjectsAcrossEnvironments,
- selectSidebarThreadsForProjectRef,
+ selectSidebarThreadSummaryByRef,
selectSidebarThreadsForProjectRefs,
- selectSidebarThreadsAcrossEnvironments,
+ selectThreadIdsByProjectRef,
selectThreadByRef,
useStore,
} from "../store";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { useUiStateStore } from "../uiStateStore";
-import {
- resolveShortcutCommand,
- shortcutLabelForCommand,
- shouldShowThreadJumpHints,
- threadJumpCommandForIndex,
- threadJumpIndexFromCommand,
- threadTraversalDirectionFromCommand,
-} from "../keybindings";
+import { shortcutLabelForCommand } from "../keybindings";
import { useGitStatus } from "../lib/gitStatusState";
import { readLocalApi } from "../localApi";
import { useComposerDraftStore } from "../composerDraftStore";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
import { useThreadActions } from "../hooks/useThreadActions";
-import {
- buildThreadRouteParams,
- resolveThreadRouteRef,
- resolveThreadRouteTarget,
-} from "../threadRoutes";
+import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
import { toastManager } from "./ui/toast";
import { formatRelativeTimeLabel } from "../timestampFormat";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
@@ -122,7 +110,6 @@ import {
import { useThreadSelectionStore } from "../threadSelectionStore";
import { isNonEmpty as isNonEmptyString } from "effect/String";
import {
- resolveAdjacentThreadId,
isContextMenuPointerDown,
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
@@ -130,25 +117,48 @@ import {
resolveThreadRowClassName,
resolveThreadStatusPill,
orderItemsByPreferredIds,
- shouldClearThreadSelectionOnMouseDown,
- sortProjectsForSidebar,
- useThreadJumpHintVisibility,
+ sortThreadsForSidebar,
ThreadStatusPill,
} from "./Sidebar.logic";
-import { sortThreads } from "../lib/threadSort";
+import {
+ createSidebarSortedProjectKeysSelector,
+ createSidebarProjectRenderStateSelector,
+ createSidebarProjectThreadStatusInputsSelector,
+ createSidebarThreadMetaSnapshotSelectorByRef,
+ createSidebarThreadRowSnapshotSelectorByRef,
+ createSidebarThreadStatusInputSelectorByRef,
+ type ProjectThreadStatusInput,
+} from "./sidebar/sidebarSelectors";
+import { THREAD_PREVIEW_LIMIT } from "./sidebar/sidebarConstants";
+import {
+ SidebarKeyboardController,
+ SidebarSelectionController,
+} from "./sidebar/sidebarControllers";
+import {
+ collapseSidebarProjectThreadList,
+ expandSidebarProjectThreadList,
+ resetSidebarViewState,
+ useSidebarIsActiveThread,
+ useSidebarProjectActiveRouteThreadKey,
+ useSidebarProjectThreadListExpanded,
+ useSidebarThreadJumpLabel,
+} from "./sidebar/sidebarViewStore";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
+import {
+ buildSidebarPhysicalToLogicalKeyMap,
+ buildSidebarProjectSnapshots,
+ type SidebarProjectSnapshot,
+} from "./sidebar/sidebarProjectSnapshots";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { CommandDialogTrigger } from "./ui/command";
import { readEnvironmentApi } from "../environmentApi";
import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
import { useServerKeybindings } from "../rpc/serverState";
-import { deriveLogicalProjectKey } from "../logicalProject";
import {
useSavedEnvironmentRegistryStore,
useSavedEnvironmentRuntimeStore,
} from "../environments/runtime";
-import type { Project, SidebarThreadSummary } from "../types";
-const THREAD_PREVIEW_LIMIT = 6;
+import type { LogicalProjectKey } from "../logicalProject";
const SIDEBAR_SORT_LABELS: Record = {
updated_at: "Last user message",
created_at: "Created at",
@@ -162,65 +172,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
duration: 180,
easing: "ease-out",
} as const;
-const EMPTY_THREAD_JUMP_LABELS = new Map();
-
-function threadJumpLabelMapsEqual(
- left: ReadonlyMap,
- right: ReadonlyMap,
-): boolean {
- if (left === right) {
- return true;
- }
- if (left.size !== right.size) {
- return false;
- }
- for (const [key, value] of left) {
- if (right.get(key) !== value) {
- return false;
- }
- }
- return true;
-}
-
-function buildThreadJumpLabelMap(input: {
- keybindings: ReturnType;
- platform: string;
- terminalOpen: boolean;
- threadJumpCommandByKey: ReadonlyMap<
- string,
- NonNullable>
- >;
-}): ReadonlyMap {
- if (input.threadJumpCommandByKey.size === 0) {
- return EMPTY_THREAD_JUMP_LABELS;
- }
-
- const shortcutLabelOptions = {
- platform: input.platform,
- context: {
- terminalFocus: false,
- terminalOpen: input.terminalOpen,
- },
- } as const;
- const mapping = new Map();
- for (const [threadKey, command] of input.threadJumpCommandByKey) {
- const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions);
- if (label) {
- mapping.set(threadKey, label);
- }
- }
- return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
-}
-type EnvironmentPresence = "local-only" | "remote-only" | "mixed";
-
-type SidebarProjectSnapshot = Project & {
- projectKey: string;
- environmentPresence: EnvironmentPresence;
- memberProjectRefs: readonly ScopedProjectRef[];
- /** Labels for remote environments this project lives in. */
- remoteEnvironmentLabels: readonly string[];
-};
interface TerminalStatusIndicator {
label: "Terminal process running";
colorClass: string;
@@ -236,6 +188,14 @@ interface PrStatusIndicator {
type ThreadPr = GitStatusResult["pr"];
+function useSidebarThreadStatusInput(
+ threadRef: ScopedThreadRef | null,
+): ProjectThreadStatusInput | undefined {
+ return useStore(
+ useMemo(() => createSidebarThreadStatusInputSelectorByRef(threadRef), [threadRef]),
+ );
+}
+
function ThreadStatusLabel({
status,
compact = false,
@@ -328,77 +288,301 @@ function resolveThreadPr(
return gitStatus.pr ?? null;
}
-interface SidebarThreadRowProps {
- thread: SidebarThreadSummary;
- projectCwd: string | null;
- orderedProjectThreadKeys: readonly string[];
- isActive: boolean;
- jumpLabel: string | null;
+const SidebarThreadMetaCluster = memo(function SidebarThreadMetaCluster(props: {
appSettingsConfirmThreadArchive: boolean;
- renamingThreadKey: string | null;
- renamingTitle: string;
- setRenamingTitle: (title: string) => void;
- renamingInputRef: React.RefObject;
- renamingCommittedRef: React.RefObject;
- confirmingArchiveThreadKey: string | null;
- setConfirmingArchiveThreadKey: React.Dispatch>;
- confirmArchiveButtonRefs: React.RefObject
+ }
+ />
+
Archive
+
+ );
+ }, [
+ appSettingsConfirmThreadArchive,
+ confirmArchiveButtonRef,
+ handleArchiveImmediateClick,
+ handleConfirmArchiveClick,
+ handleStartArchiveConfirmation,
+ isConfirmingArchiveVisible,
+ isThreadRunning,
+ stopPropagationOnPointerDown,
+ threadId,
+ threadTitle,
+ ]);
+
+ return (
+ <>
+ {archiveControl}
+
+
+ {isRemoteThread && (
+
+
+ }
+ >
+
+
+ {threadEnvironmentLabel}
+
+ )}
+ {jumpLabel ? (
+
+ {jumpLabel}
+
+ ) : relativeTimestamp ? (
+
+ {relativeTimestamp}
+
+ ) : null}
+
+
+ >
+ );
+});
+
+const SidebarThreadStatusIndicator = memo(function SidebarThreadStatusIndicator(props: {
+ threadKey: string;
+ threadRef: ScopedThreadRef;
+}) {
+ const { threadKey, threadRef } = props;
+ const statusInput = useSidebarThreadStatusInput(threadRef);
const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
+ const threadStatus = useMemo(
+ () =>
+ statusInput
+ ? resolveThreadStatusPill({
+ thread: {
+ ...statusInput,
+ lastVisitedAt,
+ },
+ })
+ : null,
+ [lastVisitedAt, statusInput],
+ );
+
+ return threadStatus ?
: null;
+});
+
+const SidebarThreadTerminalStatusIndicator = memo(
+ function SidebarThreadTerminalStatusIndicator(props: { threadRef: ScopedThreadRef }) {
+ const { threadRef } = props;
+ const runningTerminalIds = useTerminalStateStore(
+ (state) =>
+ selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,
+ );
+ const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
+
+ return terminalStatus ? (
+
+
+
+ ) : null;
+ },
+);
+
+interface SidebarThreadRowProps {
+ threadKey: string;
+ project: SidebarProjectSnapshot;
+}
+
+const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
+ const { threadKey, project } = props;
+ const threadRef = useMemo(() => parseScopedThreadKey(threadKey), [threadKey]);
+ const threadSortOrder = useSettings
(
+ (settings) => settings.sidebarThreadSortOrder,
+ );
+ const appSettingsConfirmThreadDelete = useSettings(
+ (settings) => settings.confirmThreadDelete,
+ );
+ const appSettingsConfirmThreadArchive = useSettings(
+ (settings) => settings.confirmThreadArchive,
+ );
+ const router = useRouter();
+ const { archiveThread, deleteThread } = useThreadActions();
+ const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
+ const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread);
+ const rangeSelectTo = useThreadSelectionStore((state) => state.rangeSelectTo);
+ const clearSelection = useThreadSelectionStore((state) => state.clearSelection);
+ const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection);
+ const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor);
+ const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
+ threadId: ThreadId;
+ }>({
+ onCopy: (ctx) => {
+ toastManager.add({
+ type: "success",
+ title: "Thread ID copied",
+ description: ctx.threadId,
+ });
+ },
+ onError: (error) => {
+ toastManager.add({
+ type: "error",
+ title: "Failed to copy thread ID",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ },
+ });
+ const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{
+ path: string;
+ }>({
+ onCopy: (ctx) => {
+ toastManager.add({
+ type: "success",
+ title: "Path copied",
+ description: ctx.path,
+ });
+ },
+ onError: (error) => {
+ toastManager.add({
+ type: "error",
+ title: "Failed to copy path",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ },
+ });
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [renamingTitle, setRenamingTitle] = useState("");
+ const [isConfirmingArchive, setIsConfirmingArchive] = useState(false);
+ const renamingCommittedRef = useRef(false);
+ const renamingInputRef = useRef(null);
+ const confirmArchiveButtonRef = useRef(null);
+ if (!threadRef) {
+ return null;
+ }
+ const thread = useStore(
+ useMemo(() => createSidebarThreadRowSnapshotSelectorByRef(threadRef), [threadRef]),
+ );
+ const isActive = useSidebarIsActiveThread(threadKey);
+ if (!thread) {
+ return null;
+ }
const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0);
- const runningTerminalIds = useTerminalStateStore(
- (state) =>
- selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,
- );
const primaryEnvironmentId = usePrimaryEnvironmentId();
const isRemoteThread =
primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
@@ -422,32 +606,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
[thread.environmentId, thread.projectId],
),
);
- const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;
+ const gitCwd = thread.worktreePath ?? threadProjectCwd ?? project?.cwd ?? null;
const gitStatus = useGitStatus({
environmentId: thread.environmentId,
cwd: thread.branch != null ? gitCwd : null,
});
const isHighlighted = isActive || isSelected;
- const isThreadRunning =
- thread.session?.status === "running" && thread.session.activeTurnId != null;
- const threadStatus = resolveThreadStatusPill({
- thread: {
- ...thread,
- lastVisitedAt,
- },
- });
const pr = resolveThreadPr(thread.branch, gitStatus.data);
const prStatus = prStatusIndicator(pr);
- const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
- const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
- const threadMetaClassName = isConfirmingArchive
- ? "pointer-events-none opacity-0"
- : !isThreadRunning
- ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
- : "pointer-events-none";
const clearConfirmingArchive = useCallback(() => {
- setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current));
- }, [setConfirmingArchiveThreadKey, threadKey]);
+ setIsConfirmingArchive(false);
+ }, []);
const handleMouseLeave = useCallback(() => {
clearConfirmingArchive();
}, [clearConfirmingArchive]);
@@ -463,104 +632,351 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
},
[clearConfirmingArchive],
);
- const handleRowClick = useCallback(
- (event: React.MouseEvent) => {
- handleThreadClick(event, threadRef, orderedProjectThreadKeys);
- },
- [handleThreadClick, orderedProjectThreadKeys, threadRef],
- );
- const handleRowKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- navigateToThread(threadRef);
- },
- [navigateToThread, threadRef],
- );
- const handleRowContextMenu = useCallback(
- (event: React.MouseEvent) => {
- event.preventDefault();
- if (hasSelection && isSelected) {
- void handleMultiSelectContextMenu({
- x: event.clientX,
- y: event.clientY,
- });
- return;
- }
+ const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => {
+ event.preventDefault();
+ event.stopPropagation();
- if (hasSelection) {
+ const api = readLocalApi();
+ if (!api) {
+ toastManager.add({
+ type: "error",
+ title: "Link opening is unavailable.",
+ });
+ return;
+ }
+
+ void api.shell.openExternal(prUrl).catch((error) => {
+ toastManager.add({
+ type: "error",
+ title: "Unable to open PR link",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ });
+ }, []);
+ const navigateToThread = useCallback(
+ (targetThreadRef: ScopedThreadRef) => {
+ if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
clearSelection();
}
- void handleThreadContextMenu(threadRef, {
- x: event.clientX,
- y: event.clientY,
+ setSelectionAnchor(scopedThreadKey(targetThreadRef));
+ void router.navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(targetThreadRef),
});
},
- [
- clearSelection,
- handleMultiSelectContextMenu,
- handleThreadContextMenu,
- hasSelection,
- isSelected,
- threadRef,
- ],
- );
- const handlePrClick = useCallback(
- (event: React.MouseEvent) => {
- if (!prStatus) return;
- openPrLink(event, prStatus.url);
- },
- [openPrLink, prStatus],
- );
- const handleRenameInputRef = useCallback(
- (element: HTMLInputElement | null) => {
- if (element && renamingInputRef.current !== element) {
- renamingInputRef.current = element;
- element.focus();
- element.select();
- }
- },
- [renamingInputRef],
- );
- const handleRenameInputChange = useCallback(
- (event: React.ChangeEvent) => {
- setRenamingTitle(event.target.value);
- },
- [setRenamingTitle],
+ [clearSelection, router, setSelectionAnchor],
);
- const handleRenameInputKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
- event.stopPropagation();
- if (event.key === "Enter") {
- event.preventDefault();
- renamingCommittedRef.current = true;
- void commitRename(threadRef, renamingTitle, thread.title);
- } else if (event.key === "Escape") {
- event.preventDefault();
+ const attemptArchiveThread = useCallback(async () => {
+ try {
+ await archiveThread(threadRef);
+ } catch (error) {
+ toastManager.add({
+ type: "error",
+ title: "Failed to archive thread",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ }
+ }, [archiveThread, threadRef]);
+ const cancelRename = useCallback(() => {
+ setIsRenaming(false);
+ setRenamingTitle("");
+ renamingCommittedRef.current = false;
+ renamingInputRef.current = null;
+ }, []);
+ const commitRename = useCallback(async () => {
+ const finishRename = () => {
+ setIsRenaming(false);
+ renamingCommittedRef.current = false;
+ renamingInputRef.current = null;
+ };
+
+ const trimmed = renamingTitle.trim();
+ if (trimmed.length === 0) {
+ toastManager.add({
+ type: "warning",
+ title: "Thread title cannot be empty",
+ });
+ finishRename();
+ return;
+ }
+ if (trimmed === thread.title) {
+ finishRename();
+ return;
+ }
+ const api = readEnvironmentApi(threadRef.environmentId);
+ if (!api) {
+ finishRename();
+ return;
+ }
+ try {
+ await api.orchestration.dispatchCommand({
+ type: "thread.meta.update",
+ commandId: newCommandId(),
+ threadId: threadRef.threadId,
+ title: trimmed,
+ });
+ } catch (error) {
+ toastManager.add({
+ type: "error",
+ title: "Failed to rename thread",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ }
+ finishRename();
+ }, [renamingTitle, thread.title, threadRef]);
+ const handleMultiSelectContextMenu = useCallback(
+ async (position: { x: number; y: number }) => {
+ const api = readLocalApi();
+ if (!api) return;
+ const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys];
+ if (threadKeys.length === 0) return;
+ const count = threadKeys.length;
+
+ const clicked = await api.contextMenu.show(
+ [
+ { id: "mark-unread", label: `Mark unread (${count})` },
+ { id: "delete", label: `Delete (${count})`, destructive: true },
+ ],
+ position,
+ );
+
+ if (clicked === "mark-unread") {
+ const appState = useStore.getState();
+ for (const selectedThreadKey of threadKeys) {
+ const selectedThreadRef = parseScopedThreadKey(selectedThreadKey);
+ if (!selectedThreadRef) continue;
+ const selectedThread = selectSidebarThreadSummaryByRef(appState, selectedThreadRef);
+ markThreadUnread(selectedThreadKey, selectedThread?.latestTurn?.completedAt);
+ }
+ clearSelection();
+ return;
+ }
+
+ if (clicked !== "delete") return;
+
+ if (appSettingsConfirmThreadDelete) {
+ const confirmed = await api.dialogs.confirm(
+ [
+ `Delete ${count} thread${count === 1 ? "" : "s"}?`,
+ "This permanently clears conversation history for these threads.",
+ ].join("\n"),
+ );
+ if (!confirmed) return;
+ }
+
+ const deletedThreadKeys = new Set(threadKeys);
+ for (const selectedThreadKey of threadKeys) {
+ const selectedThreadRef = parseScopedThreadKey(selectedThreadKey);
+ if (!selectedThreadRef) continue;
+ await deleteThread(selectedThreadRef, { deletedThreadKeys });
+ }
+ removeFromSelection(threadKeys);
+ },
+ [
+ appSettingsConfirmThreadDelete,
+ clearSelection,
+ deleteThread,
+ markThreadUnread,
+ removeFromSelection,
+ ],
+ );
+ const handleThreadContextMenu = useCallback(
+ async (position: { x: number; y: number }) => {
+ const api = readLocalApi();
+ if (!api) return;
+ const threadWorkspacePath = thread.worktreePath ?? project?.cwd ?? null;
+ const currentThreadSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef);
+ const clicked = await api.contextMenu.show(
+ [
+ { id: "rename", label: "Rename thread" },
+ { id: "mark-unread", label: "Mark unread" },
+ { id: "copy-path", label: "Copy Path" },
+ { id: "copy-thread-id", label: "Copy Thread ID" },
+ { id: "delete", label: "Delete", destructive: true },
+ ],
+ position,
+ );
+
+ if (clicked === "rename") {
+ setIsRenaming(true);
+ setRenamingTitle(thread.title);
+ renamingCommittedRef.current = false;
+ return;
+ }
+
+ if (clicked === "mark-unread") {
+ markThreadUnread(threadKey, currentThreadSummary?.latestTurn?.completedAt);
+ return;
+ }
+ if (clicked === "copy-path") {
+ if (!threadWorkspacePath) {
+ toastManager.add({
+ type: "error",
+ title: "Path unavailable",
+ description: "This thread does not have a workspace path to copy.",
+ });
+ return;
+ }
+ copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath });
+ return;
+ }
+ if (clicked === "copy-thread-id") {
+ copyThreadIdToClipboard(thread.id, { threadId: thread.id });
+ return;
+ }
+ if (clicked !== "delete") return;
+ if (appSettingsConfirmThreadDelete) {
+ const confirmed = await api.dialogs.confirm(
+ [
+ `Delete thread "${thread.title}"?`,
+ "This permanently clears conversation history for this thread.",
+ ].join("\n"),
+ );
+ if (!confirmed) {
+ return;
+ }
+ }
+ await deleteThread(threadRef);
+ },
+ [
+ appSettingsConfirmThreadDelete,
+ copyPathToClipboard,
+ copyThreadIdToClipboard,
+ deleteThread,
+ markThreadUnread,
+ project?.cwd,
+ thread,
+ threadKey,
+ threadRef,
+ ],
+ );
+ const handleRowClick = useCallback(
+ (event: React.MouseEvent) => {
+ const isMac = isMacPlatform(navigator.platform);
+ const isModClick = isMac ? event.metaKey : event.ctrlKey;
+ const isShiftClick = event.shiftKey;
+ const currentSelectionCount = useThreadSelectionStore.getState().selectedThreadKeys.size;
+
+ if (isModClick) {
+ event.preventDefault();
+ toggleThreadSelection(threadKey);
+ return;
+ }
+
+ if (isShiftClick) {
+ event.preventDefault();
+ const orderedProjectThreadKeys = project
+ ? sortThreadsForSidebar(
+ selectSidebarThreadsForProjectRefs(
+ useStore.getState(),
+ project.memberProjectRefs,
+ ).filter((projectThread) => projectThread.archivedAt === null),
+ threadSortOrder,
+ ).map((projectThread) =>
+ scopedThreadKey(scopeThreadRef(projectThread.environmentId, projectThread.id)),
+ )
+ : [threadKey];
+ rangeSelectTo(threadKey, orderedProjectThreadKeys);
+ return;
+ }
+
+ if (currentSelectionCount > 0) {
+ clearSelection();
+ }
+ navigateToThread(threadRef);
+ },
+ [
+ clearSelection,
+ navigateToThread,
+ project,
+ rangeSelectTo,
+ threadKey,
+ threadRef,
+ threadSortOrder,
+ toggleThreadSelection,
+ ],
+ );
+ const handleRowKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ navigateToThread(threadRef);
+ },
+ [navigateToThread, threadRef],
+ );
+ const handleRowContextMenu = useCallback(
+ (event: React.MouseEvent) => {
+ event.preventDefault();
+ if (hasSelection && isSelected) {
+ void handleMultiSelectContextMenu({
+ x: event.clientX,
+ y: event.clientY,
+ });
+ return;
+ }
+
+ if (hasSelection) {
+ clearSelection();
+ }
+ void handleThreadContextMenu({
+ x: event.clientX,
+ y: event.clientY,
+ });
+ },
+ [
+ clearSelection,
+ handleMultiSelectContextMenu,
+ handleThreadContextMenu,
+ hasSelection,
+ isSelected,
+ ],
+ );
+ const handlePrClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (!prStatus) return;
+ openPrLink(event, prStatus.url);
+ },
+ [openPrLink, prStatus],
+ );
+ const handleRenameInputRef = useCallback(
+ (element: HTMLInputElement | null) => {
+ if (element && renamingInputRef.current !== element) {
+ renamingInputRef.current = element;
+ element.focus();
+ element.select();
+ }
+ },
+ [renamingInputRef],
+ );
+ const handleRenameInputChange = useCallback(
+ (event: React.ChangeEvent) => {
+ setRenamingTitle(event.target.value);
+ },
+ [setRenamingTitle],
+ );
+ const handleRenameInputKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ renamingCommittedRef.current = true;
+ void commitRename();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
renamingCommittedRef.current = true;
cancelRename();
}
},
- [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef],
+ [cancelRename, commitRename, renamingCommittedRef],
);
const handleRenameInputBlur = useCallback(() => {
if (!renamingCommittedRef.current) {
- void commitRename(threadRef, renamingTitle, thread.title);
+ void commitRename();
}
- }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);
+ }, [commitRename, renamingCommittedRef]);
const handleRenameInputClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
- const handleConfirmArchiveRef = useCallback(
- (element: HTMLButtonElement | null) => {
- if (element) {
- confirmArchiveButtonRefs.current.set(threadKey, element);
- } else {
- confirmArchiveButtonRefs.current.delete(threadKey);
- }
- },
- [confirmArchiveButtonRefs, threadKey],
- );
const stopPropagationOnPointerDown = useCallback(
(event: React.PointerEvent) => {
event.stopPropagation();
@@ -572,28 +988,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
event.preventDefault();
event.stopPropagation();
clearConfirmingArchive();
- void attemptArchiveThread(threadRef);
+ void attemptArchiveThread();
},
- [attemptArchiveThread, clearConfirmingArchive, threadRef],
+ [attemptArchiveThread, clearConfirmingArchive],
);
const handleStartArchiveConfirmation = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
- setConfirmingArchiveThreadKey(threadKey);
+ setIsConfirmingArchive(true);
requestAnimationFrame(() => {
- confirmArchiveButtonRefs.current.get(threadKey)?.focus();
+ confirmArchiveButtonRef.current?.focus();
});
},
- [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey],
+ [],
);
const handleArchiveImmediateClick = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
- void attemptArchiveThread(threadRef);
+ void attemptArchiveThread();
},
- [attemptArchiveThread, threadRef],
+ [attemptArchiveThread],
);
const rowButtonRender = useMemo(() => , []);
@@ -635,8 +1051,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{prStatus.tooltip}
)}
- {threadStatus && }
- {renamingThreadKey === threadKey ? (
+
+ {isRenaming ? (
- {terminalStatus && (
-
-
-
- )}
+
- {isConfirmingArchive ? (
-
- ) : !isThreadRunning ? (
- appSettingsConfirmThreadArchive ? (
-
- ) : (
-
-
-
-
- }
- />
-
Archive
-
- )
- ) : null}
-
-
- {isRemoteThread && (
-
-
- }
- >
-
-
- {threadEnvironmentLabel}
-
- )}
- {jumpLabel ? (
-
- {jumpLabel}
-
- ) : (
-
- {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
-
- )}
-
-
+
@@ -758,89 +1093,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
});
interface SidebarProjectThreadListProps {
- projectKey: string;
+ project: SidebarProjectSnapshot;
projectExpanded: boolean;
hasOverflowingThreads: boolean;
- hiddenThreadStatus: ThreadStatusPill | null;
- orderedProjectThreadKeys: readonly string[];
- renderedThreads: readonly SidebarThreadSummary[];
+ hiddenThreadKeys: readonly string[];
+ renderedThreadKeys: readonly string[];
showEmptyThreadState: boolean;
shouldShowThreadPanel: boolean;
isThreadListExpanded: boolean;
- projectCwd: string;
- activeRouteThreadKey: string | null;
- threadJumpLabelByKey: ReadonlyMap