diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..c4f275d6f5a8 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,28 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +/** Official four-colour Slack "pinwheel" mark (brand asset, viewBox 122.8). */ +export const SlackIcon: Icon = (props) => ( + + + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa7340..f13298ff214d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -73,6 +73,7 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; +import { SlackThreadBadge, useThreadDisplayTitle } from "./SlackThreadBadge"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -456,6 +457,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + // Threads bridged from Slack (`Slack: …` titles) show the Slack mark in place of the prefix. + const { isSlack, displayTitle } = useThreadDisplayTitle(thread.title); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" @@ -718,7 +721,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr className="min-w-0 flex-1 truncate text-sm" data-testid={`thread-title-${thread.id}`} > - {thread.title} + {isSlack ? ( + + ) : null} + {displayTitle} } /> diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e3..4251b350a159 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -54,6 +54,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; +import { SlackThreadBadge, useThreadDisplayTitle } from "./SlackThreadBadge"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -526,6 +527,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + // Threads bridged from Slack (`Slack: …` titles) show the Slack mark in + // place of the prefix so they read differently from local conversations. + const { isSlack, displayTitle } = useThreadDisplayTitle(thread.title); + const detailsTooltip = ( - {thread.title} + {isSlack ? ( + + ) : null} + {displayTitle} ); diff --git a/apps/web/src/components/SlackThreadBadge.tsx b/apps/web/src/components/SlackThreadBadge.tsx new file mode 100644 index 000000000000..f3f4c397444c --- /dev/null +++ b/apps/web/src/components/SlackThreadBadge.tsx @@ -0,0 +1,67 @@ +import { useMemo } from "react"; + +import { SlackIcon } from "./Icons"; +import { getThreadChannel, stripThreadChannelPrefix } from "~/lib/threadChannel"; +import { cn } from "~/lib/utils"; + +/** + * Sidebar row title for a thread, with channel awareness. Threads bridged in + * from Slack (`Slack: …` titles) render the Slack mark in place of the + * literal prefix — the channel is read where the eye already starts, and the + * row gets those characters back for the actual sentence. Rename still edits + * the full stored title. + */ +export function useThreadDisplayTitle(title: string): { isSlack: boolean; displayTitle: string } { + return useMemo(() => { + const isSlack = getThreadChannel(title) === "slack"; + return { isSlack, displayTitle: isSlack ? stripThreadChannelPrefix(title) : title }; + }, [title]); +} + +export type SlackThreadBadgeDensity = "card" | "slim" | "v1"; + +/** + * The mark is 14px everywhere (all three sidebar row layouts set the title at + * `text-sm`, and the pinwheel's negative space collapses below that); density + * only tunes the gap to the title. Receded rows desaturate and step back so the + * settled tail keeps its hierarchy, and come back to full colour on row hover. + * A visually-hidden `Slack:` keeps the channel in the accessible name, since + * the visible mark replaces the prefix text. + */ +export function SlackThreadBadge({ + density, + isActive, + isMuted, + className, +}: { + density: SlackThreadBadgeDensity; + isActive: boolean; + isMuted: boolean; + className?: string | undefined; +}) { + return ( + <> + + + + Slack: + + ); +} diff --git a/apps/web/src/lib/threadChannel.test.ts b/apps/web/src/lib/threadChannel.test.ts new file mode 100644 index 000000000000..c09b7e0e9e3a --- /dev/null +++ b/apps/web/src/lib/threadChannel.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getThreadChannel, stripThreadChannelPrefix } from "./threadChannel"; + +describe("getThreadChannel", () => { + it("detects a Slack: prefix case-insensitively", () => { + expect(getThreadChannel("Slack: Can you look at the errors?")).toBe("slack"); + expect(getThreadChannel("slack: lower")).toBe("slack"); + expect(getThreadChannel(" SLACK : spaced")).toBe("slack"); + }); + + it("ignores titles that merely mention Slack", () => { + expect(getThreadChannel("Add Slack: integration")).toBeNull(); + expect(getThreadChannel("Slackline tricks")).toBeNull(); + expect(getThreadChannel("")).toBeNull(); + expect(getThreadChannel(null)).toBeNull(); + }); +}); + +describe("stripThreadChannelPrefix", () => { + it("removes the prefix and leading whitespace", () => { + expect(stripThreadChannelPrefix("Slack: Can you look?")).toBe("Can you look?"); + }); + + it("keeps the original title when only the prefix is present", () => { + expect(stripThreadChannelPrefix("Slack:")).toBe("Slack:"); + }); + + it("leaves non-channel titles untouched", () => { + expect(stripThreadChannelPrefix("Fix booking status")).toBe("Fix booking status"); + }); +}); diff --git a/apps/web/src/lib/threadChannel.ts b/apps/web/src/lib/threadChannel.ts new file mode 100644 index 000000000000..95806f093460 --- /dev/null +++ b/apps/web/src/lib/threadChannel.ts @@ -0,0 +1,23 @@ +/** + * Thread "channel" detection. + * + * Threads bridged in from an external service (the conversation gateway — + * Slack first) are titled with a channel prefix such as `Slack: …`. The + * sidebar uses this to decorate the row with a channel symbol so bridged + * conversations are recognisable at a glance. + */ +export type ThreadChannel = "slack"; + +const SLACK_TITLE_PREFIX = /^\s*slack\s*:/iu; + +export function getThreadChannel(title: string | null | undefined): ThreadChannel | null { + if (!title) return null; + if (SLACK_TITLE_PREFIX.test(title)) return "slack"; + return null; +} + +/** Title with the channel prefix removed (for variants that render the symbol instead of the word). */ +export function stripThreadChannelPrefix(title: string): string { + const stripped = title.replace(SLACK_TITLE_PREFIX, "").trimStart(); + return stripped.length > 0 ? stripped : title; +}