Skip to content

Commit 8d40150

Browse files
tellahoklopez4212npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7fMargenpub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
authored
feat(desktop): move agent management into profile sidebar (#1274)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: klopez4212 <klopez4212@gmail.com> Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Marge <marge@users.noreply.github.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
1 parent 8c3d0c9 commit 8d40150

50 files changed

Lines changed: 5220 additions & 1415 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

desktop/src/app/routes/agents.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,43 @@
11
import * as React from "react";
22
import { createFileRoute } from "@tanstack/react-router";
33

4+
import {
5+
parseProfilePanelTab,
6+
parseProfilePanelView,
7+
type ProfilePanelTab,
8+
type ProfilePanelView,
9+
} from "@/features/profile/ui/UserProfilePanelUtils";
410
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
511

12+
type AgentsRouteSearch = {
13+
profile?: string;
14+
profilePersona?: string;
15+
profileTab?: ProfilePanelTab;
16+
profileView?: ProfilePanelView;
17+
};
18+
19+
function nonEmptyString(value: unknown): string | undefined {
20+
return typeof value === "string" && value.length > 0 ? value : undefined;
21+
}
22+
23+
function validateAgentsSearch(
24+
search: Record<string, unknown>,
25+
): AgentsRouteSearch {
26+
return {
27+
profile: nonEmptyString(search.profile),
28+
profilePersona: nonEmptyString(search.profilePersona),
29+
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
30+
profileView: parseProfilePanelView(search.profileView) ?? undefined,
31+
};
32+
}
33+
634
const AgentsScreen = React.lazy(async () => {
735
const module = await import("@/features/agents/ui/AgentsScreen");
836
return { default: module.AgentsScreen };
937
});
1038

1139
export const Route = createFileRoute("/agents")({
40+
validateSearch: validateAgentsSearch,
1241
component: AgentsRouteComponent,
1342
});
1443

desktop/src/app/routes/channels.$channelId.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import * as React from "react";
22
import { createFileRoute } from "@tanstack/react-router";
33

4+
import {
5+
parseProfilePanelTab,
6+
parseProfilePanelView,
7+
type ProfilePanelTab,
8+
type ProfilePanelView,
9+
} from "@/features/profile/ui/UserProfilePanelUtils";
410
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
511

612
type ChannelRouteSearch = {
713
agentSession?: string;
814
messageId?: string;
915
profile?: string;
10-
profileView?: "memories" | "channels";
16+
profileTab?: ProfilePanelTab;
17+
profileView?: ProfilePanelView;
1118
thread?: string;
1219
threadRootId?: string;
1320
};
@@ -16,18 +23,15 @@ function nonEmptyString(value: unknown): string | undefined {
1623
return typeof value === "string" && value.length > 0 ? value : undefined;
1724
}
1825

19-
function profileViewValue(value: unknown): "memories" | "channels" | undefined {
20-
return value === "memories" || value === "channels" ? value : undefined;
21-
}
22-
2326
function validateChannelSearch(
2427
search: Record<string, unknown>,
2528
): ChannelRouteSearch {
2629
return {
2730
agentSession: nonEmptyString(search.agentSession),
2831
messageId: nonEmptyString(search.messageId),
2932
profile: nonEmptyString(search.profile),
30-
profileView: profileViewValue(search.profileView),
33+
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
34+
profileView: parseProfilePanelView(search.profileView) ?? undefined,
3135
thread: nonEmptyString(search.thread),
3236
threadRootId: nonEmptyString(search.threadRootId),
3337
};

desktop/src/app/routes/pulse.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import * as React from "react";
22
import { createFileRoute } from "@tanstack/react-router";
33

4+
import {
5+
parseProfilePanelTab,
6+
parseProfilePanelView,
7+
type ProfilePanelTab,
8+
type ProfilePanelView,
9+
} from "@/features/profile/ui/UserProfilePanelUtils";
410
import { usePreviewFeatureWarning } from "@/shared/features";
511
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
612

@@ -11,7 +17,8 @@ const PulseScreen = React.lazy(async () => {
1117

1218
type PulseRouteSearch = {
1319
profile?: string;
14-
profileView?: "memories" | "channels";
20+
profileTab?: ProfilePanelTab;
21+
profileView?: ProfilePanelView;
1522
};
1623

1724
function validatePulseSearch(
@@ -22,10 +29,8 @@ function validatePulseSearch(
2229
typeof search.profile === "string" && search.profile.length > 0
2330
? search.profile
2431
: undefined,
25-
profileView:
26-
search.profileView === "memories" || search.profileView === "channels"
27-
? search.profileView
28-
: undefined,
32+
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
33+
profileView: parseProfilePanelView(search.profileView) ?? undefined,
2934
};
3035
}
3136

desktop/src/features/agent-memory/ui/MemorySection.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from "react";
2-
import { AlertTriangle, ChevronDown, RefreshCw } from "lucide-react";
2+
import { AlertTriangle, Brain, ChevronDown, RefreshCw } from "lucide-react";
33

44
import { useAgentMemoryGraph } from "@/features/agent-memory/hooks";
55
import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
@@ -225,12 +225,16 @@ function MemoryGraphView({
225225
const isEmpty = !rootedTree && orphans.length === 0;
226226
if (isEmpty) {
227227
return (
228-
<p
229-
className="text-sm italic text-muted-foreground"
228+
<div
229+
className="flex min-h-56 flex-col items-center justify-center px-6 py-10 text-center"
230230
data-testid="agent-memory-empty"
231231
>
232-
This agent has no memories yet.
233-
</p>
232+
<Brain className="mx-auto h-4 w-4 text-muted-foreground" />
233+
<p className="mt-3 text-sm font-medium">Build this agent's memory</p>
234+
<p className="mt-1 text-sm text-muted-foreground">
235+
Try telling this agent to remember something for next time.
236+
</p>
237+
</div>
234238
);
235239
}
236240

desktop/src/features/agents/activeAgentTurnsStore.test.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test";
44
import {
55
syncAgentTurnsFromEvents,
66
getActiveTurnsForAgent,
7+
getActiveTurnsByChannel,
78
resetActiveAgentTurnsStore,
89
subscribeActiveAgentTurns,
910
} from "./activeAgentTurnsStore.ts";
1011
import { formatElapsed } from "./ui/agentSessionUtils.ts";
1112

1213
const AGENT =
1314
"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
15+
const AGENT_2 =
16+
"dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321";
1417

1518
/** Channel-id Set view of the summary array — keeps legacy assertions terse. */
1619
function channelIdsOf(turns) {
@@ -163,6 +166,60 @@ describe("activeAgentTurnsStore", () => {
163166
});
164167
});
165168

169+
describe("channel aggregation", () => {
170+
it("collapses active turns by channel across agents", () => {
171+
syncAgentTurnsFromEvents(AGENT, [
172+
makeEvent({
173+
seq: 1,
174+
turnId: "agent-1-early",
175+
channelId: "shared",
176+
timestamp: "2024-01-01T00:00:00Z",
177+
}),
178+
makeEvent({
179+
seq: 2,
180+
turnId: "agent-1-late",
181+
channelId: "shared",
182+
timestamp: "2024-01-01T00:01:00Z",
183+
}),
184+
]);
185+
syncAgentTurnsFromEvents(AGENT_2, [
186+
makeEvent({
187+
seq: 1,
188+
turnId: "agent-2",
189+
channelId: "shared",
190+
timestamp: "2024-01-01T00:02:00Z",
191+
}),
192+
]);
193+
194+
const summaries = getActiveTurnsByChannel();
195+
assert.deepEqual(
196+
summaries.map(({ channelId, agentCount }) => ({
197+
channelId,
198+
agentCount,
199+
})),
200+
[{ channelId: "shared", agentCount: 2 }],
201+
);
202+
assert.equal(
203+
summaries[0].anchorAt,
204+
getActiveTurnsForAgent(AGENT)[0].anchorAt,
205+
);
206+
});
207+
208+
it("removes a channel summary when the last active turn ends", () => {
209+
syncAgentTurnsFromEvents(AGENT, [
210+
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
211+
makeEvent({
212+
seq: 2,
213+
kind: "turn_completed",
214+
turnId: "t1",
215+
channelId: "c1",
216+
}),
217+
]);
218+
219+
assert.deepEqual(getActiveTurnsByChannel(), []);
220+
});
221+
});
222+
166223
describe("endTurn turnId-vs-channelId fallback", () => {
167224
it("ends turn by turnId when provided", () => {
168225
syncAgentTurnsFromEvents(AGENT, [

desktop/src/features/agents/activeAgentTurnsStore.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export type ActiveTurnSummary = {
4444
anchorAt: number;
4545
};
4646

47+
/** One channel with active agent work, aggregated across agents. */
48+
export type ActiveChannelTurnSummary = {
49+
channelId: string;
50+
anchorAt: number;
51+
agentCount: number;
52+
};
53+
4754
// Module-level state: agentPubkey → turnId → ActiveTurn
4855
const activeTurnsByAgent = new Map<string, Map<string, ActiveTurn>>();
4956
const listeners = new Set<() => void>();
@@ -68,6 +75,7 @@ const clockOffsetByAgent = new Map<string, number>();
6875
// Cached snapshots for useSyncExternalStore reference stability.
6976
// Only regenerated when the underlying turn map for an agent actually changes.
7077
const cachedTurnSummaries = new Map<string, ActiveTurnSummary[]>();
78+
let cachedChannelTurnSummaries: ActiveChannelTurnSummary[] | null = null;
7179

7280
// Composite watermark per agent: the newest observer event processed, by
7381
// (timestamp, seq) ordering. An event is processed only if it is strictly
@@ -87,6 +95,7 @@ let pruneInterval: ReturnType<typeof setInterval> | null = null;
8795

8896
function invalidateCache(agentKey: string) {
8997
cachedTurnSummaries.delete(agentKey);
98+
cachedChannelTurnSummaries = null;
9099
}
91100

92101
function notifyListeners() {
@@ -427,6 +436,53 @@ export function getActiveTurnsForAgent(
427436
}
428437

429438
const EMPTY_TURNS: ActiveTurnSummary[] = [];
439+
const EMPTY_CHANNEL_TURNS: ActiveChannelTurnSummary[] = [];
440+
441+
/**
442+
* Returns active working channels across all tracked agents, sorted by
443+
* channelId and anchored to the earliest live turn in each channel.
444+
*/
445+
export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
446+
if (cachedChannelTurnSummaries) return cachedChannelTurnSummaries;
447+
if (activeTurnsByAgent.size === 0) return EMPTY_CHANNEL_TURNS;
448+
449+
const summaries = new Map<
450+
string,
451+
{ anchorAt: number; agentPubkeys: Set<string> }
452+
>();
453+
454+
for (const [agentKey, agentTurns] of activeTurnsByAgent) {
455+
if (agentTurns.size === 0) continue;
456+
const offset = clockOffsetByAgent.get(agentKey) ?? 0;
457+
458+
for (const turn of agentTurns.values()) {
459+
const anchorAt = turn.startedAt + offset;
460+
const summary = summaries.get(turn.channelId);
461+
if (!summary) {
462+
summaries.set(turn.channelId, {
463+
anchorAt,
464+
agentPubkeys: new Set([agentKey]),
465+
});
466+
continue;
467+
}
468+
469+
summary.agentPubkeys.add(agentKey);
470+
if (anchorAt < summary.anchorAt) {
471+
summary.anchorAt = anchorAt;
472+
}
473+
}
474+
}
475+
476+
const result = [...summaries.entries()]
477+
.map(([channelId, summary]) => ({
478+
channelId,
479+
anchorAt: summary.anchorAt,
480+
agentCount: summary.agentPubkeys.size,
481+
}))
482+
.sort((a, b) => a.channelId.localeCompare(b.channelId));
483+
cachedChannelTurnSummaries = result;
484+
return result;
485+
}
430486

431487
/**
432488
* Synchronize the active-turns store with the latest observer events for a
@@ -457,6 +513,17 @@ export function useActiveAgentTurns(
457513
return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot);
458514
}
459515

516+
/**
517+
* Hook: returns channels with active agent work across all tracked agents.
518+
* Re-renders when the channel set changes — not when the clock ticks.
519+
*/
520+
export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] {
521+
return React.useSyncExternalStore(
522+
subscribeActiveAgentTurns,
523+
getActiveTurnsByChannel,
524+
);
525+
}
526+
460527
/**
461528
* Bridge hook: processes observer events into the active-turns store.
462529
* Should be called by a parent component that has access to the observer events.
@@ -483,6 +550,7 @@ export function resetActiveAgentTurnsStore() {
483550
lastProcessed.clear();
484551
clockOffsetByAgent.clear();
485552
cachedTurnSummaries.clear();
553+
cachedChannelTurnSummaries = null;
486554
terminalAtByAgent.clear();
487555
notifyListeners();
488556
}

0 commit comments

Comments
 (0)