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
87 changes: 86 additions & 1 deletion apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
createThreadJumpHintVisibilityController,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
getFallbackThreadIdAfterDelete,
Expand All @@ -15,6 +16,7 @@ import {
shouldClearThreadSelectionOnMouseDown,
sortProjectsForSidebar,
sortThreadsForSidebar,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
} from "./Sidebar.logic";
import { ProjectId, ThreadId } from "@t3tools/contracts";
import {
Expand Down Expand Up @@ -52,6 +54,68 @@ describe("hasUnseenCompletion", () => {
});
});

describe("createThreadJumpHintVisibilityController", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("delays showing jump hints until the configured delay elapses", () => {
const visibilityChanges: boolean[] = [];
const controller = createThreadJumpHintVisibilityController({
delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
onVisibilityChange: (visible) => {
visibilityChanges.push(visible);
},
});

controller.sync(true);
vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS - 1);

expect(visibilityChanges).toEqual([]);

vi.advanceTimersByTime(1);

expect(visibilityChanges).toEqual([true]);
});

it("hides immediately when the modifiers are released", () => {
const visibilityChanges: boolean[] = [];
const controller = createThreadJumpHintVisibilityController({
delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
onVisibilityChange: (visible) => {
visibilityChanges.push(visible);
},
});

controller.sync(true);
vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS);
controller.sync(false);

expect(visibilityChanges).toEqual([true, false]);
});

it("cancels a pending reveal when the modifier is released early", () => {
const visibilityChanges: boolean[] = [];
const controller = createThreadJumpHintVisibilityController({
delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
onVisibilityChange: (visible) => {
visibilityChanges.push(visible);
},
});

controller.sync(true);
vi.advanceTimersByTime(Math.floor(THREAD_JUMP_HINT_SHOW_DELAY_MS / 2));
controller.sync(false);
vi.advanceTimersByTime(THREAD_JUMP_HINT_SHOW_DELAY_MS);

expect(visibilityChanges).toEqual([]);
});
});

describe("shouldClearThreadSelectionOnMouseDown", () => {
it("preserves selection for thread items", () => {
const child = {
Expand Down Expand Up @@ -171,6 +235,27 @@ describe("getVisibleSidebarThreadIds", () => {
ThreadId.makeUnsafe("thread-6"),
]);
});

it("skips threads from collapsed projects whose thread panels are not shown", () => {
expect(
getVisibleSidebarThreadIds([
{
shouldShowThreadPanel: false,
renderedThreads: [
{ id: ThreadId.makeUnsafe("thread-hidden-2") },
{ id: ThreadId.makeUnsafe("thread-hidden-1") },
],
},
{
shouldShowThreadPanel: true,
renderedThreads: [
{ id: ThreadId.makeUnsafe("thread-12") },
{ id: ThreadId.makeUnsafe("thread-11") },
],
},
]),
).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]);
});
});

describe("isContextMenuPointerDown", () => {
Expand Down
92 changes: 91 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from "react";
import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import type { Thread } from "../types";
import { cn } from "../lib/utils";
Expand All @@ -8,6 +9,7 @@ import {
} 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 type SidebarNewThreadEnvMode = "local" | "worktree";
type SidebarProject = {
id: string;
Expand Down Expand Up @@ -46,6 +48,91 @@ type ThreadStatusInput = Pick<
"interactionMode" | "latestTurn" | "lastVisitedAt" | "proposedPlans" | "session"
>;

export interface ThreadJumpHintVisibilityController {
sync: (shouldShow: boolean) => void;
dispose: () => void;
}

export function createThreadJumpHintVisibilityController(input: {
delayMs: number;
onVisibilityChange: (visible: boolean) => void;
setTimeoutFn?: typeof globalThis.setTimeout;
clearTimeoutFn?: typeof globalThis.clearTimeout;
}): ThreadJumpHintVisibilityController {
const setTimeoutFn = input.setTimeoutFn ?? globalThis.setTimeout;
const clearTimeoutFn = input.clearTimeoutFn ?? globalThis.clearTimeout;
let isVisible = false;
let timeoutId: NodeJS.Timeout | null = null;

const clearPendingShow = () => {
if (timeoutId === null) {
return;
}
clearTimeoutFn(timeoutId);
timeoutId = null;
};

return {
sync: (shouldShow) => {
if (!shouldShow) {
clearPendingShow();
if (isVisible) {
isVisible = false;
input.onVisibilityChange(false);
}
return;
}

if (isVisible || timeoutId !== null) {
return;
}

timeoutId = setTimeoutFn(() => {
timeoutId = null;
isVisible = true;
input.onVisibilityChange(true);
}, input.delayMs);
},
dispose: () => {
clearPendingShow();
},
};
}

export function useThreadJumpHintVisibility(): {
showThreadJumpHints: boolean;
updateThreadJumpHintsVisibility: (shouldShow: boolean) => void;
} {
const [showThreadJumpHints, setShowThreadJumpHints] = React.useState(false);
const controllerRef = React.useRef<ThreadJumpHintVisibilityController | null>(null);

React.useEffect(() => {
const controller = createThreadJumpHintVisibilityController({
delayMs: THREAD_JUMP_HINT_SHOW_DELAY_MS,
onVisibilityChange: (visible) => {
setShowThreadJumpHints(visible);
},
setTimeoutFn: window.setTimeout.bind(window),
clearTimeoutFn: window.clearTimeout.bind(window),
});
controllerRef.current = controller;

return () => {
controller.dispose();
controllerRef.current = null;
};
}, []);

const updateThreadJumpHintsVisibility = React.useCallback((shouldShow: boolean) => {
controllerRef.current?.sync(shouldShow);
}, []);

return {
showThreadJumpHints,
updateThreadJumpHintsVisibility,
};
}

export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
Expand All @@ -71,13 +158,16 @@ export function resolveSidebarNewThreadEnvMode(input: {

export function getVisibleSidebarThreadIds<TThreadId>(
renderedProjects: readonly {
shouldShowThreadPanel?: boolean;
renderedThreads: readonly {
id: TThreadId;
}[];
}[],
): TThreadId[] {
return renderedProjects.flatMap((renderedProject) =>
renderedProject.renderedThreads.map((thread) => thread.id),
renderedProject.shouldShowThreadPanel === false
? []
: renderedProject.renderedThreads.map((thread) => thread.id),
);
}

Expand Down
37 changes: 18 additions & 19 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
import { useComposerDraftStore } from "../composerDraftStore";
import { useHandleNewThread } from "../hooks/useHandleNewThread";

import { useThreadActions } from "../hooks/useThreadActions";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { toastManager } from "./ui/toast";
Expand Down Expand Up @@ -116,6 +117,7 @@ import {
shouldClearThreadSelectionOnMouseDown,
sortProjectsForSidebar,
sortThreadsForSidebar,
useThreadJumpHintVisibility,
} from "./Sidebar.logic";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
Expand Down Expand Up @@ -361,7 +363,7 @@ export default function Sidebar() {
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet<ProjectId>
>(() => new Set());
const [showThreadJumpHints, setShowThreadJumpHints] = useState(false);
const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
const renamingCommittedRef = useRef(false);
const renamingInputRef = useRef<HTMLInputElement | null>(null);
const dragInProgressRef = useRef(false);
Expand Down Expand Up @@ -1070,23 +1072,22 @@ export default function Sidebar() {
visibleThreads,
],
);
const visibleSidebarThreadIds = useMemo(
() => getVisibleSidebarThreadIds(renderedProjects),
[renderedProjects],
);
const threadJumpCommandById = useMemo(() => {
const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>();
let visibleThreadIndex = 0;

for (const renderedProject of renderedProjects) {
for (const thread of renderedProject.renderedThreads) {
const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
if (!jumpCommand) {
return mapping;
}
mapping.set(thread.id, jumpCommand);
visibleThreadIndex += 1;
for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) {
const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex);
if (!jumpCommand) {
return mapping;
}
mapping.set(threadId, jumpCommand);
}

return mapping;
}, [renderedProjects]);
}, [visibleSidebarThreadIds]);
const threadJumpThreadIds = useMemo(
() => [...threadJumpCommandById.keys()],
[threadJumpCommandById],
Expand All @@ -1101,10 +1102,7 @@ export default function Sidebar() {
}
return mapping;
}, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]);
const orderedSidebarThreadIds = useMemo(
() => getVisibleSidebarThreadIds(renderedProjects),
[renderedProjects],
);
const orderedSidebarThreadIds = visibleSidebarThreadIds;

useEffect(() => {
const getShortcutContext = () => ({
Expand All @@ -1113,7 +1111,7 @@ export default function Sidebar() {
});

const onWindowKeyDown = (event: KeyboardEvent) => {
setShowThreadJumpHints(
updateThreadJumpHintsVisibility(
shouldShowThreadJumpHints(event, keybindings, {
platform,
context: getShortcutContext(),
Expand Down Expand Up @@ -1161,7 +1159,7 @@ export default function Sidebar() {
};

const onWindowKeyUp = (event: KeyboardEvent) => {
setShowThreadJumpHints(
updateThreadJumpHintsVisibility(
shouldShowThreadJumpHints(event, keybindings, {
platform,
context: getShortcutContext(),
Expand All @@ -1170,7 +1168,7 @@ export default function Sidebar() {
};

const onWindowBlur = () => {
setShowThreadJumpHints(false);
updateThreadJumpHintsVisibility(false);
};

window.addEventListener("keydown", onWindowKeyDown);
Expand All @@ -1190,6 +1188,7 @@ export default function Sidebar() {
routeTerminalOpen,
routeThreadId,
threadJumpThreadIds,
updateThreadJumpHintsVisibility,
]);

function renderProjectItem(
Expand Down
Loading