diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/apps/code/src/renderer/features/command/components/CommandMenu.tsx index 3fafe99f4..8551cd305 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/apps/code/src/renderer/features/command/components/CommandMenu.tsx @@ -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, @@ -24,6 +27,7 @@ import { SunIcon, ViewVerticalIcon, } from "@radix-ui/react-icons"; +import type { Task } from "@shared/types"; import { ANALYTICS_EVENTS, type CommandMenuAction, @@ -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 ( + + ); +} + 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(); @@ -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, @@ -131,7 +156,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return options; }, [theme, setTheme, systemPrefersDark]); - const sections = useMemo(() => { + const commandSections = useMemo(() => { const navigation: Command[] = [ { id: "home", @@ -214,6 +239,31 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { openReviewPanel, ]); + const taskSections = useMemo(() => { + if (tasks.length === 0) return []; + return [ + { + label: "Tasks", + items: tasks.map((task) => ({ + id: `task-${task.id}`, + label: task.title, + icon: , + action: "open-task" as CommandMenuAction, + onRun: () => { + closeSettingsDialog(); + navigateToTask(task); + }, + })), + }, + ]; + }, [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], @@ -254,14 +304,14 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }} > - No commands match "{query}" + No results for "{query}" } /> @@ -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} + + {cmd.label} + )} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx index 0a72aeeca..ace31838c 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx @@ -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; @@ -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(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 + { + const el = ref.current; + if (canTooltip && el && el.scrollWidth > el.clientWidth) { + setShowTooltip(true); + } + }} + onMouseLeave={() => setShowTooltip(false)} + > + {label} + + ); + + if (!canTooltip) return span; + + return ( + + {span} + + ); +} + export function SidebarItem({ depth, icon, @@ -36,46 +72,20 @@ export function SidebarItem({ endContent, disabled, }: SidebarItemProps) { - const labelRef = useRef(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 - - {label} - - ); - return ( ); diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 8edd66f80..eb11dbdf4 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -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"; @@ -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"; @@ -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) { @@ -325,6 +332,10 @@ function SidebarMenuComponent() { )} + + + + state.open); + return ( + + ); +} + function TaskFilterMenu() { const organizeMode = useSidebarStore((state) => state.organizeMode); const sortMode = useSidebarStore((state) => state.sortMode); @@ -320,7 +336,15 @@ export function TaskListView({ )} - } /> + + + + + } + /> {pinnedTasks.length === 0 && flatTasks.length === 0 && diff --git a/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx index 5fcef26b0..7ca165a1c 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx @@ -1,12 +1,9 @@ -import { Badge } from "@components/ui/Badge"; import { Tooltip } from "@components/ui/Tooltip"; import { EnvelopeSimple, Plus } from "@phosphor-icons/react"; -import type { ButtonProps } from "@posthog/quill"; -import { - formatHotkey, - SHORTCUTS, -} from "@renderer/constants/keyboard-shortcuts"; +import { Badge, type ButtonProps } from "@posthog/quill"; +import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { SidebarItem } from "../SidebarItem"; +import { SidebarKbdHint } from "./SidebarKbdHint"; interface NewTaskItemProps { isActive: boolean; @@ -22,6 +19,7 @@ export function NewTaskItem({ isActive, onClick }: NewTaskItemProps) { label="New task" isActive={isActive} onClick={onClick} + endContent={} /> ); } @@ -45,7 +43,6 @@ export function InboxItem({ isActive, onClick, signalCount }: InboxItemProps) { ? `${signalCount} actionable report${signalCount === 1 ? "" : "s"} assigned to you` : "No actionable reports assigned to you yet" } - shortcut={formatHotkey(SHORTCUTS.INBOX)} side="right" >
@@ -72,7 +69,12 @@ export function InboxItem({ isActive, onClick, signalCount }: InboxItemProps) { } isActive={isActive} onClick={onClick} - endContent={Alpha} + endContent={ + <> + Alpha + + + } />
diff --git a/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx new file mode 100644 index 000000000..99d68461b --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx @@ -0,0 +1,20 @@ +import { MagnifyingGlass } from "@phosphor-icons/react"; +import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; +import { SidebarItem } from "../SidebarItem"; +import { SidebarKbdHint } from "./SidebarKbdHint"; + +interface SearchItemProps { + onClick: () => void; +} + +export function SearchItem({ onClick }: SearchItemProps) { + return ( + } + label="Search" + onClick={onClick} + endContent={} + /> + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx b/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx new file mode 100644 index 000000000..3a751d2ae --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx @@ -0,0 +1,21 @@ +import { Kbd } from "@posthog/quill"; +import { formatHotkey } from "@renderer/constants/keyboard-shortcuts"; + +interface SidebarKbdHintProps { + /** Raw shortcut string from SHORTCUTS, e.g. "mod+k". */ + keys: string; +} + +/** + * Keyboard shortcut hint for a sidebar nav item. Hidden until the parent + * SidebarItem (which carries the `group` class) is hovered. Toggled via + * `display` so it takes no space when idle and preceding `endContent` sits + * flush to the edge — no transition, to match the rest of the sidebar. + */ +export function SidebarKbdHint({ keys }: SidebarKbdHintProps) { + return ( + + {formatHotkey(keys)} + + ); +} diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx new file mode 100644 index 000000000..2d8ef6a02 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx @@ -0,0 +1,204 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { Tooltip } from "@components/ui/Tooltip"; +import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus"; +import type { WorkspaceMode } from "@main/services/workspace/schemas"; +import { + ChatCircle, + Circle, + Cloud as CloudIcon, + GitBranch, + GitMerge, + GitPullRequest, + HandPalm, + Pause, + PushPin, +} from "@phosphor-icons/react"; +import { isTerminalStatus, type TaskRunStatus } from "@shared/types"; + +export const ICON_SIZE = 12; + +// Colors are passed as the phosphor `color` prop (an SVG `fill` attribute) +// rather than `text-*` classes: in the command palette, quill's +// `[data-highlighted] *` rule resets every descendant CSS `color` for the +// selected row, which turns a `currentColor` icon black on hover. An explicit +// `fill` is immune, and renders identically in the sidebar. + +function CloudStatusIcon({ taskRunStatus }: { taskRunStatus?: TaskRunStatus }) { + if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { + return ( + + + + + + ); + } + if (taskRunStatus === "completed") { + return ( + + + + + + ); + } + if (taskRunStatus === "failed" || taskRunStatus === "cancelled") { + const label = + taskRunStatus === "cancelled" ? "Cloud (cancelled)" : "Cloud (failed)"; + return ( + + + + + + ); + } + return ( + + + + + + ); +} + +function PrStatusIcon({ + prState, + hasDiff, +}: { + prState?: SidebarPrState; + hasDiff?: boolean; +}) { + if (prState === "merged") { + return ( + + + + + + ); + } + if (prState === "open") { + return ( + + + + + + ); + } + if (prState === "draft") { + return ( + + + + + + ); + } + if (prState === "closed") { + return ( + + + + + + ); + } + if (hasDiff) { + return ( + + + + + + ); + } + return null; +} + +export interface TaskIconProps { + workspaceMode?: WorkspaceMode; + isGenerating?: boolean; + isUnread?: boolean; + isPinned?: boolean; + isSuspended?: boolean; + needsPermission?: boolean; + taskRunStatus?: TaskRunStatus; + prState?: SidebarPrState; + hasDiff?: boolean; +} + +/** + * Status icon for a task, shared by the sidebar task list and the command + * palette so both render the exact same states (cloud run status, PR/branch + * status, generating, unread, etc.). + */ +export function TaskIcon({ + workspaceMode, + isGenerating, + isUnread, + isPinned, + isSuspended, + needsPermission, + taskRunStatus, + prState, + hasDiff, +}: TaskIconProps) { + const isCloudTask = workspaceMode === "cloud"; + const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus); + + if (needsPermission) { + return ( + + + + + + ); + } + if (isTerminalCloud) { + return ; + } + if (isGenerating) { + return ; + } + if (isCloudTask) { + return ; + } + if (isSuspended) { + return ( + + + + + + ); + } + if (isUnread) { + return ( + + + + ); + } + if (prState || hasDiff) { + return ; + } + if (isPinned) { + return ; + } + return ; +} diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index eb604baeb..16412e341 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -1,23 +1,12 @@ -import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; import { Tooltip } from "@components/ui/Tooltip"; import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; -import { - Archive, - ChatCircle, - Circle, - Cloud as CloudIcon, - GitBranch, - GitMerge, - GitPullRequest, - HandPalm, - Pause, - PushPin, -} from "@phosphor-icons/react"; -import { isTerminalStatus, type TaskRunStatus } from "@shared/types"; +import { Archive, PushPin } from "@phosphor-icons/react"; +import type { TaskRunStatus } from "@shared/types"; import { formatRelativeTimeShort } from "@utils/time"; import { useCallback, useEffect, useRef, useState } from "react"; import { SidebarItem } from "../SidebarItem"; +import { TaskIcon } from "./TaskIcon"; interface TaskItemProps { depth?: number; @@ -110,119 +99,8 @@ function TaskHoverToolbar({ ); } -const ICON_SIZE = 12; const INDENT_SIZE = 8; -function CloudStatusIcon({ - taskRunStatus, -}: { - taskRunStatus?: TaskItemProps["taskRunStatus"]; -}) { - if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { - return ( - - - - - - ); - } - if (taskRunStatus === "completed") { - return ( - - - - - - ); - } - if (taskRunStatus === "failed" || taskRunStatus === "cancelled") { - const label = - taskRunStatus === "cancelled" ? "Cloud (cancelled)" : "Cloud (failed)"; - return ( - - - - - - ); - } - return ( - - - - - - ); -} - -function PrStatusIcon({ - prState, - hasDiff, -}: { - prState?: SidebarPrState; - hasDiff?: boolean; -}) { - if (prState === "merged") { - return ( - - - - - - ); - } - if (prState === "open") { - return ( - - - - - - ); - } - if (prState === "draft") { - return ( - - - - - - ); - } - if (prState === "closed") { - return ( - - - - - - ); - } - if (hasDiff) { - return ( - - - - - - ); - } - return null; -} - export function TaskItem({ depth = 0, taskId, @@ -247,37 +125,18 @@ export function TaskItem({ onEditSubmit, onEditCancel, }: TaskItemProps) { - const isCloudTask = workspaceMode === "cloud"; - const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus); - - const icon = needsPermission ? ( - - - - - - ) : isTerminalCloud ? ( - - ) : isGenerating ? ( - - ) : isCloudTask ? ( - - ) : isSuspended ? ( - - - - - - ) : isUnread ? ( - - - - ) : prState || hasDiff ? ( - - ) : isPinned ? ( - - ) : ( - + const icon = ( + ); const timestampNode = timestamp ? ( diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index 22c7787f3..cf034e96c 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -12,7 +12,9 @@ export interface TaskPrStatus { const SIDEBAR_STALE_TIME = 60_000; const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; -export function useTaskPrStatus(task: TaskData): TaskPrStatus { +export function useTaskPrStatus( + task: Pick, +): TaskPrStatus { const trpc = useTRPC(); const { data } = useQuery( diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 17f6e439b..7250605aa 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -39,7 +39,8 @@ export type CommandMenuAction = | "logout" | "toggle-theme" | "toggle-left-sidebar" - | "open-review-panel"; + | "open-review-panel" + | "open-task"; // Event property interfaces export interface TaskListViewProperties {