mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
35
desktop/src/features/channels/lib/channelLifecycle.test.mjs
Normal file
35
desktop/src/features/channels/lib/channelLifecycle.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
23
desktop/src/features/channels/lib/channelLifecycle.ts
Normal file
23
desktop/src/features/channels/lib/channelLifecycle.ts
Normal 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";
|
||||
}
|
||||
29
desktop/src/features/channels/ui/ChannelGlyph.tsx
Normal file
29
desktop/src/features/channels/ui/ChannelGlyph.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
64
desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx
Normal file
64
desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
14
desktop/src/features/projects/lib/projectHomeChannel.ts
Normal file
14
desktop/src/features/projects/lib/projectHomeChannel.ts
Normal 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 ?? []);
|
||||
}
|
||||
20
desktop/src/features/projects/ui/ProjectChannelIcon.tsx
Normal file
20
desktop/src/features/projects/ui/ProjectChannelIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user