Skip to content

Commit bd96700

Browse files
posthog[bot]PostHog Codeadamleithpclaude
authored
feat(sidebar): scoped command palette for task search (#2175)
Co-authored-by: PostHog Code <code@posthog.com> Co-authored-by: Adam Leith <adamleithp@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ae05339 commit bd96700

11 files changed

Lines changed: 418 additions & 215 deletions

File tree

apps/code/src/renderer/features/command/components/CommandMenu.tsx

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { useReviewNavigationStore } from "@features/code-review/stores/reviewNav
22
import { CommandKeyHints } from "@features/command/components/CommandKeyHints";
33
import { useFolders } from "@features/folders/hooks/useFolders";
44
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
5+
import { TaskIcon } from "@features/sidebar/components/items/TaskIcon";
6+
import { useTaskPrStatus } from "@features/sidebar/hooks/useTaskPrStatus";
57
import { useSidebarStore } from "@features/sidebar/stores/sidebarStore";
8+
import { useTasks } from "@features/tasks/hooks/useTasks";
69
import {
710
Autocomplete,
811
AutocompleteCollection,
@@ -24,6 +27,7 @@ import {
2427
SunIcon,
2528
ViewVerticalIcon,
2629
} from "@radix-ui/react-icons";
30+
import type { Task } from "@shared/types";
2731
import {
2832
ANALYTICS_EVENTS,
2933
type CommandMenuAction,
@@ -49,8 +53,28 @@ type Command = {
4953

5054
type CommandSection = { label: string; items: Command[] };
5155

56+
/**
57+
* Task icon for the command palette. Renders the same shared `TaskIcon` as
58+
* the sidebar — cloud run status, PR/branch status, etc. — deriving its
59+
* inputs from the raw task and a per-task PR-status query.
60+
*/
61+
function TaskCommandIcon({ task }: { task: Task }) {
62+
const { prState, hasDiff } = useTaskPrStatus({
63+
id: task.id,
64+
cloudPrUrl: null,
65+
});
66+
return (
67+
<TaskIcon
68+
workspaceMode={task.latest_run?.environment}
69+
taskRunStatus={task.latest_run?.status}
70+
prState={prState}
71+
hasDiff={hasDiff}
72+
/>
73+
);
74+
}
75+
5276
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
53-
const { navigateToTaskInput } = useNavigationStore();
77+
const { navigateToTaskInput, navigateToTask } = useNavigationStore();
5478
const openSettingsDialog = useSettingsDialogStore((state) => state.open);
5579
const closeSettingsDialog = useSettingsDialogStore((state) => state.close);
5680
const { folders } = useFolders();
@@ -63,6 +87,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
6387
const getReviewMode = useReviewNavigationStore(
6488
(state) => state.getReviewMode,
6589
);
90+
const { data: tasks = [] } = useTasks();
6691
const [query, setQuery] = useState("");
6792
const [systemPrefersDark, setSystemPrefersDark] = useState(
6893
() => window.matchMedia("(prefers-color-scheme: dark)").matches,
@@ -131,7 +156,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
131156
return options;
132157
}, [theme, setTheme, systemPrefersDark]);
133158

134-
const sections = useMemo<CommandSection[]>(() => {
159+
const commandSections = useMemo<CommandSection[]>(() => {
135160
const navigation: Command[] = [
136161
{
137162
id: "home",
@@ -214,6 +239,31 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
214239
openReviewPanel,
215240
]);
216241

242+
const taskSections = useMemo<CommandSection[]>(() => {
243+
if (tasks.length === 0) return [];
244+
return [
245+
{
246+
label: "Tasks",
247+
items: tasks.map((task) => ({
248+
id: `task-${task.id}`,
249+
label: task.title,
250+
icon: <TaskCommandIcon task={task} />,
251+
action: "open-task" as CommandMenuAction,
252+
onRun: () => {
253+
closeSettingsDialog();
254+
navigateToTask(task);
255+
},
256+
})),
257+
},
258+
];
259+
}, [tasks, navigateToTask, closeSettingsDialog]);
260+
261+
// Commands and tasks share a single filterable list.
262+
const sections = useMemo(
263+
() => [...commandSections, ...taskSections],
264+
[commandSections, taskSections],
265+
);
266+
217267
const allCommands = useMemo(
218268
() => sections.flatMap((s) => s.items),
219269
[sections],
@@ -254,14 +304,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
254304
}}
255305
>
256306
<AutocompleteInput
257-
placeholder="Type a command…"
307+
placeholder="Search commands and tasks…"
258308
autoFocus
259309
showClear
260310
/>
261311
<AutocompleteStatus
262312
emptyContent={
263313
<span>
264-
No commands match <strong>"{query}"</strong>
314+
No results for <strong>"{query}"</strong>
265315
</span>
266316
}
267317
/>
@@ -275,9 +325,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
275325
key={cmd.id}
276326
value={cmd.id}
277327
onClick={() => handleSelect(cmd.id)}
328+
// Long task names wrap instead of truncating, so the
329+
// item must grow: min-height, not a fixed height.
330+
className="h-auto! min-h-7 py-1.5 text-left"
278331
>
279332
{cmd.icon}
280-
{cmd.label}
333+
<span className="wrap-break-word min-w-0 whitespace-normal">
334+
{cmd.label}
335+
</span>
281336
</AutocompleteItem>
282337
)}
283338
</AutocompleteCollection>

apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Tooltip } from "@components/ui/Tooltip";
22
import { Button, cn } from "@posthog/quill";
3-
import { useCallback, useRef, useState } from "react";
3+
import { useRef, useState } from "react";
44
import type { SidebarItemAction } from "../types";
55

66
const INDENT_SIZE = 8;
@@ -22,6 +22,42 @@ interface SidebarItemProps {
2222
disabled?: boolean;
2323
}
2424

25+
/**
26+
* Label that truncates with an ellipsis and reveals the full text in a
27+
* tooltip on hover when it's actually clipped. Truncation is scoped to this
28+
* span so sibling content (e.g. `endContent`) is never hidden.
29+
*/
30+
function SidebarItemLabel({ label }: { label: React.ReactNode }) {
31+
const ref = useRef<HTMLSpanElement>(null);
32+
const [showTooltip, setShowTooltip] = useState(false);
33+
const canTooltip = typeof label === "string" || typeof label === "number";
34+
35+
const span = (
36+
// biome-ignore lint/a11y/noStaticElementInteractions: hover handlers only drive a tooltip for truncated labels
37+
<span
38+
ref={ref}
39+
className="min-w-0 flex-1 truncate"
40+
onMouseEnter={() => {
41+
const el = ref.current;
42+
if (canTooltip && el && el.scrollWidth > el.clientWidth) {
43+
setShowTooltip(true);
44+
}
45+
}}
46+
onMouseLeave={() => setShowTooltip(false)}
47+
>
48+
{label}
49+
</span>
50+
);
51+
52+
if (!canTooltip) return span;
53+
54+
return (
55+
<Tooltip content={label} open={showTooltip} side="top">
56+
{span}
57+
</Tooltip>
58+
);
59+
}
60+
2561
export function SidebarItem({
2662
depth,
2763
icon,
@@ -36,46 +72,20 @@ export function SidebarItem({
3672
endContent,
3773
disabled,
3874
}: SidebarItemProps) {
39-
const labelRef = useRef<HTMLSpanElement>(null);
40-
const [showLabelTooltip, setShowLabelTooltip] = useState(false);
41-
const canShowLabelTooltip =
42-
typeof label === "string" || typeof label === "number";
43-
44-
const handleLabelMouseEnter = useCallback(() => {
45-
const el = labelRef.current;
46-
if (el && el.scrollWidth > el.clientWidth) {
47-
setShowLabelTooltip(true);
48-
}
49-
}, []);
50-
51-
const handleLabelMouseLeave = useCallback(() => {
52-
setShowLabelTooltip(false);
53-
}, []);
54-
55-
const labelSpan = (
56-
// biome-ignore lint/a11y/noStaticElementInteractions: hover handlers only drive a visual tooltip for truncated labels
57-
<span
58-
ref={labelRef}
59-
className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
60-
onMouseEnter={canShowLabelTooltip ? handleLabelMouseEnter : undefined}
61-
onMouseLeave={canShowLabelTooltip ? handleLabelMouseLeave : undefined}
62-
>
63-
{label}
64-
</span>
65-
);
66-
6775
return (
6876
<Button
6977
type="button"
7078
className={cn(
71-
"group focus-visible:-outline-offset-2 flex w-full text-left text-[13px] leading-snug transition-colors focus-visible:outline-2 focus-visible:outline-accent-8",
72-
"cursor-default disabled:opacity-100 data-active:bg-fill-selected",
79+
"group flex w-full cursor-default text-left text-[13px] leading-snug transition-colors",
80+
"focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-accent-8",
81+
"disabled:opacity-100 data-active:bg-fill-selected",
7382
)}
7483
data-active={isActive || undefined}
7584
draggable={draggable}
7685
onDragStart={onDragStart}
7786
style={{
7887
paddingLeft: `${depth * INDENT_SIZE + 8 + (depth > 0 ? 4 : 0)}px`,
88+
paddingRight: "8px",
7989
}}
8090
onClick={onClick}
8191
onDoubleClick={onDoubleClick}
@@ -87,22 +97,16 @@ export function SidebarItem({
8797
{icon}
8898
</span>
8999
) : null}
90-
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
91-
<span className="flex h-[18px] items-center gap-1">
92-
{canShowLabelTooltip ? (
93-
<Tooltip content={label} open={showLabelTooltip} side="top">
94-
{labelSpan}
95-
</Tooltip>
96-
) : (
97-
labelSpan
98-
)}
100+
<span className="flex min-w-0 flex-1 flex-col">
101+
<span className="flex min-h-[18px] items-center gap-1">
102+
<SidebarItemLabel label={label} />
99103
{endContent}
100104
</span>
101-
{subtitle && (
102-
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-gray-10 group-data-active:text-gray-11">
105+
{subtitle ? (
106+
<span className="truncate text-gray-10 group-data-active:text-gray-11">
103107
{subtitle}
104108
</span>
105-
)}
109+
) : null}
106110
</span>
107111
</Button>
108112
);

apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ScrollArea, Separator } from "@posthog/quill";
2020
import { Box, Flex } from "@radix-ui/themes";
2121
import type { Schemas } from "@renderer/api/generated";
2222
import type { Task } from "@shared/types";
23+
import { useCommandMenuStore } from "@stores/commandMenuStore";
2324
import { useNavigationStore } from "@stores/navigationStore";
2425
import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore";
2526
import { useQueryClient } from "@tanstack/react-query";
@@ -33,6 +34,7 @@ import { useSidebarStore } from "../stores/sidebarStore";
3334
import { CommandCenterItem } from "./items/CommandCenterItem";
3435
import { InboxItem, NewTaskItem } from "./items/HomeItem";
3536
import { McpServersItem } from "./items/McpServersItem";
37+
import { SearchItem } from "./items/SearchItem";
3638
import { SetupItem } from "./items/SetupItem";
3739
import { SkillsItem } from "./items/SkillsItem";
3840
import { SidebarItem } from "./SidebarItem";
@@ -145,6 +147,11 @@ function SidebarMenuComponent() {
145147
navigateToSetup();
146148
};
147149

150+
const openCommandMenu = useCommandMenuStore((s) => s.open);
151+
const handleSearchClick = () => {
152+
openCommandMenu();
153+
};
154+
148155
const handleTaskClick = (taskId: string) => {
149156
const task = taskMap.get(taskId);
150157
if (task) {
@@ -325,6 +332,10 @@ function SidebarMenuComponent() {
325332
</Box>
326333
)}
327334

335+
<Box>
336+
<SearchItem onClick={handleSearchClick} />
337+
</Box>
338+
328339
<Box>
329340
<InboxItem
330341
isActive={sidebarData.isInboxActive}

apps/code/src/renderer/features/sidebar/components/TaskListView.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useMeQuery } from "@hooks/useMeQuery";
66
import {
77
FunnelSimple as FunnelSimpleIcon,
88
GitBranch,
9+
MagnifyingGlass,
910
} from "@phosphor-icons/react";
1011
import {
1112
Button,
@@ -21,6 +22,7 @@ import { Flex, Text } from "@radix-ui/themes";
2122
import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png";
2223
import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace";
2324
import { normalizeRepoKey } from "@shared/utils/repo";
25+
import { useCommandMenuStore } from "@stores/commandMenuStore";
2426
import { useNavigationStore } from "@stores/navigationStore";
2527
import { getRelativeDateGroup } from "@utils/time";
2628
import { motion } from "framer-motion";
@@ -132,6 +134,20 @@ function TaskRow({
132134
);
133135
}
134136

137+
function TaskSearchButton() {
138+
const openCommandMenu = useCommandMenuStore((state) => state.open);
139+
return (
140+
<Button
141+
type="button"
142+
aria-label="Search tasks"
143+
size="icon-sm"
144+
onClick={() => openCommandMenu()}
145+
>
146+
<MagnifyingGlass size={14} />
147+
</Button>
148+
);
149+
}
150+
135151
function TaskFilterMenu() {
136152
const organizeMode = useSidebarStore((state) => state.organizeMode);
137153
const sortMode = useSidebarStore((state) => state.sortMode);
@@ -320,7 +336,15 @@ export function TaskListView({
320336
</>
321337
)}
322338

323-
<SectionLabel label="Tasks" endContent={<TaskFilterMenu />} />
339+
<SectionLabel
340+
label="Tasks"
341+
endContent={
342+
<span className="flex items-center">
343+
<TaskSearchButton />
344+
<TaskFilterMenu />
345+
</span>
346+
}
347+
/>
324348

325349
{pinnedTasks.length === 0 &&
326350
flatTasks.length === 0 &&

0 commit comments

Comments
 (0)