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 <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-22 21:26:05 -04:00
parent 510dc6a8f3
commit efa64c661b
25 changed files with 530 additions and 107 deletions

View File

@@ -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",
);
});

View File

@@ -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";
}

View File

@@ -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<Channel, "channelType" | "id" | "visibility">;
className?: string;
}) {
const projectHome = useIsProjectHomeChannel(channel.id);
const iconClass = cn("size-4 shrink-0", className);
if (projectHome) {
return <ProjectChannelIcon className={iconClass} />;
}
if (channel.visibility === "private") {
return <Lock className={iconClass} />;
}
if (channel.channelType === "forum") {
return <FileText className={iconClass} />;
}
return <Hash className={iconClass} />;
}

View File

@@ -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"
>
<ChannelTypeSettings
channelId={resolvedChannel.id}
disabled={isSavingChannelEdits}
onTemporaryChange={(temporary) => {
setIsEphemeralDraft(temporary);
@@ -778,16 +779,10 @@ function ChannelManagementPanelContent({
<FieldGroup testId="channel-management-details" title="Details">
{resolvedChannel.channelType !== "dm" ? (
<>
<EditableInfoFieldRow
editTestId="channel-management-edit-channel-type"
label="Channel type"
onEdit={canEditChannel ? onOpenEdit : undefined}
testId="channel-management-type"
value={
resolvedChannel.ttlSeconds === null
? "Ongoing"
: `Temporary · ${formatTtlDuration(resolvedChannel.ttlSeconds)}`
}
<ChannelTypeDetailRow
canEdit={canEditChannel}
channel={resolvedChannel}
onEdit={onOpenEdit}
/>
<EditableInfoFieldRow
editTestId="channel-management-edit-visibility"

View File

@@ -2,8 +2,6 @@ import {
Check,
ChevronRight,
Copy,
FileText,
Hash,
Info,
MessageSquare,
Pencil,
@@ -12,18 +10,13 @@ import {
import * as React from "react";
import { toast } from "sonner";
import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { PanelSectionGroup } from "@/shared/ui/PanelSectionGroup";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
if (channelType === "forum") return FileText;
if (channelType === "dm") return MessageSquare;
return Hash;
}
export function ChannelHero({
channel,
onEdit,
@@ -31,7 +24,6 @@ export function ChannelHero({
channel: Channel;
onEdit?: () => 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"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Icon className="h-8 w-8" />
{channel.channelType === "dm" ? (
<MessageSquare className="h-8 w-8" />
) : (
<ChannelGlyph channel={channel} className="h-8 w-8" />
)}
</div>
{channel.channelType !== "dm" && onEdit ? (
<button

View File

@@ -35,6 +35,7 @@ import { AddChannelBotDialog } from "./AddChannelBotDialog";
type ChannelMembersBarProps = {
channel: Channel;
currentPubkey?: string;
endActions?: React.ReactNode;
isAddBotOpen?: boolean;
onAddBotOpenChange?: (open: boolean) => void;
onManageChannel: () => void;
@@ -45,6 +46,7 @@ type ChannelMembersBarProps = {
export function ChannelMembersBar({
channel,
currentPubkey,
endActions,
isAddBotOpen: isAddBotOpenProp,
onAddBotOpenChange,
onManageChannel,
@@ -189,39 +191,42 @@ export function ChannelMembersBar({
const controls =
variant === "compact" ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Channel actions"
data-testid="channel-actions-menu-trigger"
size="icon"
type="button"
variant="outline"
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" forceMount>
<DropdownMenuItem
data-testid="channel-members-trigger"
onSelect={onToggleMembers}
>
<Users />
<span>Members</span>
<span className="ml-auto text-xs text-muted-foreground">
{memberCount}
</span>
</DropdownMenuItem>
{huddleIndicator}
<DropdownMenuItem
data-testid="channel-management-trigger"
onSelect={onManageChannel}
>
<Settings2 />
<span>Manage channel</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-[6px]">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Channel actions"
data-testid="channel-actions-menu-trigger"
size="icon"
type="button"
variant="outline"
>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" forceMount>
<DropdownMenuItem
data-testid="channel-members-trigger"
onSelect={onToggleMembers}
>
<Users />
<span>Members</span>
<span className="ml-auto text-xs text-muted-foreground">
{memberCount}
</span>
</DropdownMenuItem>
{huddleIndicator}
<DropdownMenuItem
data-testid="channel-management-trigger"
onSelect={onManageChannel}
>
<Settings2 />
<span>Manage channel</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{endActions}
</div>
) : (
<div className="flex items-center gap-[6px]">
<Tooltip disableHoverableContent>
@@ -260,6 +265,8 @@ export function ChannelMembersBar({
</TooltipTrigger>
<TooltipContent>Channel settings</TooltipContent>
</Tooltip>
{endActions}
</div>
);

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getChannelIntroKind } from "./ChannelPane.helpers.ts";
function channel(overrides = {}) {
return {
ttlDeadline: null,
ttlSeconds: null,
visibility: "open",
...overrides,
};
}
test("getChannelIntroKind names project homes ahead of regular streams", () => {
assert.equal(getChannelIntroKind(channel(), true), "project channel");
assert.equal(getChannelIntroKind(channel(), false), "regular channel");
});
test("getChannelIntroKind keeps private and ephemeral labels for other streams", () => {
assert.equal(
getChannelIntroKind(channel({ visibility: "private" })),
"private channel",
);
assert.equal(
getChannelIntroKind(channel({ ttlSeconds: 3600 })),
"ephemeral channel",
);
});

View File

@@ -3,7 +3,14 @@ import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
export function getChannelIntroKind(channel: Channel): string {
export function getChannelIntroKind(
channel: Channel,
projectHome = false,
): string {
if (projectHome) {
return "project channel";
}
const isPrivate = channel.visibility === "private";
const isEphemeral = isEphemeralChannel(channel);

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Hash, LogIn } from "lucide-react";
import { LogIn } from "lucide-react";
import { AnimatePresence } from "motion/react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
@@ -27,6 +27,7 @@ import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeig
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
@@ -46,6 +47,7 @@ import {
import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner";
import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers";
import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle";
import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph";
import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
@@ -77,6 +79,8 @@ export const ChannelPane = React.memo(function ChannelPane({
editTarget = null,
fetchOlder,
header,
idleAuxiliaryPanel = null,
idleAuxiliaryTitle = "",
hasOlderMessages,
historyExhausted,
isFetchingOlder,
@@ -104,8 +108,10 @@ export const ChannelPane = React.memo(function ChannelPane({
onCloseAgentSession,
onCloseChannelManagement,
onChannelManagementDeleted,
onCloseIdleAuxiliaryPanel,
onCloseProfilePanel,
onAddAgent,
onAddFiles,
onBrowseChannels,
onCreateChannel,
onCloseThread,
@@ -396,6 +402,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const standardChannelIntro = useChannelIntro({
activeChannel,
onAddAgent,
onAddFiles,
onBrowseChannels,
onCreateChannel,
onOpenMembers,
@@ -457,9 +464,15 @@ export const ChannelPane = React.memo(function ChannelPane({
threadViewMode === "focus" &&
useSplitAuxiliaryPane &&
(Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
const useFocusIdleDrawer =
useSplitAuxiliaryPane &&
Boolean(idleAuxiliaryPanel) &&
Boolean(onCloseIdleAuxiliaryPanel);
const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
useFocusThreadDrawer,
onCloseThread,
useFocusThreadDrawer || useFocusIdleDrawer,
useFocusThreadDrawer
? onCloseThread
: (onCloseIdleAuxiliaryPanel ?? onCloseThread),
);
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
useThreadViewModeSwitch({
@@ -516,6 +529,19 @@ export const ChannelPane = React.memo(function ChannelPane({
) : (
wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY })
);
const wrapIdlePanel = (panel: React.ReactNode) =>
useFocusIdleDrawer && onCloseIdleAuxiliaryPanel ? (
<FocusThreadDrawer
channelName={activeChannel?.name ?? "channel"}
key="idle-auxiliary-surface"
label={idleAuxiliaryTitle || "Panel"}
onClose={onCloseIdleAuxiliaryPanel}
>
{panel}
</FocusThreadDrawer>
) : (
wrapAux(panel, "idle-auxiliary-panel")
);
const threadHeaderLeading = useSplitAuxiliaryPane ? (
<ThreadViewModeToggle onChange={changeThreadViewMode} />
) : undefined;
@@ -643,7 +669,12 @@ export const ChannelPane = React.memo(function ChannelPane({
className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-5 py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
<Hash className="h-4 w-4 shrink-0" />
{activeChannel ? (
<ChannelGlyph
channel={activeChannel}
className="h-4 w-4 shrink-0"
/>
) : null}
<span className="truncate">
Viewing{" "}
<span className="font-medium text-foreground">
@@ -935,6 +966,22 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapAux(panel, "user-profile-panel");
})()
) : idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel ? (
wrapIdlePanel(
<IdleAuxiliaryPanel
canResetWidth={canResetThreadPanelWidth}
isFocusDrawer={useFocusIdleDrawer}
isSinglePanelView={isSinglePanelView}
onClose={onCloseIdleAuxiliaryPanel}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
title={idleAuxiliaryTitle}
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
widthPx={threadPanelWidthPx}
>
{idleAuxiliaryPanel}
</IdleAuxiliaryPanel>,
)
) : null}
</AnimatePresence>
</div>

View File

@@ -46,6 +46,13 @@ export type ChannelPaneProps = {
} | null;
fetchOlder?: () => Promise<void>;
header?: React.ReactNode;
/**
* Idle-state body for the right auxiliary pane (project extras, etc.).
* Shown only when no thread, profile, agent session, or channel-management
* panel is open — the same slot as those panels.
*/
idleAuxiliaryPanel?: React.ReactNode;
idleAuxiliaryTitle?: string;
hasOlderMessages?: boolean;
/** True when the loaded window provably starts at the channel's beginning. */
historyExhausted?: boolean;
@@ -79,8 +86,10 @@ export type ChannelPaneProps = {
onCloseAgentSession: () => void;
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
onCloseIdleAuxiliaryPanel?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
onAddFiles?: () => void;
onBrowseChannels?: () => void;
onCreateChannel?: () => void;
onCloseThread: () => void;

View File

@@ -88,6 +88,11 @@ export function ChannelScreen({
autoSendDraftKey,
currentIdentity,
currentProfile,
headerEndActions,
idleAuxiliaryPanel,
idleAuxiliaryTitle,
onAddFiles,
onCloseIdleAuxiliaryPanel,
onCloseForumPost,
onSelectForumPost,
selectedForumPostId,
@@ -742,6 +747,7 @@ export function ChannelScreen({
activeDmPresenceStatus={activeDmPresenceStatus}
chromeWrapperRef={channelHeaderChromeRef}
currentPubkey={currentPubkey}
headerEndActions={headerEndActions}
isAddBotOpen={isAddBotOpen}
isJoining={joinChannelMutation.isPending}
onAddBotOpenChange={setIsAddBotOpen}
@@ -762,6 +768,7 @@ export function ChannelScreen({
activeDmPresenceStatus,
channelHeaderChromeRef,
currentPubkey,
headerEndActions,
isAddBotOpen,
joinChannelMutation.isPending,
joinChannelMutation.mutateAsync,
@@ -845,9 +852,12 @@ export function ChannelScreen({
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
header={channelHeader}
idleAuxiliaryPanel={idleAuxiliaryPanel}
idleAuxiliaryTitle={idleAuxiliaryTitle}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}
onAddAgent={handleOpenAddBot}
onAddFiles={onAddFiles}
onBrowseChannels={openBrowseChannels}
onCreateChannel={openCreateChannel}
onOpenMembers={handleOpenMembersSidebar}
@@ -900,6 +910,7 @@ export function ChannelScreen({
: undefined
}
onCloseChannelManagement={handleCloseChannelManagement}
onCloseIdleAuxiliaryPanel={onCloseIdleAuxiliaryPanel}
onCloseThread={handleCloseThread}
onDelete={
activeChannel?.archivedAt ? undefined : handleDelete

View File

@@ -1,3 +1,5 @@
import type { ReactNode } from "react";
import type {
Channel,
Identity,
@@ -16,6 +18,11 @@ export type ChannelScreenProps = {
autoSendDraftKey: string | null;
currentIdentity?: Identity;
currentProfile?: Profile;
idleAuxiliaryPanel?: ReactNode;
idleAuxiliaryTitle?: string;
headerEndActions?: ReactNode;
onAddFiles?: () => void;
onCloseIdleAuxiliaryPanel?: () => void;
onCloseForumPost: () => void;
onSelectForumPost: (postId: string) => void;
selectedForumPostId: string | null;

View File

@@ -6,6 +6,7 @@ import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralC
import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader";
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import {
@@ -38,6 +39,7 @@ type ChannelScreenHeaderProps = {
activeDmPresenceStatus: PresenceStatus | null;
chromeWrapperRef?: React.Ref<HTMLDivElement>;
currentPubkey?: string;
headerEndActions?: React.ReactNode;
isAddBotOpen?: boolean;
isJoining?: boolean;
showHeaderContent?: boolean;
@@ -58,6 +60,7 @@ export function ChannelScreenHeader({
activeDmPresenceStatus,
chromeWrapperRef,
currentPubkey,
headerEndActions,
isAddBotOpen,
isJoining = false,
onAddBotOpenChange,
@@ -95,19 +98,23 @@ export function ChannelScreenHeader({
) : null;
const channelActions = activeChannel ? (
showJoinButton ? (
<Button
disabled={isJoining}
onClick={() => void onJoinChannel()}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-4 w-4" />
{isJoining ? "Joining…" : "Join"}
</Button>
<div className="flex items-center gap-1">
<Button
disabled={isJoining}
onClick={() => void onJoinChannel()}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-4 w-4" />
{isJoining ? "Joining…" : "Join"}
</Button>
{headerEndActions}
</div>
) : (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
endActions={headerEndActions}
isAddBotOpen={isAddBotOpen}
onAddBotOpenChange={onAddBotOpenChange}
onManageChannel={onManageChannel}
@@ -115,13 +122,16 @@ export function ChannelScreenHeader({
variant={actionsVariant}
/>
)
) : null;
const actions = activeChannel ? (
<div className="flex items-center gap-1">
{terminalButton}
{channelActions}
</div>
) : null;
) : (
headerEndActions
);
const actions =
terminalButton || channelActions ? (
<div className="flex items-center gap-1">
{terminalButton}
{channelActions}
</div>
) : null;
if (!showHeaderContent) {
return null;
@@ -173,6 +183,11 @@ export function ChannelScreenHeader({
testId="chat-header-dm-avatar"
/>
)
) : activeChannel ? (
<ChannelGlyph
channel={activeChannel}
className="h-4 w-4 translate-y-px text-muted-foreground"
/>
) : undefined
}
statusBadge={

View File

@@ -1,6 +1,8 @@
import { ChevronDown, ClockFading, Hash } from "lucide-react";
import * as React from "react";
import type { ChannelLifecycle } from "@/features/channels/lib/channelLifecycle";
import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
@@ -11,37 +13,57 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
const LIFECYCLE_LABEL: Record<ChannelLifecycle, string> = {
ongoing: "Ongoing",
project: "Project",
temporary: "Temporary",
};
const LIFECYCLE_ICON = {
ongoing: Hash,
temporary: ClockFading,
} as const;
export function ChannelTypePicker({
align = "start",
allowProject = false,
ariaLabel,
className,
disabled,
lifecycle,
onLifecycleChange,
onOpenChange,
onTemporaryChange,
open,
temporary,
temporaryOptionAriaLabel = "Temporary channel",
testId,
}: {
align?: React.ComponentProps<typeof DropdownMenuContent>["align"];
allowProject?: boolean;
ariaLabel?: string;
className?: string;
disabled?: boolean;
lifecycle: ChannelLifecycle;
onLifecycleChange: (lifecycle: Exclude<ChannelLifecycle, "project">) => void;
onOpenChange?: (open: boolean) => void;
onTemporaryChange: (temporary: boolean) => void;
open?: boolean;
temporary: boolean;
temporaryOptionAriaLabel?: string;
testId?: string;
}) {
const [internalOpen, setInternalOpen] = React.useState(false);
const pickerOpen = open ?? internalOpen;
const setPickerOpen = onOpenChange ?? setInternalOpen;
const label = temporary ? "Temporary" : "Ongoing";
const Icon = temporary ? ClockFading : Hash;
const label = LIFECYCLE_LABEL[lifecycle];
const Icon = lifecycle === "project" ? null : LIFECYCLE_ICON[lifecycle];
const projectLocked = lifecycle === "project";
function selectType(nextType: string) {
onTemporaryChange(nextType === "temporary");
if (nextType === "project" || projectLocked) {
setPickerOpen(false);
return;
}
if (nextType === "temporary" || nextType === "ongoing") {
onLifecycleChange(nextType);
}
setPickerOpen(false);
}
@@ -59,7 +81,11 @@ export function ChannelTypePicker({
type="button"
variant="ghost"
>
<Icon className="h-4 w-4" />
{Icon ? (
<Icon className="h-4 w-4" />
) : (
<ProjectChannelIcon className="h-4 w-4" />
)}
{label}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
@@ -71,15 +97,22 @@ export function ChannelTypePicker({
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={selectType}
value={temporary ? "temporary" : "ongoing"}
>
<DropdownMenuRadioItem aria-label="Ongoing channel" value="ongoing">
<DropdownMenuRadioGroup onValueChange={selectType} value={lifecycle}>
{allowProject ? (
<DropdownMenuRadioItem aria-label="Project channel" value="project">
Project
</DropdownMenuRadioItem>
) : null}
<DropdownMenuRadioItem
aria-label="Ongoing channel"
disabled={projectLocked}
value="ongoing"
>
Ongoing
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
aria-label={temporaryOptionAriaLabel}
disabled={projectLocked}
value="temporary"
>
Temporary

View File

@@ -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 (
<EditableInfoFieldRow
editTestId="channel-management-edit-channel-type"
label="Channel type"
onEdit={canEdit ? onEdit : undefined}
testId="channel-management-type"
value={channelLifecycleLabel(lifecycle, channel.ttlSeconds)}
/>
);
}
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({
<span className="text-sm font-medium text-foreground">{label}</span>
<ChannelTypePicker
align="end"
allowProject={projectHome}
className="-mr-2.5"
disabled={disabled}
lifecycle={lifecycle}
onLifecycleChange={(next) => onTemporaryChange(next === "temporary")}
onOpenChange={onOpenChange}
onTemporaryChange={onTemporaryChange}
open={open}
temporary={temporary}
testId={`${testIdPrefix}-channel-type`}
/>
</div>
<AnimatePresence initial={false}>
{temporary ? (
{temporary && !projectHome ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"

View File

@@ -11,6 +11,8 @@ import { cn } from "@/shared/lib/cn";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
/** Accessible name for the drawer. Channel threads leave the default. */
label?: string;
onClose: () => 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"

View File

@@ -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<HTMLButtonElement>;
title: string;
useSplitAuxiliaryPane: boolean;
widthPx: number;
}) {
const split = useSplitAuxiliaryPane && !isFocusDrawer;
return (
<AuxiliaryPanel
canResetWidth={canResetWidth}
enterMotion={!isFocusDrawer}
isSinglePanelView={
isFocusDrawer ? true : split ? false : isSinglePanelView
}
layout={split ? "split" : "standalone"}
onClose={onClose}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
resizeHandleAriaLabel="Resize panel"
resizeHandleTestId="idle-auxiliary-resize-handle"
testId="idle-auxiliary-panel"
transparentChrome={split}
widthPx={widthPx}
header={
<AuxiliaryPanelHeader backdrop={isFocusDrawer} transparent={split}>
<AuxiliaryPanelHeaderGroup>
<AuxiliaryPanelTitle>{title}</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
</AuxiliaryPanelHeader>
}
>
<AuxiliaryPanelBody className="overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-8">
{children}
</AuxiliaryPanelBody>
</AuxiliaryPanel>
);
}

View File

@@ -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={{

View File

@@ -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: <FolderPlus aria-hidden className="h-5 w-5" />,
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,

View File

@@ -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,

View File

@@ -63,7 +63,7 @@ export function ChannelIntroBlock({
</p>
) : null}
{intro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto p-1">
<div className="mt-4 flex max-w-full flex-nowrap gap-2 overflow-x-auto p-1">
{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({
</span>
<span className="mt-auto min-w-0">
<span
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
className="block whitespace-normal break-words text-sm font-medium leading-5 text-foreground"
data-testid={
action.testId ? `${action.testId}-title` : undefined
}

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isProjectHomeChannel } from "./projectHomeChannel.ts";
test("isProjectHomeChannel is true when a project points at the channel", () => {
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,
);
});

View File

@@ -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 ?? []);
}

View File

@@ -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 (
<span
aria-hidden="true"
className={cn("relative inline-flex size-4 shrink-0", className)}
data-testid="project-channel-icon"
>
<Folders className="!size-full" />
<Hash
className="pointer-events-none absolute -bottom-px -right-px !size-[62.5%]"
strokeWidth={2.5}
/>
</span>
);
}

View File

@@ -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);