From 1ca718908df760a822df3aba4ba54cac22a3599e Mon Sep 17 00:00:00 2001 From: Andy Maguire Date: Thu, 21 May 2026 12:16:18 +0000 Subject: [PATCH 1/4] feat(sidebar): show Slack icon for tasks originating from a Slack thread Threads `origin_product` through the sidebar task list and command palette so tasks created from Slack (origin_product === "slack") show a Slack icon instead of the generic chat-bubble / cloud icon. Cloud tasks that came from Slack keep their running / completed / failed color coding on the Slack logo. The /tasks/summaries/ endpoint doesn't currently include origin_product, so we fetch the slack-origin subset separately and intersect by id in useSidebarData. Drive-by: dedupe a duplicate INBOX_VIEWED entry in analytics.ts (which was breaking the typecheck pre-commit hook on main) and drop a stale no-property INBOX_VIEWED call in navigationStore; the canonical track-with-properties call in InboxSignalsTab is what we want to keep. Generated-By: PostHog Code Task-Id: 030bba98-3ca5-403e-82f2-812c8158910b --- .../command/components/CommandMenu.tsx | 1 + .../sidebar/components/TaskListView.tsx | 1 + .../sidebar/components/items/TaskIcon.tsx | 55 +++++++++++++++---- .../sidebar/components/items/TaskItem.tsx | 3 + .../features/sidebar/hooks/useSidebarData.ts | 20 ++++++- .../renderer/features/tasks/hooks/useTasks.ts | 13 +++++ .../src/renderer/stores/navigationStore.ts | 1 - apps/code/src/shared/types/analytics.ts | 2 - 8 files changed, 81 insertions(+), 15 deletions(-) diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/apps/code/src/renderer/features/command/components/CommandMenu.tsx index 006dc619a..5b32b0d8d 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/apps/code/src/renderer/features/command/components/CommandMenu.tsx @@ -68,6 +68,7 @@ function TaskCommandIcon({ task }: { task: Task }) { diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index b8cec1f3e..d1d202fde 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -120,6 +120,7 @@ function TaskRow({ isPinned={task.isPinned} needsPermission={task.needsPermission} taskRunStatus={task.taskRunStatus} + originProduct={task.originProduct} prState={prState} hasDiff={hasDiff} timestamp={timestamp} diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx index 2d8ef6a02..1d69a8712 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx @@ -12,6 +12,7 @@ import { HandPalm, Pause, PushPin, + SlackLogo, } from "@phosphor-icons/react"; import { isTerminalStatus, type TaskRunStatus } from "@shared/types"; @@ -23,40 +24,50 @@ export const ICON_SIZE = 12; // 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 }) { +function CloudStatusIcon({ + taskRunStatus, + isFromSlack, +}: { + taskRunStatus?: TaskRunStatus; + isFromSlack?: boolean; +}) { + const Icon = isFromSlack ? SlackLogo : CloudIcon; + const sourceLabel = isFromSlack ? "Slack" : "Cloud"; if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { return ( - + - + ); } if (taskRunStatus === "completed") { return ( - + - + ); } if (taskRunStatus === "failed" || taskRunStatus === "cancelled") { const label = - taskRunStatus === "cancelled" ? "Cloud (cancelled)" : "Cloud (failed)"; + taskRunStatus === "cancelled" + ? `${sourceLabel} (cancelled)` + : `${sourceLabel} (failed)`; return ( - + ); } return ( - + - + ); @@ -137,6 +148,7 @@ export interface TaskIconProps { isSuspended?: boolean; needsPermission?: boolean; taskRunStatus?: TaskRunStatus; + originProduct?: string; prState?: SidebarPrState; hasDiff?: boolean; } @@ -154,11 +166,13 @@ export function TaskIcon({ isSuspended, needsPermission, taskRunStatus, + originProduct, prState, hasDiff, }: TaskIconProps) { const isCloudTask = workspaceMode === "cloud"; const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus); + const isFromSlack = originProduct === "slack"; if (needsPermission) { return ( @@ -170,13 +184,23 @@ export function TaskIcon({ ); } if (isTerminalCloud) { - return ; + return ( + + ); } if (isGenerating) { return ; } if (isCloudTask) { - return ; + return ( + + ); } if (isSuspended) { return ( @@ -200,5 +224,14 @@ export function TaskIcon({ if (isPinned) { return ; } + if (isFromSlack) { + 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 16412e341..3479a9ab9 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -21,6 +21,7 @@ interface TaskItemProps { isSuspended?: boolean; needsPermission?: boolean; taskRunStatus?: TaskRunStatus; + originProduct?: string; prState?: SidebarPrState; hasDiff?: boolean; timestamp?: number; @@ -113,6 +114,7 @@ export function TaskItem({ isPinned = false, needsPermission = false, taskRunStatus, + originProduct, prState, hasDiff, timestamp, @@ -134,6 +136,7 @@ export function TaskItem({ isSuspended={isSuspended} needsPermission={needsPermission} taskRunStatus={taskRunStatus} + originProduct={originProduct} prState={prState} hasDiff={hasDiff} /> diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index 06e8d4892..fcc43b8d7 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -2,7 +2,11 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore"; import { useSessions } from "@features/sessions/stores/sessionStore"; import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; -import { useTaskSummaries, useTasks } from "@features/tasks/hooks/useTasks"; +import { + useSlackTasks, + useTaskSummaries, + useTasks, +} from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import type { Schemas } from "@renderer/api/generated"; import type { Task, TaskRunStatus } from "@shared/types"; @@ -33,6 +37,7 @@ export interface TaskData { folderId?: string; taskRunStatus?: TaskRunStatus; taskRunEnvironment?: "local" | "cloud"; + originProduct?: string; folderPath: string | null; cloudPrUrl: string | null; branchName: string | null; @@ -129,6 +134,11 @@ export function useSidebarData({ { showAllUsers, showInternal }, { enabled: showAllUsers }, ); + const { data: slackTasks = [] } = useSlackTasks(); + const slackTaskIds = useMemo( + () => new Set(slackTasks.map((t) => t.id)), + [slackTasks], + ); type SidebarTask = Schemas.TaskSummary & { latest_run: @@ -136,6 +146,7 @@ export function useSidebarData({ output?: { pr_url?: unknown } | null; }) | null; + origin_product?: string; }; const rawTasks: SidebarTask[] = useMemo(() => { @@ -153,6 +164,7 @@ export function useSidebarData({ output: t.latest_run.output ?? null, } : null, + origin_product: t.origin_product, })); }, [showAllUsers, summaryTasks, fullTasks]); @@ -224,6 +236,10 @@ export function useSidebarData({ ? task.latest_run.output.pr_url : ((session?.cloudOutput?.pr_url as string | undefined) ?? null); + const originProduct = + task.origin_product ?? + (slackTaskIds.has(task.id) ? "slack" : undefined); + return { id: task.id, title: task.title, @@ -239,6 +255,7 @@ export function useSidebarData({ taskRunStatus: session?.cloudStatus ?? task.latest_run?.status ?? undefined, taskRunEnvironment: task.latest_run?.environment ?? undefined, + originProduct, folderPath: workspace?.folderPath ?? null, cloudPrUrl, branchName: workspace?.branchName ?? null, @@ -252,6 +269,7 @@ export function useSidebarData({ suspendedTaskIds, sessionByTaskId, workspaces, + slackTaskIds, ]); const pinnedTasks = useMemo(() => { diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index 32491e6b5..f19174bbb 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -73,6 +73,19 @@ export function useTaskSummaries( ); } +// The /tasks/summaries/ endpoint doesn't include origin_product, so fetch the +// slack-origin subset separately and intersect by id in the sidebar. +export function useSlackTasks() { + return useAuthenticatedQuery( + taskKeys.list({ originProduct: "slack" }), + (client) => + client.getTasks({ originProduct: "slack" }) as unknown as Promise, + { + refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + }, + ); +} + export function useCreateTask() { const queryClient = useQueryClient(); diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 47ca4f1f9..bb43e334a 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -258,7 +258,6 @@ export const useNavigationStore = create()( navigateToInbox: () => { navigate({ type: "inbox" }); - track(ANALYTICS_EVENTS.INBOX_VIEWED); }, navigateToArchived: () => { diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index e91f667ee..72fe93df6 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -597,7 +597,6 @@ export const ANALYTICS_EVENTS = { ONBOARDING_ABANDONED: "Onboarding abandoned", AI_CONSENT_GATE_SHOWN: "Ai consent gate shown", AI_CONSENT_APPROVED: "Ai consent approved", - INBOX_VIEWED: "Inbox viewed", // Setup / onboarding events SETUP_DISCOVERY_STARTED: "Setup discovery started", @@ -704,7 +703,6 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.ONBOARDING_ABANDONED]: OnboardingAbandonedProperties; [ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN]: AiConsentGateShownProperties; [ANALYTICS_EVENTS.AI_CONSENT_APPROVED]: never; - [ANALYTICS_EVENTS.INBOX_VIEWED]: never; // Setup / onboarding events [ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED]: SetupDiscoveryStartedProperties; From 0e28236c16485131aa59a2f386008ebd1b452ca9 Mon Sep 17 00:00:00 2001 From: Andy Maguire Date: Thu, 21 May 2026 12:30:01 +0000 Subject: [PATCH 2/4] refactor(sidebar): scope and gate the slack-tasks query for the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address bot review feedback on the Slack icon PR: - `useSlackTasks` now accepts `{ enabled, showInternal }` so callers can both gate the fetch and forward the same visibility scope the sidebar uses. - `useSidebarData` disables the slack query when `showAllUsers=true` — that path already maps `origin_product` off `fullTasks`, so the extra request and the resulting `taskData` memo churn are redundant. - Pass `showInternal` through so staff viewing internal tasks still get the Slack icon on internal slack-origin tasks. Generated-By: PostHog Code Task-Id: 030bba98-3ca5-403e-82f2-812c8158910b --- .../features/sidebar/hooks/useSidebarData.ts | 7 ++++++- .../renderer/features/tasks/hooks/useTasks.ts | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index fcc43b8d7..823f77b0f 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -134,7 +134,12 @@ export function useSidebarData({ { showAllUsers, showInternal }, { enabled: showAllUsers }, ); - const { data: slackTasks = [] } = useSlackTasks(); + // Skip the slack fetch when showAllUsers is on — fullTasks already carries + // origin_product through the rawTasks mapping below. + const { data: slackTasks = [] } = useSlackTasks({ + enabled: !showAllUsers, + showInternal, + }); const slackTaskIds = useMemo( () => new Set(slackTasks.map((t) => t.id)), [slackTasks], diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index f19174bbb..9643f8ccf 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -74,13 +74,23 @@ export function useTaskSummaries( } // The /tasks/summaries/ endpoint doesn't include origin_product, so fetch the -// slack-origin subset separately and intersect by id in the sidebar. -export function useSlackTasks() { +// slack-origin subset separately and intersect by id in the sidebar. The +// `internal` filter mirrors the sidebar's task-visibility scope so staff +// toggling the internal view still see slack icons on internal tasks. +export function useSlackTasks(options?: { + enabled?: boolean; + showInternal?: boolean; +}) { + const internal = options?.showInternal ? true : undefined; return useAuthenticatedQuery( - taskKeys.list({ originProduct: "slack" }), + taskKeys.list({ originProduct: "slack", internal }), (client) => - client.getTasks({ originProduct: "slack" }) as unknown as Promise, + client.getTasks({ + originProduct: "slack", + internal, + }) as unknown as Promise, { + enabled: options?.enabled ?? true, refetchInterval: TASK_LIST_POLL_INTERVAL_MS, }, ); From 1d295a2e3ae0dc95c31862539d1d6a36e385a0fe Mon Sep 17 00:00:00 2001 From: Andy Maguire Date: Thu, 21 May 2026 14:34:37 +0000 Subject: [PATCH 3/4] refactor(sidebar): plumb originProduct as a string into CloudStatusIcon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review (joshsny): swap the boolean `isFromSlack` prop on `CloudStatusIcon` for the raw `originProduct` string, and look up the icon + label from a small `ORIGIN_PRODUCT_META` map. Adding a new branded origin product (email, support, …) is now a one-line addition rather than another boolean prop. Generated-By: PostHog Code Task-Id: 030bba98-3ca5-403e-82f2-812c8158910b --- .../sidebar/components/items/TaskIcon.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx index 1d69a8712..7c0d4a087 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx @@ -24,15 +24,30 @@ export const ICON_SIZE = 12; // selected row, which turns a `currentColor` icon black on hover. An explicit // `fill` is immune, and renders identically in the sidebar. +// Map origin_product values to the icon + label used to brand the task's +// status icon. Extend this when a new product (e.g. email, support) needs its +// own indicator. +type OriginProductMeta = { Icon: typeof SlackLogo; label: string }; +const ORIGIN_PRODUCT_META: Record = { + slack: { Icon: SlackLogo, label: "Slack" }, +}; + +function getOriginProductMeta( + originProduct?: string, +): OriginProductMeta | undefined { + return originProduct ? ORIGIN_PRODUCT_META[originProduct] : undefined; +} + function CloudStatusIcon({ taskRunStatus, - isFromSlack, + originProduct, }: { taskRunStatus?: TaskRunStatus; - isFromSlack?: boolean; + originProduct?: string; }) { - const Icon = isFromSlack ? SlackLogo : CloudIcon; - const sourceLabel = isFromSlack ? "Slack" : "Cloud"; + const meta = getOriginProductMeta(originProduct); + const Icon = meta?.Icon ?? CloudIcon; + const sourceLabel = meta?.label ?? "Cloud"; if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { return ( @@ -172,7 +187,7 @@ export function TaskIcon({ }: TaskIconProps) { const isCloudTask = workspaceMode === "cloud"; const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus); - const isFromSlack = originProduct === "slack"; + const originProductMeta = getOriginProductMeta(originProduct); if (needsPermission) { return ( @@ -187,7 +202,7 @@ export function TaskIcon({ return ( ); } @@ -198,7 +213,7 @@ export function TaskIcon({ return ( ); } @@ -224,11 +239,12 @@ export function TaskIcon({ if (isPinned) { return ; } - if (isFromSlack) { + if (originProductMeta) { + const { Icon, label } = originProductMeta; return ( - + - + ); From 4147573f0e9f7f0ab2e7a53ca27a65487327642d Mon Sep 17 00:00:00 2001 From: Andy Maguire Date: Thu, 21 May 2026 14:48:57 +0000 Subject: [PATCH 4/4] feat(sidebar): make the Slack icon open the originating thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the Slack icon on a slack-origin task now opens the originating thread in the user's default browser, so the user can jump back into Slack to ping someone or continue the conversation without leaving and re-finding the thread manually. Plumbing: - `task.latest_run.state.slack_thread_url` is a pre-built URL on the full Task. `useSlackTasks` already fetches full Tasks, so for the summaries sidebar path we build a `Map` from that query. For the showAllUsers path, the URL is pulled directly out of the rawTasks mapping. - Threaded `slackThreadUrl` through `TaskData → TaskRow → TaskItem → TaskIcon` and also through `CommandMenu`'s `TaskCommandIcon`. - Added an `IconWrapper` / `IconLink` helper inside `TaskIcon` that renders the icon as a `role="link"` span (a real `` would be invalid inside the parent `SidebarItem` `