diff --git a/packages/ui/src/features/home/components/HomeArchivedSection.tsx b/packages/ui/src/features/home/components/HomeArchivedSection.tsx new file mode 100644 index 0000000000..53db43b4bb --- /dev/null +++ b/packages/ui/src/features/home/components/HomeArchivedSection.tsx @@ -0,0 +1,147 @@ +import { + Archive, + CaretDown, + CaretRight, + Cloud as CloudIcon, + GitBranch as GitBranchIcon, + Laptop as LaptopIcon, +} from "@phosphor-icons/react"; +import { + type ArchivedTaskWithDetails, + formatRelativeDate, + getRepoName, +} from "@posthog/core/archive/archiveListView"; +import type { WorkspaceMode } from "@posthog/shared"; +import { useHomeUiStore } from "@posthog/ui/features/home/stores/homeUiStore"; +import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; +import { openTask } from "@posthog/ui/router/useOpenTask"; +import { Box, Flex, Text } from "@radix-ui/themes"; + +// Cap rows shown inline; the full searchable list lives in ArchivedTasksView. +const INLINE_LIMIT = 8; + +function ModeGlyph({ mode }: { mode: WorkspaceMode }) { + const Icon = + mode === "cloud" + ? CloudIcon + : mode === "worktree" + ? GitBranchIcon + : LaptopIcon; + return ; +} + +interface HomeArchivedSectionProps { + items: ArchivedTaskWithDetails[]; +} + +export function HomeArchivedSection({ items }: HomeArchivedSectionProps) { + const expanded = useHomeUiStore((s) => s.archivedExpanded); + const toggleExpanded = useHomeUiStore((s) => s.toggleArchivedExpanded); + + if (items.length === 0) return null; + + const visible = items.slice(0, INLINE_LIMIT); + const hiddenCount = items.length - visible.length; + + return ( + + + + + + + {expanded ? ( + <> + {visible.map((item) => ( + + ))} + {hiddenCount > 0 ? ( + + ) : null} + + ) : null} + + ); +} + +function HomeArchivedRow({ item }: { item: ArchivedTaskWithDetails }) { + const { task, archived } = item; + const title = task?.title ?? "Unknown task"; + const repoName = getRepoName(task?.repository); + + const onOpen = () => { + if (task) { + void openTask(task); + } else { + navigateToArchived(); + } + }; + + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + role="button" + tabIndex={0} + aria-label={`Open ${title}`} + className="group flex cursor-pointer items-center gap-3 border-(--gray-3) border-b py-2 pr-3 pl-4 transition-colors hover:bg-(--gray-2)" + > + +
+ + {title} + + {repoName !== "—" ? ( + + {repoName} + + ) : null} +
+ + {formatRelativeDate(archived.archivedAt)} + +
+ ); +} diff --git a/packages/ui/src/features/home/components/HomeView.tsx b/packages/ui/src/features/home/components/HomeView.tsx index 6f5111dcea..529f0e2e47 100644 --- a/packages/ui/src/features/home/components/HomeView.tsx +++ b/packages/ui/src/features/home/components/HomeView.tsx @@ -7,6 +7,7 @@ import { Warning, } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; +import { useHomeArchivedTasks } from "@posthog/ui/features/home/hooks/useHomeArchivedTasks"; import { useHomeSnapshot } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; import { type HomeViewMode, @@ -18,6 +19,7 @@ import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; import { useEffect, useMemo } from "react"; import { ConfigMap } from "../config/ConfigMap"; import { HomeActiveAgentsStrip } from "./HomeActiveAgentsStrip"; +import { HomeArchivedSection } from "./HomeArchivedSection"; import { HomeBoardView } from "./HomeBoardView"; import { HomeEmptyState } from "./HomeEmptyState"; import { HomeWorkstreamDetailPanel } from "./HomeWorkstreamDetailPanel"; @@ -39,6 +41,8 @@ const HEADER_CONTENT = ( export function HomeView() { const { snapshot, isLoading } = useHomeSnapshot(); + const { items: archivedItems, isLoading: archivedLoading } = + useHomeArchivedTasks(); const viewMode = useHomeUiStore((s) => s.viewMode); const setViewMode = useHomeUiStore((s) => s.setViewMode); const selectedWorkstreamId = useHomeUiStore((s) => s.selectedWorkstreamId); @@ -88,7 +92,7 @@ export function HomeView() { } const totalRows = needsAttention.length + inProgress.length; - const hasContent = activeAgents.length > 0 || totalRows > 0; + const activeHasContent = activeAgents.length > 0 || totalRows > 0; return ( @@ -100,7 +104,7 @@ export function HomeView() { Home - {hasContent ? ( + {activeHasContent ? ( {needsAttention.length > 0 ? ( - {!hasContent ? ( - 0} /> - ) : viewMode === "board" ? ( - - - + {viewMode === "board" ? ( + activeHasContent ? ( + + + + ) : ( + 0} /> + ) ) : ( {needsAttention.length > 0 ? ( @@ -190,6 +196,14 @@ export function HomeView() { {totalRows === 0 && activeAgents.length > 0 ? ( ) : null} + + + + {!activeHasContent && + archivedItems.length === 0 && + !archivedLoading ? ( + + ) : null} )} diff --git a/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx b/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx index 913f3c6091..aa69307734 100644 --- a/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx +++ b/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx @@ -40,11 +40,13 @@ export function HomeWorkstreamCard({ workstream }: HomeWorkstreamCardProps) { primaryIsTask, showPrInMenu, showTaskInMenu, + canArchive, hasMenu, runAction, isRunningAction, openTask, openPr, + archive, } = useWorkstreamPresentation(workstream); const setSelectedWorkstreamId = useHomeUiStore( (s) => s.setSelectedWorkstreamId, @@ -196,9 +198,11 @@ export function HomeWorkstreamCard({ workstream }: HomeWorkstreamCardProps) { restBound={restBound} showPrInMenu={showPrInMenu} showTaskInMenu={showTaskInMenu} + showArchive={canArchive} onRun={runAction} onOpenPr={openPr} onOpenTask={openTask} + onArchive={archive} size="xs" runDisabled={isRunningAction} /> diff --git a/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx b/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx index 26c50ecef0..e06711d6ff 100644 --- a/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx +++ b/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx @@ -43,11 +43,13 @@ export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { primaryIsTask, showPrInMenu, showTaskInMenu, + canArchive, hasMenu, runAction, isRunningAction, openTask, openPr, + archive, } = useWorkstreamPresentation(workstream); const setSelectedWorkstreamId = useHomeUiStore( (s) => s.setSelectedWorkstreamId, @@ -204,9 +206,11 @@ export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { restBound={restBound} showPrInMenu={showPrInMenu} showTaskInMenu={showTaskInMenu} + showArchive={canArchive} onRun={runAction} onOpenPr={openPr} onOpenTask={openTask} + onArchive={archive} size="sm" runDisabled={isRunningAction} /> diff --git a/packages/ui/src/features/home/components/WorkstreamBits.tsx b/packages/ui/src/features/home/components/WorkstreamBits.tsx index bd408c5153..9c4c5c0c72 100644 --- a/packages/ui/src/features/home/components/WorkstreamBits.tsx +++ b/packages/ui/src/features/home/components/WorkstreamBits.tsx @@ -1,4 +1,5 @@ import { + Archive, ArrowSquareOut, CheckCircle, CircleNotch, @@ -132,18 +133,23 @@ export function WorkstreamOverflowMenu({ restBound, showPrInMenu, showTaskInMenu, + showArchive = false, onRun, onOpenPr, onOpenTask, + onArchive, size = "sm", runDisabled = false, }: { restBound: BoundAction[]; showPrInMenu: boolean; showTaskInMenu: boolean; + /** Whether to offer "Archive" in the menu. */ + showArchive?: boolean; onRun: (action: BoundAction) => void; onOpenPr: () => void; onOpenTask: () => void; + onArchive?: () => void; size?: "sm" | "xs"; /** Disables the task-starting actions while one is already in flight. */ runDisabled?: boolean; @@ -184,6 +190,17 @@ export function WorkstreamOverflowMenu({ {showTaskInMenu ? ( Open task ) : null} + {showArchive && onArchive ? ( + <> + {restBound.length > 0 || showPrInMenu || showTaskInMenu ? ( + + ) : null} + + + Archive + + + ) : null} ); diff --git a/packages/ui/src/features/home/hooks/useHomeArchivedTasks.ts b/packages/ui/src/features/home/hooks/useHomeArchivedTasks.ts new file mode 100644 index 0000000000..a436e18257 --- /dev/null +++ b/packages/ui/src/features/home/hooks/useHomeArchivedTasks.ts @@ -0,0 +1,35 @@ +import { + type ArchivedTaskWithDetails, + mergeArchivedWithTasks, +} from "@posthog/core/archive/archiveListView"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +export interface HomeArchivedTasks { + items: ArchivedTaskWithDetails[]; + isLoading: boolean; +} + +// Window into archived tasks for the Home list view. Reuses the same data +// pipeline as the dedicated ArchivedTasksView (archive.list joined with tasks), +// sorted most-recently-archived first so the Home section shows the latest work. +export function useHomeArchivedTasks(): HomeArchivedTasks { + const trpc = useHostTRPC(); + const { data: archivedTasks = [], isLoading: isLoadingArchived } = useQuery( + trpc.archive.list.queryOptions(), + ); + const { data: tasks = [], isLoading: isLoadingTasks } = useTasks(); + + const items = useMemo(() => { + const merged = mergeArchivedWithTasks(archivedTasks, tasks); + return [...merged].sort( + (a, b) => + new Date(b.archived.archivedAt).getTime() - + new Date(a.archived.archivedAt).getTime(), + ); + }, [archivedTasks, tasks]); + + return { items, isLoading: isLoadingArchived || isLoadingTasks }; +} diff --git a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts index 3e20ce6923..b71460f781 100644 --- a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts +++ b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts @@ -1,10 +1,12 @@ import type { PrSnapshot } from "@posthog/core/home/prSnapshot"; -import type { HomeWorkstream } from "@posthog/core/home/schemas"; +import type { HomeSnapshot, HomeWorkstream } from "@posthog/core/home/schemas"; import type { SituationId } from "@posthog/core/workflow/schemas"; +import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; import { type BoundAction, useBoundActions, } from "@posthog/ui/features/home/hooks/useBoundActions"; +import { homeKeys } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; import { useRunWorkstreamAction } from "@posthog/ui/features/home/hooks/useRunWorkstreamAction"; import { useQuickActionStore } from "@posthog/ui/features/home/stores/quickActionStore"; import { @@ -13,8 +15,13 @@ import { situationCss, } from "@posthog/ui/features/home/utils/situationDisplay"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { toast } from "@posthog/ui/primitives/toast"; import { openTask } from "@posthog/ui/router/useOpenTask"; +import { logger } from "@posthog/ui/shell/logger"; import { openUrlInBrowser } from "@posthog/ui/utils/browser"; +import { useQueryClient } from "@tanstack/react-query"; + +const log = logger.scope("workstream-archive"); export interface WorkstreamPresentation { pr: PrSnapshot | null; @@ -36,12 +43,16 @@ export interface WorkstreamPresentation { primaryIsTask: boolean; showPrInMenu: boolean; showTaskInMenu: boolean; + /** Whether the workstream has a task that can be archived from the overflow menu. */ + canArchive: boolean; hasMenu: boolean; runAction: (action: BoundAction) => void; /** True while a quick action is starting a task; disable the row's action controls. */ isRunningAction: boolean; openTask: () => void; openPr: () => void; + /** Archive the workstream's head task and drop it from the Home snapshot. */ + archive: () => void; } /** @@ -54,6 +65,8 @@ export function useWorkstreamPresentation( const { data: tasks = [] } = useTasks(); const boundActions = useBoundActions(workstream); const { run } = useRunWorkstreamAction(); + const { archiveTask } = useArchiveTask(); + const queryClient = useQueryClient(); const isRunningAction = useQuickActionStore( (s) => !!s.inFlight[workstream.id], ); @@ -85,7 +98,9 @@ export function useWorkstreamPresentation( const primaryIsTask = !primaryBound && !workstream.prUrl && !!headTask; const showPrInMenu = !!workstream.prUrl && !primaryIsPr; const showTaskInMenu = !!headTask && !primaryIsTask; - const hasMenu = restBound.length > 0 || showPrInMenu || showTaskInMenu; + const canArchive = !!headTask; + const hasMenu = + restBound.length > 0 || showPrInMenu || showTaskInMenu || canArchive; return { pr, @@ -103,6 +118,7 @@ export function useWorkstreamPresentation( primaryIsTask, showPrInMenu, showTaskInMenu, + canArchive, hasMenu, runAction: (action) => run(action, workstream), isRunningAction, @@ -114,5 +130,35 @@ export function useWorkstreamPresentation( openPr: () => { if (workstream.prUrl) void openUrlInBrowser(workstream.prUrl); }, + archive: () => { + if (!headTask) return; + const taskId = headTask.id; + archiveTask({ taskId }) + .then(() => { + // The Home snapshot is server-computed and only refreshes on its poll + // (and, for workstreams, after the code-workstreams worker re-runs), + // so drop the row optimistically here for immediate feedback. + queryClient.setQueryData(homeKeys.snapshot, (old) => + old + ? { + ...old, + activeAgents: old.activeAgents.filter( + (a) => a.taskId !== taskId, + ), + needsAttention: old.needsAttention.filter( + (w) => w.id !== workstream.id, + ), + inProgress: old.inProgress.filter( + (w) => w.id !== workstream.id, + ), + } + : old, + ); + }) + .catch((error) => { + log.error("Failed to archive workstream task", { taskId, error }); + toast.error("Failed to archive task"); + }); + }, }; } diff --git a/packages/ui/src/features/home/stores/homeUiStore.ts b/packages/ui/src/features/home/stores/homeUiStore.ts index 65c8b4ba55..b8835871b3 100644 --- a/packages/ui/src/features/home/stores/homeUiStore.ts +++ b/packages/ui/src/features/home/stores/homeUiStore.ts @@ -9,6 +9,8 @@ interface HomeUiStore { setViewMode: (mode: HomeViewMode) => void; selectedWorkstreamId: string | null; setSelectedWorkstreamId: (id: string | null) => void; + archivedExpanded: boolean; + toggleArchivedExpanded: () => void; } export const useHomeUiStore = create()( @@ -18,11 +20,17 @@ export const useHomeUiStore = create()( setViewMode: (mode) => set({ viewMode: mode }), selectedWorkstreamId: null, setSelectedWorkstreamId: (id) => set({ selectedWorkstreamId: id }), + archivedExpanded: false, + toggleArchivedExpanded: () => + set((state) => ({ archivedExpanded: !state.archivedExpanded })), }), { name: "home-ui-store", storage: electronStorage, - partialize: (state) => ({ viewMode: state.viewMode }), + partialize: (state) => ({ + viewMode: state.viewMode, + archivedExpanded: state.archivedExpanded, + }), }, ), );