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
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,17 @@ function TaskCommandIcon({ task }: { task: Task }) {
cloudPrUrl: null,
taskRunEnvironment: task.latest_run?.environment,
});
const stateSlackThreadUrl = (
task.latest_run?.state as { slack_thread_url?: unknown } | undefined
)?.slack_thread_url;
const slackThreadUrl =
typeof stateSlackThreadUrl === "string" ? stateSlackThreadUrl : undefined;
return (
<TaskIcon
workspaceMode={task.latest_run?.environment}
taskRunStatus={task.latest_run?.status}
originProduct={task.origin_product}
slackThreadUrl={slackThreadUrl}
prState={prState}
hasDiff={hasDiff}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ function TaskRow({
isPinned={task.isPinned}
needsPermission={task.needsPermission}
taskRunStatus={task.taskRunStatus}
originProduct={task.originProduct}
slackThreadUrl={task.slackThreadUrl}
prState={prState}
hasDiff={hasDiff}
timestamp={timestamp}
Expand Down
178 changes: 158 additions & 20 deletions apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
HandPalm,
Pause,
PushPin,
SlackLogo,
} from "@phosphor-icons/react";
import { trpcClient } from "@renderer/trpc/client";
import { isTerminalStatus, type TaskRunStatus } from "@shared/types";

export const ICON_SIZE = 12;
Expand All @@ -23,47 +25,145 @@ 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<string, OriginProductMeta> = {
slack: { Icon: SlackLogo, label: "Slack" },
};

function getOriginProductMeta(
originProduct?: string,
): OriginProductMeta | undefined {
return originProduct ? ORIGIN_PRODUCT_META[originProduct] : undefined;
}

// Renders the icon inside a span. When `link` is set the span becomes
// clickable and opens the originating thread externally. SidebarItem renders
// the row as a `<button>`, so a real `<a>` here would be invalid HTML — match
// the inline role="button" pattern used by TaskHoverToolbar.
//
// Returned as a plain React element (not a component) so the span is the
// direct child of Tooltip — Radix's `asChild` Slot needs a host element to
// attach hover handlers to.
function renderIconSpan({
icon,
link,
ariaLabel,
}: {
icon: React.ReactNode;
link?: string;
ariaLabel?: string;
}) {
if (!link) {
return <span className="flex items-center justify-center">{icon}</span>;
}
const open = () => {
void trpcClient.os.openExternal.mutate({ url: link });
};
return (
// biome-ignore lint/a11y/useSemanticElements: nested clickable inside SidebarItem button
<span
role="button"
tabIndex={0}
aria-label={ariaLabel}
className="flex cursor-pointer items-center justify-center rounded transition-opacity hover:opacity-70"
onClick={(e) => {
e.stopPropagation();
open();
}}
onDoubleClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
open();
}
}}
>
{icon}
</span>
);
}

function CloudStatusIcon({
taskRunStatus,
originProduct,
threadUrl,
size,
}: {
taskRunStatus?: TaskRunStatus;
originProduct?: string;
threadUrl?: string;
size: number;
}) {
const meta = getOriginProductMeta(originProduct);
const Icon = meta?.Icon ?? CloudIcon;
const sourceLabel = meta?.label ?? "Cloud";
const link = meta && threadUrl ? threadUrl : undefined;
const ariaLabel = link ? `Open ${sourceLabel} thread` : undefined;

if (taskRunStatus === "queued" || taskRunStatus === "in_progress") {
return (
<Tooltip content="Cloud (running)" side="right">
<span className="flex items-center justify-center">
<CloudIcon size={size} className="ph-pulse" />
</span>
<Tooltip
content={
link ? `Open ${sourceLabel} thread` : `${sourceLabel} (running)`
}
side="right"
>
{renderIconSpan({
icon: <Icon size={size} className="ph-pulse" />,
link,
ariaLabel,
})}
</Tooltip>
);
}
if (taskRunStatus === "completed") {
return (
<Tooltip content="Cloud (completed)" side="right">
<span className="flex items-center justify-center">
<CloudIcon size={size} weight="fill" color="var(--green-11)" />
</span>
<Tooltip
content={
link ? `Open ${sourceLabel} thread` : `${sourceLabel} (completed)`
}
side="right"
>
{renderIconSpan({
icon: <Icon size={size} weight="fill" color="var(--green-11)" />,
link,
ariaLabel,
})}
</Tooltip>
);
}
if (taskRunStatus === "failed" || taskRunStatus === "cancelled") {
const label =
taskRunStatus === "cancelled" ? "Cloud (cancelled)" : "Cloud (failed)";
const statusLabel =
taskRunStatus === "cancelled"
? `${sourceLabel} (cancelled)`
: `${sourceLabel} (failed)`;
return (
<Tooltip content={label} side="right">
<span className="flex items-center justify-center">
<CloudIcon size={size} weight="fill" color="var(--red-11)" />
</span>
<Tooltip
content={link ? `Open ${sourceLabel} thread` : statusLabel}
side="right"
>
{renderIconSpan({
icon: <Icon size={size} weight="fill" color="var(--red-11)" />,
link,
ariaLabel,
})}
</Tooltip>
);
}
return (
<Tooltip content="Cloud" side="right">
<span className="flex items-center justify-center">
<CloudIcon size={size} />
</span>
<Tooltip
content={link ? `Open ${sourceLabel} thread` : sourceLabel}
side="right"
>
{renderIconSpan({
icon: <Icon size={size} />,
link,
ariaLabel,
})}
</Tooltip>
);
}
Expand Down Expand Up @@ -133,6 +233,11 @@ export interface TaskIconProps {
isSuspended?: boolean;
needsPermission?: boolean;
taskRunStatus?: TaskRunStatus;
originProduct?: string;
/** Pre-built URL to the originating Slack thread (read from
* `task.latest_run.state.slack_thread_url`). When set, the Slack icon
* becomes a link that opens the thread in the user's browser. */
slackThreadUrl?: string;
prState?: SidebarPrState;
hasDiff?: boolean;
size?: number;
Expand All @@ -151,12 +256,15 @@ export function TaskIcon({
isSuspended,
needsPermission,
taskRunStatus,
originProduct,
slackThreadUrl,
prState,
hasDiff,
size = ICON_SIZE,
}: TaskIconProps) {
const isCloudTask = workspaceMode === "cloud";
const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus);
const originProductMeta = getOriginProductMeta(originProduct);

if (needsPermission) {
return (
Expand All @@ -168,13 +276,27 @@ export function TaskIcon({
);
}
if (isTerminalCloud) {
return <CloudStatusIcon taskRunStatus={taskRunStatus} size={size} />;
return (
<CloudStatusIcon
taskRunStatus={taskRunStatus}
originProduct={originProduct}
threadUrl={slackThreadUrl}
size={size}
/>
);
}
if (isGenerating) {
return <DotsCircleSpinner size={size} className="text-accent-11" />;
}
if (isCloudTask) {
return <CloudStatusIcon taskRunStatus={taskRunStatus} size={size} />;
return (
<CloudStatusIcon
taskRunStatus={taskRunStatus}
originProduct={originProduct}
threadUrl={slackThreadUrl}
size={size}
/>
);
}
if (isSuspended) {
return (
Expand All @@ -198,5 +320,21 @@ export function TaskIcon({
if (isPinned) {
return <PushPin size={size} color="var(--accent-11)" />;
}
if (originProductMeta) {
const { Icon, label } = originProductMeta;
const link = slackThreadUrl;
return (
<Tooltip
content={link ? `Open ${label} thread` : `From ${label}`}
side="right"
>
{renderIconSpan({
icon: <Icon size={size} color="var(--gray-10)" />,
link,
ariaLabel: `Open ${label} thread`,
})}
</Tooltip>
);
}
return <ChatCircle size={size} color="var(--gray-10)" />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ interface TaskItemProps {
isSuspended?: boolean;
needsPermission?: boolean;
taskRunStatus?: TaskRunStatus;
originProduct?: string;
slackThreadUrl?: string;
prState?: SidebarPrState;
hasDiff?: boolean;
timestamp?: number;
Expand Down Expand Up @@ -113,6 +115,8 @@ export function TaskItem({
isPinned = false,
needsPermission = false,
taskRunStatus,
originProduct,
slackThreadUrl,
prState,
hasDiff,
timestamp,
Expand All @@ -134,6 +138,8 @@ export function TaskItem({
isSuspended={isSuspended}
needsPermission={needsPermission}
taskRunStatus={taskRunStatus}
originProduct={originProduct}
slackThreadUrl={slackThreadUrl}
prState={prState}
hasDiff={hasDiff}
/>
Expand Down
Loading
Loading