From efa64c661b012e23d43e9714d43fb9b9e3df16c6 Mon Sep 17 00:00:00 2001
From: Thomas Petersen
Date: Sat, 22 Aug 2026 21:26:05 -0400
Subject: [PATCH] feat(channels): support project-home channel surfaces
Teach the shared channel shell to identify project homes, host an idle auxiliary surface, and present project-aware channel chrome without changing normal channel behavior.
Signed-off-by: Thomas Petersen
---
.../channels/lib/channelLifecycle.test.mjs | 35 +++++++++
.../features/channels/lib/channelLifecycle.ts | 23 ++++++
.../src/features/channels/ui/ChannelGlyph.tsx | 29 ++++++++
.../channels/ui/ChannelManagementSheet.tsx | 25 +++----
.../ui/ChannelManagementSheetRows.tsx | 16 ++--
.../channels/ui/ChannelMembersBar.tsx | 73 ++++++++++---------
.../channels/ui/ChannelPane.helpers.test.mjs | 29 ++++++++
.../channels/ui/ChannelPane.helpers.ts | 9 ++-
.../src/features/channels/ui/ChannelPane.tsx | 55 +++++++++++++-
.../features/channels/ui/ChannelPane.types.ts | 9 +++
.../features/channels/ui/ChannelScreen.tsx | 11 +++
.../channels/ui/ChannelScreen.types.ts | 7 ++
.../channels/ui/ChannelScreenHeader.tsx | 47 ++++++++----
.../channels/ui/ChannelTypePicker.tsx | 59 +++++++++++----
.../channels/ui/ChannelTypeSettings.tsx | 44 ++++++++++-
.../channels/ui/FocusThreadDrawer.tsx | 5 +-
.../channels/ui/IdleAuxiliaryPanel.tsx | 64 ++++++++++++++++
.../channels/ui/RightAuxiliaryPane.tsx | 3 +
.../features/channels/ui/useChannelIntro.tsx | 15 +++-
.../messages/lib/timelineSnapshot.test.mjs | 2 +-
.../messages/ui/ChannelIntroBlock.tsx | 12 +--
.../projects/lib/projectHomeChannel.test.mjs | 25 +++++++
.../projects/lib/projectHomeChannel.ts | 14 ++++
.../projects/ui/ProjectChannelIcon.tsx | 20 +++++
desktop/tests/e2e/channels.spec.ts | 6 +-
25 files changed, 530 insertions(+), 107 deletions(-)
create mode 100644 desktop/src/features/channels/lib/channelLifecycle.test.mjs
create mode 100644 desktop/src/features/channels/lib/channelLifecycle.ts
create mode 100644 desktop/src/features/channels/ui/ChannelGlyph.tsx
create mode 100644 desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
create mode 100644 desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx
create mode 100644 desktop/src/features/projects/lib/projectHomeChannel.test.mjs
create mode 100644 desktop/src/features/projects/lib/projectHomeChannel.ts
create mode 100644 desktop/src/features/projects/ui/ProjectChannelIcon.tsx
diff --git a/desktop/src/features/channels/lib/channelLifecycle.test.mjs b/desktop/src/features/channels/lib/channelLifecycle.test.mjs
new file mode 100644
index 0000000000..0477aa7b77
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelLifecycle.test.mjs
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { channelLifecycle, channelLifecycleLabel } from "./channelLifecycle.ts";
+
+test("channelLifecycle prefers project home over TTL", () => {
+ assert.equal(
+ channelLifecycle({ projectHome: true, temporary: false }),
+ "project",
+ );
+ assert.equal(
+ channelLifecycle({ projectHome: true, temporary: true }),
+ "project",
+ );
+});
+
+test("channelLifecycle maps ongoing and temporary streams", () => {
+ assert.equal(
+ channelLifecycle({ projectHome: false, temporary: false }),
+ "ongoing",
+ );
+ assert.equal(
+ channelLifecycle({ projectHome: false, temporary: true }),
+ "temporary",
+ );
+});
+
+test("channelLifecycleLabel names project, ongoing, and temporary", () => {
+ assert.equal(channelLifecycleLabel("project", null), "Project");
+ assert.equal(channelLifecycleLabel("ongoing", null), "Ongoing");
+ assert.equal(
+ channelLifecycleLabel("temporary", 7 * 24 * 60 * 60),
+ "Temporary · 7d",
+ );
+});
diff --git a/desktop/src/features/channels/lib/channelLifecycle.ts b/desktop/src/features/channels/lib/channelLifecycle.ts
new file mode 100644
index 0000000000..0951767561
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelLifecycle.ts
@@ -0,0 +1,23 @@
+import { formatTtlDuration } from "@/features/channels/lib/ephemeralChannel";
+
+export type ChannelLifecycle = "ongoing" | "temporary" | "project";
+
+export function channelLifecycle(input: {
+ projectHome: boolean;
+ temporary: boolean;
+}): ChannelLifecycle {
+ if (input.projectHome) return "project";
+ return input.temporary ? "temporary" : "ongoing";
+}
+
+export function channelLifecycleLabel(
+ lifecycle: ChannelLifecycle,
+ ttlSeconds: number | null,
+): string {
+ if (lifecycle === "project") return "Project";
+ if (lifecycle === "temporary" && ttlSeconds != null) {
+ return `Temporary · ${formatTtlDuration(ttlSeconds)}`;
+ }
+ if (lifecycle === "temporary") return "Temporary";
+ return "Ongoing";
+}
diff --git a/desktop/src/features/channels/ui/ChannelGlyph.tsx b/desktop/src/features/channels/ui/ChannelGlyph.tsx
new file mode 100644
index 0000000000..fae767426f
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelGlyph.tsx
@@ -0,0 +1,29 @@
+import { FileText, Hash, Lock } from "lucide-react";
+
+import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
+import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon";
+import type { Channel } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+
+/** Stream/forum glyph for a channel, using the project mark on project homes. */
+export function ChannelGlyph({
+ channel,
+ className,
+}: {
+ channel: Pick;
+ className?: string;
+}) {
+ const projectHome = useIsProjectHomeChannel(channel.id);
+ const iconClass = cn("size-4 shrink-0", className);
+
+ if (projectHome) {
+ return ;
+ }
+ if (channel.visibility === "private") {
+ return ;
+ }
+ if (channel.channelType === "forum") {
+ return ;
+ }
+ return ;
+}
diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
index aea0f9323e..8a1aaf6a02 100644
--- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
+++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
@@ -24,10 +24,7 @@ import {
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelWorkflowsQuery } from "@/features/workflows/hooks";
-import {
- DEFAULT_EPHEMERAL_TTL_SECONDS,
- formatTtlDuration,
-} from "@/features/channels/lib/ephemeralChannel";
+import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel";
import type { Channel, ChannelMember, Workflow } from "@/shared/api/types";
import { useWorkflowEditorOverlay } from "@/shared/context/WorkflowEditorOverlayContext";
import { useFeatureEnabled } from "@/shared/features";
@@ -65,7 +62,10 @@ import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "./channelFormStyles";
-import { ChannelTypeSettings } from "./ChannelTypeSettings";
+import {
+ ChannelTypeDetailRow,
+ ChannelTypeSettings,
+} from "./ChannelTypeSettings";
import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings";
import {
ActionFieldRow,
@@ -555,6 +555,7 @@ export function ChannelManagementSheet({
data-testid="channel-management-lifecycle"
>
{
setIsEphemeralDraft(temporary);
@@ -778,16 +779,10 @@ function ChannelManagementPanelContent({
{resolvedChannel.channelType !== "dm" ? (
<>
-
void;
}) {
- const Icon = getChannelIcon(channel.channelType);
const channelDescription = channel.description.trim();
const description =
channelDescription || (onEdit ? "Add a description" : null);
@@ -42,7 +34,11 @@ export function ChannelHero({
data-testid="channel-management-hero"
>
-
+ {channel.channelType === "dm" ? (
+
+ ) : (
+
+ )}
{channel.channelType !== "dm" && onEdit ? (
@@ -71,15 +97,22 @@ export function ChannelTypePicker({
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
-
-
+
+ {allowProject ? (
+
+ Project
+
+ ) : null}
+
Ongoing
Temporary
diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx
index 6883f4cad1..82fd1c9f7a 100644
--- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx
+++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx
@@ -1,10 +1,16 @@
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import {
+ channelLifecycle,
+ channelLifecycleLabel,
+} from "@/features/channels/lib/channelLifecycle";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
+import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
+import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -13,6 +19,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
+import { EditableInfoFieldRow } from "./ChannelManagementSheetRows";
import { ChannelTypePicker } from "./ChannelTypePicker";
const EPHEMERAL_TIMEOUT_OPTIONS = [
@@ -32,7 +39,34 @@ const CHANNEL_TYPE_RESIZE_TRANSITION = {
ease: [0.23, 1, 0.32, 1],
} as const;
+export function ChannelTypeDetailRow({
+ canEdit,
+ channel,
+ onEdit,
+}: {
+ canEdit: boolean;
+ channel: Channel;
+ onEdit?: () => void;
+}) {
+ const projectHome = useIsProjectHomeChannel(channel.id);
+ const lifecycle = channelLifecycle({
+ projectHome,
+ temporary: channel.ttlSeconds !== null,
+ });
+
+ return (
+
+ );
+}
+
export function ChannelTypeSettings({
+ channelId,
disabled,
label = "Channel type",
onOpenChange,
@@ -43,6 +77,7 @@ export function ChannelTypeSettings({
testIdPrefix,
ttlSeconds,
}: {
+ channelId?: string | null;
disabled?: boolean;
label?: string;
onOpenChange?: (open: boolean) => void;
@@ -53,6 +88,8 @@ export function ChannelTypeSettings({
testIdPrefix: string;
ttlSeconds: number;
}) {
+ const projectHome = useIsProjectHomeChannel(channelId);
+ const lifecycle = channelLifecycle({ projectHome, temporary });
const shouldReduceMotion = useReducedMotion();
const channelTypeResizeTransition = shouldReduceMotion
? { duration: 0 }
@@ -82,17 +119,18 @@ export function ChannelTypeSettings({
{label}
onTemporaryChange(next === "temporary")}
onOpenChange={onOpenChange}
- onTemporaryChange={onTemporaryChange}
open={open}
- temporary={temporary}
testId={`${testIdPrefix}-channel-type`}
/>
- {temporary ? (
+ {temporary && !projectHome ? (
void;
};
@@ -139,6 +141,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
export function FocusThreadDrawer({
channelName,
children,
+ label = "Thread",
onClose,
}: FocusThreadDrawerProps) {
const prefersReducedMotion = useReducedMotion();
@@ -220,7 +223,7 @@ export function FocusThreadDrawer({
// see the token for why a `border-l` cannot.
"absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left",
)}
- aria-label="Thread"
+ aria-label={label}
data-testid="focus-thread-drawer"
ref={drawerRef}
role="complementary"
diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx
new file mode 100644
index 0000000000..f254efb098
--- /dev/null
+++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx
@@ -0,0 +1,64 @@
+import type * as React from "react";
+
+import {
+ AuxiliaryPanel,
+ AuxiliaryPanelBody,
+ AuxiliaryPanelHeader,
+ AuxiliaryPanelHeaderGroup,
+ AuxiliaryPanelTitle,
+} from "@/shared/layout/AuxiliaryPanel";
+
+export function IdleAuxiliaryPanel({
+ canResetWidth,
+ children,
+ isFocusDrawer = false,
+ isSinglePanelView,
+ onClose,
+ onResetWidth,
+ onResizeStart,
+ title,
+ useSplitAuxiliaryPane,
+ widthPx,
+}: {
+ canResetWidth: boolean;
+ children: React.ReactNode;
+ isFocusDrawer?: boolean;
+ isSinglePanelView: boolean;
+ onClose: () => void;
+ onResetWidth: () => void;
+ onResizeStart: React.PointerEventHandler;
+ title: string;
+ useSplitAuxiliaryPane: boolean;
+ widthPx: number;
+}) {
+ const split = useSplitAuxiliaryPane && !isFocusDrawer;
+ return (
+
+
+ {title}
+
+
+ }
+ >
+
+ {children}
+
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
index 43e4d80e6b..d46de8eb7d 100644
--- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
+++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
@@ -6,6 +6,7 @@ import { cn } from "@/shared/lib/cn";
type RightAuxiliaryPaneProps = {
canResetWidth: boolean;
children: React.ReactNode;
+ className?: string;
constrainToAvailableSpace?: boolean;
detached?: boolean;
onResetWidth: () => void;
@@ -17,6 +18,7 @@ type RightAuxiliaryPaneProps = {
export function RightAuxiliaryPane({
canResetWidth,
children,
+ className,
constrainToAvailableSpace = true,
detached = false,
onResetWidth,
@@ -31,6 +33,7 @@ export function RightAuxiliaryPane({
detached
? "bg-transparent"
: "before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']",
+ className,
)}
data-testid={testId}
style={{
diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx
index 19f2da0edb..910d836ff0 100644
--- a/desktop/src/features/channels/ui/useChannelIntro.tsx
+++ b/desktop/src/features/channels/ui/useChannelIntro.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Bot, Plus, Sparkles, UserPlus } from "lucide-react";
+import { Bot, FolderPlus, Plus, Sparkles, UserPlus } from "lucide-react";
import {
getChannelIntroDescription,
@@ -29,6 +29,7 @@ type ChannelIntroAction = {
export function useChannelIntro({
activeChannel,
onAddAgent,
+ onAddFiles,
onBrowseChannels,
onCreateChannel,
onOpenMembers,
@@ -36,6 +37,7 @@ export function useChannelIntro({
}: {
activeChannel: Channel | null;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
+ onAddFiles?: () => void;
onBrowseChannels?: () => void;
onCreateChannel?: () => void;
onOpenMembers?: () => void;
@@ -89,6 +91,16 @@ export function useChannelIntro({
}
if (!activeChannel.archivedAt && activeChannel.isMember) {
+ if (onAddFiles) {
+ actions.push({
+ description: "Add a repo.",
+ icon: ,
+ label: "Add files",
+ onClick: onAddFiles,
+ testId: "channel-intro-action-add-files",
+ });
+ }
+
if (onAddAgent) {
actions.push({
description: "Bring them in.",
@@ -119,6 +131,7 @@ export function useChannelIntro({
}, [
activeChannel,
onAddAgent,
+ onAddFiles,
onBrowseChannels,
onCreateChannel,
onOpenMembers,
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
index a0374fbe2b..ad09e4e6f4 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
+++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
@@ -423,7 +423,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
test("timeline-body-surface: first authoritative rows wait for deferred paint", () => {
// A newly selected populated channel has already resolved live rows, but the
// deferred snapshot is still empty. It has never committed a settled empty
- // surface, so showing its intro here would flash Create agent / Add people.
+ // surface, so showing its intro here would flash Add agent / Add people.
assert.equal(
selectTimelineBodySurface({
deferredCount: 0,
diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
index 76d1960b96..c69fd46a32 100644
--- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
+++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
@@ -63,7 +63,7 @@ export function ChannelIntroBlock({
) : null}
{intro.actions?.length ? (
-
+
{intro.actions.map((action) => {
const hasDescription = Boolean(action.description);
@@ -72,8 +72,8 @@ export function ChannelIntroBlock({
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
- ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
- : "h-28 w-64 flex-col rounded-2xl p-4",
+ ? "h-52 w-48 flex-col rounded-2xl p-3"
+ : "h-24 w-56 flex-col rounded-2xl p-3",
)}
data-testid={action.testId}
key={action.label}
@@ -84,8 +84,8 @@ export function ChannelIntroBlock({
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
hasDescription
- ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
- : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
+ ? "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5"
+ : "h-9 w-9 [&_svg]:h-4 [&_svg]:w-4",
)}
data-testid={
action.testId ? `${action.testId}-icon` : undefined
@@ -95,7 +95,7 @@ export function ChannelIntroBlock({
{
+ assert.equal(
+ isProjectHomeChannel("channel-a", [
+ { projectChannelId: "channel-a" },
+ { projectChannelId: "channel-b" },
+ ]),
+ true,
+ );
+});
+
+test("isProjectHomeChannel is false for unbound channels", () => {
+ assert.equal(
+ isProjectHomeChannel("channel-z", [{ projectChannelId: "channel-a" }]),
+ false,
+ );
+ assert.equal(
+ isProjectHomeChannel(null, [{ projectChannelId: "channel-a" }]),
+ false,
+ );
+});
diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts
new file mode 100644
index 0000000000..7cf49ff5e6
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectHomeChannel.ts
@@ -0,0 +1,14 @@
+import { useProjectsQuery } from "@/features/projects/hooks";
+
+export function isProjectHomeChannel(
+ channelId: string | null | undefined,
+ projects: ReadonlyArray<{ projectChannelId: string | null }>,
+): boolean {
+ if (!channelId) return false;
+ return projects.some((project) => project.projectChannelId === channelId);
+}
+
+export function useIsProjectHomeChannel(channelId: string | null | undefined) {
+ const projectsQuery = useProjectsQuery();
+ return isProjectHomeChannel(channelId, projectsQuery.data ?? []);
+}
diff --git a/desktop/src/features/projects/ui/ProjectChannelIcon.tsx b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx
new file mode 100644
index 0000000000..524003ad1e
--- /dev/null
+++ b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx
@@ -0,0 +1,20 @@
+import { Folders, Hash } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+
+/** Projects glyph with a small channel hash nested in the lower right. */
+export function ProjectChannelIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index 52d91dea3e..25513f6ad7 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -450,9 +450,9 @@ async function expectIntroActionCardLayout(
}
expect(actionBox.height).toBeGreaterThan(actionBox.width);
- expect(Math.round(actionBox.width)).toBe(220);
- expect(Math.round(iconBox.width)).toBe(48);
- expect(Math.round(iconBox.height)).toBe(48);
+ expect(Math.round(actionBox.width)).toBe(192);
+ expect(Math.round(iconBox.width)).toBe(40);
+ expect(Math.round(iconBox.height)).toBe(40);
const introIconRadius = await page
.getByTestId("message-channel-intro-icon")
.evaluate((element) => window.getComputedStyle(element).borderRadius);