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 ? ( - - - - - Members - - {memberCount} - - - {huddleIndicator} - - - Manage channel - - - +
+ + + + + + + + Members + + {memberCount} + + + {huddleIndicator} + + + Manage channel + + + + {endActions} +
) : (
@@ -260,6 +265,8 @@ export function ChannelMembersBar({ Channel settings + + {endActions}
); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 0000000000..e4f8aa65e1 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index cb0600a28a..2a645ba387 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -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); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed4..beb9a992bc 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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 ? ( + + {panel} + + ) : ( + wrapAux(panel, "idle-auxiliary-panel") + ); const threadHeaderLeading = useSplitAuxiliaryPane ? ( ) : 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" >
- + {activeChannel ? ( + + ) : null} Viewing{" "} @@ -935,6 +966,22 @@ export const ChannelPane = React.memo(function ChannelPane({ ); return wrapAux(panel, "user-profile-panel"); })() + ) : idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel ? ( + wrapIdlePanel( + + {idleAuxiliaryPanel} + , + ) ) : null}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073..68fab10d48 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -46,6 +46,13 @@ export type ChannelPaneProps = { } | null; fetchOlder?: () => Promise; 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; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c7..b01e9f9d26 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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 diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5..223b69b809 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -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; diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 358a0e637b..44e4d891dc 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -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; 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 ? ( - +
+ + {headerEndActions} +
) : ( ) - ) : null; - const actions = activeChannel ? ( -
- {terminalButton} - {channelActions} -
- ) : null; + ) : ( + headerEndActions + ); + const actions = + terminalButton || channelActions ? ( +
+ {terminalButton} + {channelActions} +
+ ) : null; if (!showHeaderContent) { return null; @@ -173,6 +183,11 @@ export function ChannelScreenHeader({ testId="chat-header-dm-avatar" /> ) + ) : activeChannel ? ( + ) : undefined } statusBadge={ diff --git a/desktop/src/features/channels/ui/ChannelTypePicker.tsx b/desktop/src/features/channels/ui/ChannelTypePicker.tsx index 78e2c1080b..b69e382bbb 100644 --- a/desktop/src/features/channels/ui/ChannelTypePicker.tsx +++ b/desktop/src/features/channels/ui/ChannelTypePicker.tsx @@ -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 = { + 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["align"]; + allowProject?: boolean; ariaLabel?: string; className?: string; disabled?: boolean; + lifecycle: ChannelLifecycle; + onLifecycleChange: (lifecycle: Exclude) => 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 ? ( + + ) : ( + + )} {label} @@ -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);