Desktop #806 follow-ups: panel/inbox fixes + top-bar backdrop (#814)

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:
Wes
2026-06-02 08:45:04 -07:00
committed by GitHub
co-authored by Brain
parent 5bed17a117
commit a25ca5d1bf
14 changed files with 400 additions and 109 deletions
@@ -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);
});
+7 -1
View File
@@ -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,
)}
/>
);
}