Skip to content

Commit a25ca5d

Browse files
wesbillmanBrain
andauthored
Desktop #806 follow-ups: panel/inbox fixes + top-bar backdrop (#814)
Signed-off-by: Wes <[email protected]> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
1 parent 5bed17a commit a25ca5d

14 files changed

Lines changed: 397 additions & 106 deletions

‎desktop/src/features/agents/ui/AgentsScreen.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from "react";
22

3+
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
34
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
45

56
const AgentsView = React.lazy(async () => {
@@ -9,7 +10,8 @@ const AgentsView = React.lazy(async () => {
910

1011
export function AgentsScreen() {
1112
return (
12-
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
13+
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
14+
<TopChromeBackdrop />
1315
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
1416
<AgentsView />
1517
</React.Suspense>
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import {
5+
extractPromptText,
6+
parsePromptText,
7+
} from "./agentSessionTranscriptHelpers.ts";
8+
9+
const HEX = "a".repeat(64);
10+
const HEX_UPPER = "A".repeat(64);
11+
12+
// --- parsePromptText: no section headers ---
13+
14+
test("parsePromptText returns the empty/Prompt fallback for whitespace-only input", () => {
15+
// The early `sections.length === 0` branch only fires when there are no
16+
// section bodies at all (e.g. empty/whitespace input).
17+
const result = parsePromptText(" ");
18+
assert.deepEqual(result, {
19+
sections: [],
20+
userText: "",
21+
userTitle: "Prompt",
22+
userPubkey: null,
23+
});
24+
});
25+
26+
test("parsePromptText wraps header-less free text in a single Prompt section", () => {
27+
// Free text with no `[header]` becomes one "Prompt" section. Since no
28+
// section is a "Sprout event", there is no event content to surface, so
29+
// userText is empty and the title falls through to "Sprout event".
30+
const result = parsePromptText("just some free text");
31+
assert.deepEqual(
32+
result.sections.map((s) => s.title),
33+
["Prompt"],
34+
);
35+
assert.equal(result.sections[0].body, "just some free text");
36+
assert.equal(result.userText, "");
37+
assert.equal(result.userTitle, "Sprout event");
38+
assert.equal(result.userPubkey, null);
39+
});
40+
41+
// --- parsePromptText: Sprout event section ---
42+
43+
test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () => {
44+
const text = [
45+
"[System]",
46+
"system preamble here",
47+
"",
48+
"[Sprout event: @mention]",
49+
"Channel: demo",
50+
`From: Wes (hex: ${HEX})`,
51+
"Content: hello @Brain please look",
52+
].join("\n");
53+
54+
const result = parsePromptText(text);
55+
56+
assert.equal(result.userText, "hello @Brain please look");
57+
assert.equal(result.userPubkey, HEX);
58+
// titleCase capitalizes after word boundaries but leaves the leading "@"
59+
// (a non-word char) in place: "@mention" -> "@Mention".
60+
assert.equal(result.userTitle, "@Mention");
61+
// Both headers become sections.
62+
assert.deepEqual(
63+
result.sections.map((s) => s.title),
64+
["System", "Sprout event: @mention"],
65+
);
66+
});
67+
68+
test("parsePromptText lowercases the extracted hex pubkey", () => {
69+
const text = [
70+
"[Sprout event: dm]",
71+
`From: Someone (hex: ${HEX_UPPER})`,
72+
"Content: hi",
73+
].join("\n");
74+
75+
const result = parsePromptText(text);
76+
assert.equal(result.userPubkey, HEX);
77+
});
78+
79+
test("parsePromptText yields a null pubkey when From has no hex", () => {
80+
const text = ["[Sprout event: note]", "From: Someone", "Content: hi"].join(
81+
"\n",
82+
);
83+
84+
const result = parsePromptText(text);
85+
assert.equal(result.userPubkey, null);
86+
assert.equal(result.userText, "hi");
87+
assert.equal(result.userTitle, "Note");
88+
});
89+
90+
test("parsePromptText defaults the title to 'Sprout event' when no kind is present", () => {
91+
const text = ["[Sprout event]", "Content: x"].join("\n");
92+
const result = parsePromptText(text);
93+
assert.equal(result.userTitle, "Sprout event");
94+
});
95+
96+
test("parsePromptText leading text before a header becomes a Prompt section", () => {
97+
const text = ["preamble line", "[Other]", "body"].join("\n");
98+
const result = parsePromptText(text);
99+
assert.deepEqual(
100+
result.sections.map((s) => s.title),
101+
["Prompt", "Other"],
102+
);
103+
});
104+
105+
// --- extractPromptText ---
106+
107+
test("extractPromptText joins text blocks from params.prompt", () => {
108+
const payload = {
109+
params: {
110+
prompt: [{ text: "line one" }, { text: "line two" }],
111+
},
112+
};
113+
assert.equal(extractPromptText(payload), "line one\nline two");
114+
});
115+
116+
test("extractPromptText handles plain string blocks", () => {
117+
const payload = { params: { prompt: ["a", "b"] } };
118+
assert.equal(extractPromptText(payload), "a\nb");
119+
});
120+
121+
test("extractPromptText returns empty string when prompt is missing or not an array", () => {
122+
assert.equal(extractPromptText({}), "");
123+
assert.equal(extractPromptText({ params: { prompt: "nope" } }), "");
124+
});

‎desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,10 @@ export function AgentSessionThreadPanel({
204204
onScroll={onScroll}
205205
className={cn(
206206
"min-h-0 flex-1 overflow-y-auto px-3 pb-4",
207-
isOverlay ? "pt-4" : "pt-[76px]",
207+
// Match MessageThreadPanel: single-panel mode has a 76px header
208+
// (min-h-[76px] with -mb-[76px]), so the body must clear it. Only
209+
// the floating overlay (44px header) uses the smaller pt-4.
210+
isSinglePanelView ? "pt-[76px]" : isOverlay ? "pt-4" : "pt-[76px]",
208211
)}
209212
>
210213
<ManagedAgentSessionPanel

‎desktop/src/features/channels/ui/ChannelPane.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ export const ChannelPane = React.memo(function ChannelPane({
507507
onResetWidth={onResetThreadPanelWidth}
508508
onResizeStart={onThreadPanelResizeStart}
509509
pubkey={profilePanelPubkey}
510+
splitPaneClamp
510511
widthPx={threadPanelWidthPx}
511512
/>
512513
) : null}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import {
5+
getContextMessageDepth,
6+
getReactionTargetId,
7+
matchesInboxFilter,
8+
} from "./inboxViewHelpers.ts";
9+
10+
// --- matchesInboxFilter ---
11+
12+
test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => {
13+
assert.equal(matchesInboxFilter({ categories: [] }, "all"), true);
14+
assert.equal(matchesInboxFilter({ categories: ["mentions"] }, "all"), true);
15+
});
16+
17+
test("matchesInboxFilter matches when the category is present", () => {
18+
assert.equal(
19+
matchesInboxFilter({ categories: ["mentions", "activity"] }, "mentions"),
20+
true,
21+
);
22+
});
23+
24+
test("matchesInboxFilter is false when the category is absent", () => {
25+
assert.equal(
26+
matchesInboxFilter({ categories: ["activity"] }, "mentions"),
27+
false,
28+
);
29+
assert.equal(matchesInboxFilter({ categories: [] }, "mentions"), false);
30+
});
31+
32+
// --- getReactionTargetId ---
33+
34+
test("getReactionTargetId returns the last e-tag target id", () => {
35+
const tags = [
36+
["e", "first"],
37+
["p", "somebody"],
38+
["e", "second"],
39+
];
40+
assert.equal(getReactionTargetId(tags), "second");
41+
});
42+
43+
test("getReactionTargetId returns null when there is no e-tag", () => {
44+
assert.equal(getReactionTargetId([["p", "somebody"]]), null);
45+
assert.equal(getReactionTargetId([]), null);
46+
});
47+
48+
test("getReactionTargetId ignores e-tags with a missing/non-string id", () => {
49+
// Trailing malformed e-tag should be skipped in favor of the valid one.
50+
const tags = [["e", "valid"], ["e"]];
51+
assert.equal(getReactionTargetId(tags), "valid");
52+
});
53+
54+
// --- getContextMessageDepth ---
55+
56+
function event(id, parentId) {
57+
// A "reply" e-tag is how getThreadReference resolves a parent.
58+
const tags = parentId ? [["e", parentId, "", "reply"]] : [];
59+
return {
60+
id,
61+
pubkey: "x",
62+
created_at: 0,
63+
kind: 9,
64+
tags,
65+
content: "",
66+
sig: "",
67+
};
68+
}
69+
70+
test("getContextMessageDepth is 0 for a root message", () => {
71+
const root = event("root", null);
72+
const map = new Map([[root.id, root]]);
73+
assert.equal(getContextMessageDepth(root, map), 0);
74+
});
75+
76+
test("getContextMessageDepth counts ancestors present in the map", () => {
77+
const root = event("root", null);
78+
const mid = event("mid", "root");
79+
const leaf = event("leaf", "mid");
80+
const map = new Map([
81+
[root.id, root],
82+
[mid.id, mid],
83+
[leaf.id, leaf],
84+
]);
85+
assert.equal(getContextMessageDepth(leaf, map), 2);
86+
assert.equal(getContextMessageDepth(mid, map), 1);
87+
});
88+
89+
test("getContextMessageDepth stops when a parent is missing from the map", () => {
90+
// leaf -> mid (present) -> absent root. Depth counts only the present hop.
91+
const mid = event("mid", "absent-root");
92+
const leaf = event("leaf", "mid");
93+
const map = new Map([
94+
[mid.id, mid],
95+
[leaf.id, leaf],
96+
]);
97+
assert.equal(getContextMessageDepth(leaf, map), 1);
98+
});
99+
100+
test("getContextMessageDepth does not loop forever on a cycle", () => {
101+
// a -> b -> a. The `seen` set must terminate the walk.
102+
const a = event("a", "b");
103+
const b = event("b", "a");
104+
const map = new Map([
105+
[a.id, a],
106+
[b.id, b],
107+
]);
108+
// From a: hop to b (depth 1); b's parent is a, already seen -> stop.
109+
assert.equal(getContextMessageDepth(a, map), 1);
110+
});

‎desktop/src/features/home/ui/HomeView.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,12 +230,18 @@ export function HomeView({
230230
return;
231231
}
232232

233+
// Don't default-select before the width is measured: at width 0
234+
// isNarrowHomeViewport is false, so narrow Home would cold-load into detail.
235+
if (homeInboxWidthPx === 0) {
236+
return;
237+
}
238+
233239
if (!filteredItems.some((item) => item.id === selectedItemId)) {
234240
setSelectedItemId(
235241
isNarrowHomeViewport ? null : (filteredItems[0]?.id ?? null),
236242
);
237243
}
238-
}, [filteredItems, isNarrowHomeViewport, selectedItemId]);
244+
}, [filteredItems, homeInboxWidthPx, isNarrowHomeViewport, selectedItemId]);
239245

240246
React.useEffect(() => {
241247
void selectedItemId;

‎desktop/src/features/home/ui/InboxDetailPane.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
DropdownMenuSeparator,
3131
DropdownMenuTrigger,
3232
} from "@/shared/ui/dropdown-menu";
33+
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
3334
import {
3435
Tooltip,
3536
TooltipContent,
@@ -218,10 +219,7 @@ export function InboxDetailPane({
218219
ref={detailPaneRef}
219220
>
220221
<div className="relative min-h-0 flex-1 overflow-hidden">
221-
<div
222-
aria-hidden="true"
223-
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"
224-
/>
222+
<TopChromeBackdrop className="h-[76px]" />
225223
<div
226224
className={cn(
227225
"absolute inset-x-0 top-[42px] z-40 min-h-[32px] py-[4px] pr-3",

‎desktop/src/features/home/ui/InboxListPane.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { cn } from "@/shared/lib/cn";
99
import { Button } from "@/shared/ui/button";
1010
import { Markdown } from "@/shared/ui/markdown";
11+
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
1112
import {
1213
DropdownMenu,
1314
DropdownMenuContent,
@@ -46,10 +47,7 @@ export function InboxListPane({
4647

4748
return (
4849
<section className="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
49-
<div
50-
aria-hidden="true"
51-
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"
52-
/>
50+
<TopChromeBackdrop className="h-[76px]" />
5351
<div className="absolute inset-x-0 top-[42px] z-40 min-h-[32px] px-5 py-[4px]">
5452
<div className="flex min-w-0 items-center justify-between gap-3">
5553
<div className="flex min-w-0 items-center gap-[6px]">

‎desktop/src/features/profile/ui/UserProfilePanel.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ type UserProfilePanelProps = {
5353
onResetWidth: () => void;
5454
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
5555
pubkey: string;
56+
/**
57+
* When true, the panel sits beside a sibling pane managed by a single-panel
58+
* width controller (ChannelScreen). The width is clamped so the sibling keeps
59+
* at least THREAD_PANEL_MIN_WIDTH_PX. Standalone/floating mounts (e.g. Pulse)
60+
* have no such sibling, so they omit this and use the configured width
61+
* directly — otherwise `calc(100% - 300px)` would wrongly shrink the panel.
62+
*/
63+
splitPaneClamp?: boolean;
5664
widthPx: number;
5765
};
5866

@@ -92,6 +100,7 @@ export function UserProfilePanel({
92100
onResetWidth,
93101
onResizeStart,
94102
pubkey,
103+
splitPaneClamp = false,
95104
widthPx,
96105
}: UserProfilePanelProps) {
97106
const isOverlay = useIsThreadPanelOverlay();
@@ -203,7 +212,9 @@ export function UserProfilePanel({
203212
style={{
204213
width: isSinglePanelView
205214
? "100%"
206-
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
215+
: splitPaneClamp
216+
? `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
217+
: `${widthPx}px`,
207218
}}
208219
>
209220
{!isOverlay && !isSinglePanelView && (

0 commit comments

Comments
 (0)