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
65 changes: 60 additions & 5 deletions apps/code/src/renderer/features/command/components/CommandMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { useReviewNavigationStore } from "@features/code-review/stores/reviewNav
import { CommandKeyHints } from "@features/command/components/CommandKeyHints";
import { useFolders } from "@features/folders/hooks/useFolders";
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
import { TaskIcon } from "@features/sidebar/components/items/TaskIcon";
import { useTaskPrStatus } from "@features/sidebar/hooks/useTaskPrStatus";
import { useSidebarStore } from "@features/sidebar/stores/sidebarStore";
import { useTasks } from "@features/tasks/hooks/useTasks";
import {
Autocomplete,
AutocompleteCollection,
Expand All @@ -24,6 +27,7 @@ import {
SunIcon,
ViewVerticalIcon,
} from "@radix-ui/react-icons";
import type { Task } from "@shared/types";
import {
ANALYTICS_EVENTS,
type CommandMenuAction,
Expand All @@ -49,8 +53,28 @@ type Command = {

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

/**
* Task icon for the command palette. Renders the same shared `TaskIcon` as
* the sidebar — cloud run status, PR/branch status, etc. — deriving its
* inputs from the raw task and a per-task PR-status query.
*/
function TaskCommandIcon({ task }: { task: Task }) {
const { prState, hasDiff } = useTaskPrStatus({
id: task.id,
cloudPrUrl: null,
});
return (
<TaskIcon
workspaceMode={task.latest_run?.environment}
taskRunStatus={task.latest_run?.status}
prState={prState}
hasDiff={hasDiff}
/>
);
}

export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const { navigateToTaskInput } = useNavigationStore();
const { navigateToTaskInput, navigateToTask } = useNavigationStore();
const openSettingsDialog = useSettingsDialogStore((state) => state.open);
const closeSettingsDialog = useSettingsDialogStore((state) => state.close);
const { folders } = useFolders();
Expand All @@ -63,6 +87,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const getReviewMode = useReviewNavigationStore(
(state) => state.getReviewMode,
);
const { data: tasks = [] } = useTasks();
const [query, setQuery] = useState("");
const [systemPrefersDark, setSystemPrefersDark] = useState(
() => window.matchMedia("(prefers-color-scheme: dark)").matches,
Expand Down Expand Up @@ -131,7 +156,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
return options;
}, [theme, setTheme, systemPrefersDark]);

const sections = useMemo<CommandSection[]>(() => {
const commandSections = useMemo<CommandSection[]>(() => {
const navigation: Command[] = [
{
id: "home",
Expand Down Expand Up @@ -214,6 +239,31 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
openReviewPanel,
]);

const taskSections = useMemo<CommandSection[]>(() => {
if (tasks.length === 0) return [];
return [
{
label: "Tasks",
items: tasks.map((task) => ({
id: `task-${task.id}`,
label: task.title,
icon: <TaskCommandIcon task={task} />,
action: "open-task" as CommandMenuAction,
onRun: () => {
closeSettingsDialog();
navigateToTask(task);
},
})),
},
];
Comment thread
adamleithp marked this conversation as resolved.
}, [tasks, navigateToTask, closeSettingsDialog]);

// Commands and tasks share a single filterable list.
const sections = useMemo(
() => [...commandSections, ...taskSections],
[commandSections, taskSections],
);

const allCommands = useMemo(
() => sections.flatMap((s) => s.items),
[sections],
Expand Down Expand Up @@ -254,14 +304,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
}}
>
<AutocompleteInput
placeholder="Type a command…"
placeholder="Search commands and tasks…"
autoFocus
showClear
/>
<AutocompleteStatus
emptyContent={
<span>
No commands match <strong>"{query}"</strong>
No results for <strong>"{query}"</strong>
</span>
}
/>
Expand All @@ -275,9 +325,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
key={cmd.id}
value={cmd.id}
onClick={() => handleSelect(cmd.id)}
// Long task names wrap instead of truncating, so the
// item must grow: min-height, not a fixed height.
className="h-auto! min-h-7 py-1.5 text-left"
>
{cmd.icon}
{cmd.label}
<span className="wrap-break-word min-w-0 whitespace-normal">
{cmd.label}
</span>
</AutocompleteItem>
)}
</AutocompleteCollection>
Expand Down
90 changes: 47 additions & 43 deletions apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Tooltip } from "@components/ui/Tooltip";
import { Button, cn } from "@posthog/quill";
import { useCallback, useRef, useState } from "react";
import { useRef, useState } from "react";
import type { SidebarItemAction } from "../types";

const INDENT_SIZE = 8;
Expand All @@ -22,6 +22,42 @@ interface SidebarItemProps {
disabled?: boolean;
}

/**
* Label that truncates with an ellipsis and reveals the full text in a
* tooltip on hover when it's actually clipped. Truncation is scoped to this
* span so sibling content (e.g. `endContent`) is never hidden.
*/
function SidebarItemLabel({ label }: { label: React.ReactNode }) {
const ref = useRef<HTMLSpanElement>(null);
const [showTooltip, setShowTooltip] = useState(false);
const canTooltip = typeof label === "string" || typeof label === "number";

const span = (
// biome-ignore lint/a11y/noStaticElementInteractions: hover handlers only drive a tooltip for truncated labels
<span
ref={ref}
className="min-w-0 flex-1 truncate"
onMouseEnter={() => {
const el = ref.current;
if (canTooltip && el && el.scrollWidth > el.clientWidth) {
setShowTooltip(true);
}
}}
onMouseLeave={() => setShowTooltip(false)}
>
{label}
</span>
);

if (!canTooltip) return span;

return (
<Tooltip content={label} open={showTooltip} side="top">
{span}
</Tooltip>
);
}

export function SidebarItem({
depth,
icon,
Expand All @@ -36,46 +72,20 @@ export function SidebarItem({
endContent,
disabled,
}: SidebarItemProps) {
const labelRef = useRef<HTMLSpanElement>(null);
const [showLabelTooltip, setShowLabelTooltip] = useState(false);
const canShowLabelTooltip =
typeof label === "string" || typeof label === "number";

const handleLabelMouseEnter = useCallback(() => {
const el = labelRef.current;
if (el && el.scrollWidth > el.clientWidth) {
setShowLabelTooltip(true);
}
}, []);

const handleLabelMouseLeave = useCallback(() => {
setShowLabelTooltip(false);
}, []);

const labelSpan = (
// biome-ignore lint/a11y/noStaticElementInteractions: hover handlers only drive a visual tooltip for truncated labels
<span
ref={labelRef}
className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
onMouseEnter={canShowLabelTooltip ? handleLabelMouseEnter : undefined}
onMouseLeave={canShowLabelTooltip ? handleLabelMouseLeave : undefined}
>
{label}
</span>
);

return (
<Button
type="button"
className={cn(
"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",
"cursor-default disabled:opacity-100 data-active:bg-fill-selected",
"group flex w-full cursor-default text-left text-[13px] leading-snug transition-colors",
"focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-accent-8",
"disabled:opacity-100 data-active:bg-fill-selected",
)}
data-active={isActive || undefined}
draggable={draggable}
onDragStart={onDragStart}
style={{
paddingLeft: `${depth * INDENT_SIZE + 8 + (depth > 0 ? 4 : 0)}px`,
paddingRight: "8px",
}}
onClick={onClick}
onDoubleClick={onDoubleClick}
Expand All @@ -87,22 +97,16 @@ export function SidebarItem({
{icon}
</span>
) : null}
<span className="flex min-w-0 flex-1 flex-col overflow-hidden">
<span className="flex h-[18px] items-center gap-1">
{canShowLabelTooltip ? (
<Tooltip content={label} open={showLabelTooltip} side="top">
{labelSpan}
</Tooltip>
) : (
labelSpan
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-h-[18px] items-center gap-1">
<SidebarItemLabel label={label} />
{endContent}
</span>
{subtitle && (
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-gray-10 group-data-active:text-gray-11">
{subtitle ? (
<span className="truncate text-gray-10 group-data-active:text-gray-11">
{subtitle}
</span>
)}
) : null}
</span>
</Button>
);
Expand Down
11 changes: 11 additions & 0 deletions apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ScrollArea, Separator } from "@posthog/quill";
import { Box, Flex } from "@radix-ui/themes";
import type { Schemas } from "@renderer/api/generated";
import type { Task } from "@shared/types";
import { useCommandMenuStore } from "@stores/commandMenuStore";
import { useNavigationStore } from "@stores/navigationStore";
import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore";
import { useQueryClient } from "@tanstack/react-query";
Expand All @@ -33,6 +34,7 @@ import { useSidebarStore } from "../stores/sidebarStore";
import { CommandCenterItem } from "./items/CommandCenterItem";
import { InboxItem, NewTaskItem } from "./items/HomeItem";
import { McpServersItem } from "./items/McpServersItem";
import { SearchItem } from "./items/SearchItem";
import { SetupItem } from "./items/SetupItem";
import { SkillsItem } from "./items/SkillsItem";
import { SidebarItem } from "./SidebarItem";
Expand Down Expand Up @@ -145,6 +147,11 @@ function SidebarMenuComponent() {
navigateToSetup();
};

const openCommandMenu = useCommandMenuStore((s) => s.open);
const handleSearchClick = () => {
openCommandMenu();
};

const handleTaskClick = (taskId: string) => {
const task = taskMap.get(taskId);
if (task) {
Expand Down Expand Up @@ -325,6 +332,10 @@ function SidebarMenuComponent() {
</Box>
)}

<Box>
<SearchItem onClick={handleSearchClick} />
</Box>

<Box>
<InboxItem
isActive={sidebarData.isInboxActive}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useMeQuery } from "@hooks/useMeQuery";
import {
FunnelSimple as FunnelSimpleIcon,
GitBranch,
MagnifyingGlass,
} from "@phosphor-icons/react";
import {
Button,
Expand All @@ -21,6 +22,7 @@ import { Flex, Text } from "@radix-ui/themes";
import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png";
import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace";
import { normalizeRepoKey } from "@shared/utils/repo";
import { useCommandMenuStore } from "@stores/commandMenuStore";
import { useNavigationStore } from "@stores/navigationStore";
import { getRelativeDateGroup } from "@utils/time";
import { motion } from "framer-motion";
Expand Down Expand Up @@ -132,6 +134,20 @@ function TaskRow({
);
}

function TaskSearchButton() {
const openCommandMenu = useCommandMenuStore((state) => state.open);
return (
<Button
type="button"
aria-label="Search tasks"
size="icon-sm"
onClick={() => openCommandMenu()}
>
<MagnifyingGlass size={14} />
</Button>
);
}

function TaskFilterMenu() {
const organizeMode = useSidebarStore((state) => state.organizeMode);
const sortMode = useSidebarStore((state) => state.sortMode);
Expand Down Expand Up @@ -320,7 +336,15 @@ export function TaskListView({
</>
)}

<SectionLabel label="Tasks" endContent={<TaskFilterMenu />} />
<SectionLabel
label="Tasks"
endContent={
<span className="flex items-center">
<TaskSearchButton />
<TaskFilterMenu />
</span>
}
/>

{pinnedTasks.length === 0 &&
flatTasks.length === 0 &&
Expand Down
Loading
Loading