mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
const AgentsView = React.lazy(async () => {
|
||||
@@ -9,7 +10,8 @@ const AgentsView = React.lazy(async () => {
|
||||
|
||||
export function AgentsScreen() {
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TopChromeBackdrop />
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
|
||||
<AgentsView />
|
||||
</React.Suspense>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
extractPromptText,
|
||||
parsePromptText,
|
||||
} from "./agentSessionTranscriptHelpers.ts";
|
||||
|
||||
const HEX = "a".repeat(64);
|
||||
const HEX_UPPER = "A".repeat(64);
|
||||
|
||||
// --- parsePromptText: no section headers ---
|
||||
|
||||
test("parsePromptText returns the empty/Prompt fallback for whitespace-only input", () => {
|
||||
// The early `sections.length === 0` branch only fires when there are no
|
||||
// section bodies at all (e.g. empty/whitespace input).
|
||||
const result = parsePromptText(" ");
|
||||
assert.deepEqual(result, {
|
||||
sections: [],
|
||||
userText: "",
|
||||
userTitle: "Prompt",
|
||||
userPubkey: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("parsePromptText wraps header-less free text in a single Prompt section", () => {
|
||||
// Free text with no `[header]` becomes one "Prompt" section. Since no
|
||||
// section is a "Sprout event", there is no event content to surface, so
|
||||
// userText is empty and the title falls through to "Sprout event".
|
||||
const result = parsePromptText("just some free text");
|
||||
assert.deepEqual(
|
||||
result.sections.map((s) => s.title),
|
||||
["Prompt"],
|
||||
);
|
||||
assert.equal(result.sections[0].body, "just some free text");
|
||||
assert.equal(result.userText, "");
|
||||
assert.equal(result.userTitle, "Sprout event");
|
||||
assert.equal(result.userPubkey, null);
|
||||
});
|
||||
|
||||
// --- parsePromptText: Sprout event section ---
|
||||
|
||||
test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () => {
|
||||
const text = [
|
||||
"[System]",
|
||||
"system preamble here",
|
||||
"",
|
||||
"[Sprout event: @mention]",
|
||||
"Channel: demo",
|
||||
`From: Wes (hex: ${HEX})`,
|
||||
"Content: hello @Brain please look",
|
||||
].join("\n");
|
||||
|
||||
const result = parsePromptText(text);
|
||||
|
||||
assert.equal(result.userText, "hello @Brain please look");
|
||||
assert.equal(result.userPubkey, HEX);
|
||||
// titleCase capitalizes after word boundaries but leaves the leading "@"
|
||||
// (a non-word char) in place: "@mention" -> "@Mention".
|
||||
assert.equal(result.userTitle, "@Mention");
|
||||
// Both headers become sections.
|
||||
assert.deepEqual(
|
||||
result.sections.map((s) => s.title),
|
||||
["System", "Sprout event: @mention"],
|
||||
);
|
||||
});
|
||||
|
||||
test("parsePromptText lowercases the extracted hex pubkey", () => {
|
||||
const text = [
|
||||
"[Sprout event: dm]",
|
||||
`From: Someone (hex: ${HEX_UPPER})`,
|
||||
"Content: hi",
|
||||
].join("\n");
|
||||
|
||||
const result = parsePromptText(text);
|
||||
assert.equal(result.userPubkey, HEX);
|
||||
});
|
||||
|
||||
test("parsePromptText yields a null pubkey when From has no hex", () => {
|
||||
const text = ["[Sprout event: note]", "From: Someone", "Content: hi"].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
const result = parsePromptText(text);
|
||||
assert.equal(result.userPubkey, null);
|
||||
assert.equal(result.userText, "hi");
|
||||
assert.equal(result.userTitle, "Note");
|
||||
});
|
||||
|
||||
test("parsePromptText defaults the title to 'Sprout event' when no kind is present", () => {
|
||||
const text = ["[Sprout event]", "Content: x"].join("\n");
|
||||
const result = parsePromptText(text);
|
||||
assert.equal(result.userTitle, "Sprout event");
|
||||
});
|
||||
|
||||
test("parsePromptText leading text before a header becomes a Prompt section", () => {
|
||||
const text = ["preamble line", "[Other]", "body"].join("\n");
|
||||
const result = parsePromptText(text);
|
||||
assert.deepEqual(
|
||||
result.sections.map((s) => s.title),
|
||||
["Prompt", "Other"],
|
||||
);
|
||||
});
|
||||
|
||||
// --- extractPromptText ---
|
||||
|
||||
test("extractPromptText joins text blocks from params.prompt", () => {
|
||||
const payload = {
|
||||
params: {
|
||||
prompt: [{ text: "line one" }, { text: "line two" }],
|
||||
},
|
||||
};
|
||||
assert.equal(extractPromptText(payload), "line one\nline two");
|
||||
});
|
||||
|
||||
test("extractPromptText handles plain string blocks", () => {
|
||||
const payload = { params: { prompt: ["a", "b"] } };
|
||||
assert.equal(extractPromptText(payload), "a\nb");
|
||||
});
|
||||
|
||||
test("extractPromptText returns empty string when prompt is missing or not an array", () => {
|
||||
assert.equal(extractPromptText({}), "");
|
||||
assert.equal(extractPromptText({ params: { prompt: "nope" } }), "");
|
||||
});
|
||||
@@ -204,7 +204,10 @@ export function AgentSessionThreadPanel({
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto px-3 pb-4",
|
||||
isOverlay ? "pt-4" : "pt-[76px]",
|
||||
// Match MessageThreadPanel: single-panel mode has a 76px header
|
||||
// (min-h-[76px] with -mb-[76px]), so the body must clear it. Only
|
||||
// the floating overlay (44px header) uses the smaller pt-4.
|
||||
isSinglePanelView ? "pt-[76px]" : isOverlay ? "pt-4" : "pt-[76px]",
|
||||
)}
|
||||
>
|
||||
<ManagedAgentSessionPanel
|
||||
|
||||
@@ -507,6 +507,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getContextMessageDepth,
|
||||
getReactionTargetId,
|
||||
matchesInboxFilter,
|
||||
} from "./inboxViewHelpers.ts";
|
||||
|
||||
// --- matchesInboxFilter ---
|
||||
|
||||
test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => {
|
||||
assert.equal(matchesInboxFilter({ categories: [] }, "all"), true);
|
||||
assert.equal(matchesInboxFilter({ categories: ["mentions"] }, "all"), true);
|
||||
});
|
||||
|
||||
test("matchesInboxFilter matches when the category is present", () => {
|
||||
assert.equal(
|
||||
matchesInboxFilter({ categories: ["mentions", "activity"] }, "mentions"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("matchesInboxFilter is false when the category is absent", () => {
|
||||
assert.equal(
|
||||
matchesInboxFilter({ categories: ["activity"] }, "mentions"),
|
||||
false,
|
||||
);
|
||||
assert.equal(matchesInboxFilter({ categories: [] }, "mentions"), false);
|
||||
});
|
||||
|
||||
// --- getReactionTargetId ---
|
||||
|
||||
test("getReactionTargetId returns the last e-tag target id", () => {
|
||||
const tags = [
|
||||
["e", "first"],
|
||||
["p", "somebody"],
|
||||
["e", "second"],
|
||||
];
|
||||
assert.equal(getReactionTargetId(tags), "second");
|
||||
});
|
||||
|
||||
test("getReactionTargetId returns null when there is no e-tag", () => {
|
||||
assert.equal(getReactionTargetId([["p", "somebody"]]), null);
|
||||
assert.equal(getReactionTargetId([]), null);
|
||||
});
|
||||
|
||||
test("getReactionTargetId ignores e-tags with a missing/non-string id", () => {
|
||||
// Trailing malformed e-tag should be skipped in favor of the valid one.
|
||||
const tags = [["e", "valid"], ["e"]];
|
||||
assert.equal(getReactionTargetId(tags), "valid");
|
||||
});
|
||||
|
||||
// --- getContextMessageDepth ---
|
||||
|
||||
function event(id, parentId) {
|
||||
// A "reply" e-tag is how getThreadReference resolves a parent.
|
||||
const tags = parentId ? [["e", parentId, "", "reply"]] : [];
|
||||
return {
|
||||
id,
|
||||
pubkey: "x",
|
||||
created_at: 0,
|
||||
kind: 9,
|
||||
tags,
|
||||
content: "",
|
||||
sig: "",
|
||||
};
|
||||
}
|
||||
|
||||
test("getContextMessageDepth is 0 for a root message", () => {
|
||||
const root = event("root", null);
|
||||
const map = new Map([[root.id, root]]);
|
||||
assert.equal(getContextMessageDepth(root, map), 0);
|
||||
});
|
||||
|
||||
test("getContextMessageDepth counts ancestors present in the map", () => {
|
||||
const root = event("root", null);
|
||||
const mid = event("mid", "root");
|
||||
const leaf = event("leaf", "mid");
|
||||
const map = new Map([
|
||||
[root.id, root],
|
||||
[mid.id, mid],
|
||||
[leaf.id, leaf],
|
||||
]);
|
||||
assert.equal(getContextMessageDepth(leaf, map), 2);
|
||||
assert.equal(getContextMessageDepth(mid, map), 1);
|
||||
});
|
||||
|
||||
test("getContextMessageDepth stops when a parent is missing from the map", () => {
|
||||
// leaf -> mid (present) -> absent root. Depth counts only the present hop.
|
||||
const mid = event("mid", "absent-root");
|
||||
const leaf = event("leaf", "mid");
|
||||
const map = new Map([
|
||||
[mid.id, mid],
|
||||
[leaf.id, leaf],
|
||||
]);
|
||||
assert.equal(getContextMessageDepth(leaf, map), 1);
|
||||
});
|
||||
|
||||
test("getContextMessageDepth does not loop forever on a cycle", () => {
|
||||
// a -> b -> a. The `seen` set must terminate the walk.
|
||||
const a = event("a", "b");
|
||||
const b = event("b", "a");
|
||||
const map = new Map([
|
||||
[a.id, a],
|
||||
[b.id, b],
|
||||
]);
|
||||
// From a: hop to b (depth 1); b's parent is a, already seen -> stop.
|
||||
assert.equal(getContextMessageDepth(a, map), 1);
|
||||
});
|
||||
@@ -230,12 +230,18 @@ export function HomeView({
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't default-select before the width is measured: at width 0
|
||||
// isNarrowHomeViewport is false, so narrow Home would cold-load into detail.
|
||||
if (homeInboxWidthPx === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filteredItems.some((item) => item.id === selectedItemId)) {
|
||||
setSelectedItemId(
|
||||
isNarrowHomeViewport ? null : (filteredItems[0]?.id ?? null),
|
||||
);
|
||||
}
|
||||
}, [filteredItems, isNarrowHomeViewport, selectedItemId]);
|
||||
}, [filteredItems, homeInboxWidthPx, isNarrowHomeViewport, selectedItemId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void selectedItemId;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -218,10 +219,7 @@ export function InboxDetailPane({
|
||||
ref={detailPaneRef}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35"
|
||||
/>
|
||||
<TopChromeBackdrop className="h-[76px]" />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-[42px] z-40 min-h-[32px] py-[4px] pr-3",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -46,10 +47,7 @@ export function InboxListPane({
|
||||
|
||||
return (
|
||||
<section className="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35"
|
||||
/>
|
||||
<TopChromeBackdrop className="h-[76px]" />
|
||||
<div className="absolute inset-x-0 top-[42px] z-40 min-h-[32px] px-5 py-[4px]">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-[6px]">
|
||||
|
||||
@@ -53,6 +53,14 @@ type UserProfilePanelProps = {
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
pubkey: string;
|
||||
/**
|
||||
* When true, the panel sits beside a sibling pane managed by a single-panel
|
||||
* width controller (ChannelScreen). The width is clamped so the sibling keeps
|
||||
* at least THREAD_PANEL_MIN_WIDTH_PX. Standalone/floating mounts (e.g. Pulse)
|
||||
* have no such sibling, so they omit this and use the configured width
|
||||
* directly — otherwise `calc(100% - 300px)` would wrongly shrink the panel.
|
||||
*/
|
||||
splitPaneClamp?: boolean;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
@@ -92,6 +100,7 @@ export function UserProfilePanel({
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
pubkey,
|
||||
splitPaneClamp = false,
|
||||
widthPx,
|
||||
}: UserProfilePanelProps) {
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
@@ -203,7 +212,9 @@ export function UserProfilePanel({
|
||||
style={{
|
||||
width: isSinglePanelView
|
||||
? "100%"
|
||||
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
|
||||
: splitPaneClamp
|
||||
? `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
|
||||
: `${widthPx}px`,
|
||||
}}
|
||||
>
|
||||
{!isOverlay && !isSinglePanelView && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { isSafeUrl } from "@/shared/lib/url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
function CloneUrlRow({ url }: { url: string }) {
|
||||
@@ -123,108 +124,111 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
onClick={() => {
|
||||
void goProjects();
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TopChromeBackdrop />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
onClick={() => {
|
||||
void goProjects();
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">{project.name}</h2>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.description}
|
||||
</p>
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">{project.name}</h2>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.description}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{project.cloneUrls.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Clone
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{project.cloneUrls.map((url) => (
|
||||
<CloneUrlRow key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{project.webUrl && isSafeUrl(project.webUrl) ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Web
|
||||
</h3>
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
href={project.webUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{project.webUrl}
|
||||
</a>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{project.contributors.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Contributors ({project.contributors.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{project.contributors.map((pubkey) => {
|
||||
const label = resolveUserLabel({ pubkey, profiles });
|
||||
const avatarUrl =
|
||||
profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5"
|
||||
key={pubkey}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{project.cloneUrls.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Clone
|
||||
Details
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{project.cloneUrls.map((url) => (
|
||||
<CloneUrlRow key={url} url={url} />
|
||||
))}
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>Created: {createdDate}</p>
|
||||
<p className="truncate">
|
||||
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{project.webUrl && isSafeUrl(project.webUrl) ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Web
|
||||
</h3>
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
href={project.webUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{project.webUrl}
|
||||
</a>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{project.contributors.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Contributors ({project.contributors.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{project.contributors.map((pubkey) => {
|
||||
const label = resolveUserLabel({ pubkey, profiles });
|
||||
const avatarUrl =
|
||||
profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5"
|
||||
key={pubkey}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Details
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>Created: {createdDate}</p>
|
||||
<p className="truncate">
|
||||
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
|
||||
export function ProjectsScreen() {
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TopChromeBackdrop />
|
||||
<ProjectsView />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
|
||||
type WorkflowsViewProps = {
|
||||
channels: Channel[];
|
||||
@@ -162,9 +163,10 @@ export function WorkflowsView({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 overflow-hidden"
|
||||
className="relative flex min-h-0 flex-1 overflow-hidden"
|
||||
data-testid="workflows-view"
|
||||
>
|
||||
<TopChromeBackdrop />
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-14"
|
||||
data-scroll-restoration-id="workflows-list"
|
||||
|
||||
@@ -13,9 +13,13 @@ export const PANEL_BASE_CLASS =
|
||||
* Starts below the fixed top chrome (window drag region + global search and
|
||||
* channel actions, ~44px tall) so the panel header doesn't collide with it at
|
||||
* narrow widths. Matches the inline layout where the header sits below chrome.
|
||||
*
|
||||
* `h-auto` overrides the base `h-full`: with `fixed top-11 bottom-0`, height is
|
||||
* driven by the top/bottom insets. Without it, `h-full` resolves to 100vh and
|
||||
* the panel hangs below the viewport (100vh tall, but starting 44px down).
|
||||
*/
|
||||
export const PANEL_OVERLAY_CLASS =
|
||||
"fixed bottom-0 right-0 top-11 z-40 shadow-xl max-w-[calc(100vw-2rem)]";
|
||||
"fixed bottom-0 right-0 top-11 z-40 h-auto shadow-xl max-w-[calc(100vw-2rem)]";
|
||||
|
||||
/**
|
||||
* Single-column panel headers should render above the local panel backdrop
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
/**
|
||||
* Blurred strip pinned to the top of a scroll container so content scrolls
|
||||
* *under* the global search chrome instead of showing through it.
|
||||
*
|
||||
* Render as the first child of a `relative` (non-scrolling) parent, alongside
|
||||
* the scrollable content. It is purely decorative (aria-hidden,
|
||||
* pointer-events-none) and sits at z-40 — below the global chrome controls
|
||||
* (z-[45]) but above page content.
|
||||
*
|
||||
* Pass the height via `className` (e.g. `h-10` for pages with no sub-header,
|
||||
* `h-[76px]` for panels whose own header occupies the top). `cn`/twMerge lets
|
||||
* the passed height override the default.
|
||||
*/
|
||||
export function TopChromeBackdrop({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-40 h-10 bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user