Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
309889d
Move agent management into profile sidebar
klopez4212 Jun 23, 2026
f4d1664
merge: bring main into PR #1200 (profile sidebar)
Jun 24, 2026
5833946
merge: bring latest main into PR #1200 (profile sidebar)
Jun 25, 2026
85a5438
fix(desktop): enable all agent profile ingress views
tellaho Jun 24, 2026
ba47f02
fix(desktop): handle model discovery setup states
tellaho Jun 24, 2026
93f3f42
feat(profile): surface agent info fields in summary
tellaho Jun 24, 2026
adbb278
feat(profile): combine agent configuration ingress
tellaho Jun 24, 2026
4227ee6
feat(profile): refine agent diagnostics log layout
tellaho Jun 24, 2026
0a57ea6
fix(profile): emphasize diagnostics errors
tellaho Jun 24, 2026
0a2e487
feat(profile): refine agent detail grouping
tellaho Jun 24, 2026
dbf13ed
feat(profile): merge owner and respond-to into one field
Jun 24, 2026
1bba0b3
feat(profile): move agent actions into settings menu
tellaho Jun 25, 2026
e550c1f
fix(profile): show avatar for fallback owner row
tellaho Jun 25, 2026
feee836
fix(profile): show agent details before ingresses
tellaho Jun 25, 2026
06d4953
feat(profile): add tabbed agent profile with drag-scroll tab bar
tellaho Jun 25, 2026
f48f16e
fix(profile): refine profile panel layout
tellaho Jun 25, 2026
8e06ac7
fix(profile): rename diagnostics pane to harness log
tellaho Jun 25, 2026
ef1a7b0
fix(profile): open agent instructions in focused view
tellaho Jun 25, 2026
3a73346
fix(profile): refine agent profile panel actions
tellaho Jun 25, 2026
85a961e
fix(profile): place instructions above status
tellaho Jun 25, 2026
2adbcf2
fix(profile): move action labels into tooltips
tellaho Jun 25, 2026
dc0973a
feat(profile): add history-backed profile routing
tellaho Jun 25, 2026
412857d
fix(profile): align memory empty state
tellaho Jun 25, 2026
98c4563
refactor(profile): componentize shared panel and identity UI primitives
tellaho Jun 25, 2026
7d5c24d
fix(profile): update profile e2e activity navigation
tellaho Jun 25, 2026
aa3e6fd
feat(sidebar): show agent working status on channels
tellaho Jun 25, 2026
4fd4472
test(profile): add profile sidebar screenshot spec
Jun 25, 2026
ffd84f6
Merge origin/main into tho/agent-profile-sidebar
Jun 25, 2026
3250c8d
test(e2e): drive agent lifecycle via profile sidebar primary action
Jun 25, 2026
a496f7e
fix(desktop): align agent profile empty states
tellaho Jun 25, 2026
6f52d4a
fix(desktop): wait for profile DM open before closing
tellaho Jun 25, 2026
28c4611
Merge remote-tracking branch 'origin/main' into tho/agent-profile-sid…
Jun 25, 2026
9a26425
refactor(profile): collapse duplicate truncatePubkey onto canonical h…
Jun 25, 2026
c5c0173
revert(desktop): drop ba47f021 model-discovery setup-state additions
Jun 25, 2026
746b37a
revert(profile): undo shared profile componentization
tellaho Jun 26, 2026
c752ffd
test(profile): remove unregistered profile screenshot spec
tellaho Jun 26, 2026
445a66b
chore: merge origin/main into agent profile sidebar
tellaho Jun 26, 2026
208b715
feat(profile): move archive controls into profile settings
tellaho Jun 26, 2026
73e6afc
chore(profile): polish agent lifecycle modal copy
tellaho Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions desktop/src/app/routes/agents.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";

import {
parseProfilePanelTab,
parseProfilePanelView,
type ProfilePanelTab,
type ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

type AgentsRouteSearch = {
profile?: string;
profilePersona?: string;
profileTab?: ProfilePanelTab;
profileView?: ProfilePanelView;
};

function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function validateAgentsSearch(
search: Record<string, unknown>,
): AgentsRouteSearch {
return {
profile: nonEmptyString(search.profile),
profilePersona: nonEmptyString(search.profilePersona),
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
profileView: parseProfilePanelView(search.profileView) ?? undefined,
};
}

const AgentsScreen = React.lazy(async () => {
const module = await import("@/features/agents/ui/AgentsScreen");
return { default: module.AgentsScreen };
});

export const Route = createFileRoute("/agents")({
validateSearch: validateAgentsSearch,
component: AgentsRouteComponent,
});

Expand Down
16 changes: 10 additions & 6 deletions desktop/src/app/routes/channels.$channelId.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";

import {
parseProfilePanelTab,
parseProfilePanelView,
type ProfilePanelTab,
type ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

type ChannelRouteSearch = {
agentSession?: string;
messageId?: string;
profile?: string;
profileView?: "memories" | "channels";
profileTab?: ProfilePanelTab;
profileView?: ProfilePanelView;
thread?: string;
threadRootId?: string;
};
Expand All @@ -16,18 +23,15 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function profileViewValue(value: unknown): "memories" | "channels" | undefined {
return value === "memories" || value === "channels" ? value : undefined;
}

function validateChannelSearch(
search: Record<string, unknown>,
): ChannelRouteSearch {
return {
agentSession: nonEmptyString(search.agentSession),
messageId: nonEmptyString(search.messageId),
profile: nonEmptyString(search.profile),
profileView: profileViewValue(search.profileView),
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
profileView: parseProfilePanelView(search.profileView) ?? undefined,
thread: nonEmptyString(search.thread),
threadRootId: nonEmptyString(search.threadRootId),
};
Expand Down
15 changes: 10 additions & 5 deletions desktop/src/app/routes/pulse.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";

import {
parseProfilePanelTab,
parseProfilePanelView,
type ProfilePanelTab,
type ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

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

type PulseRouteSearch = {
profile?: string;
profileView?: "memories" | "channels";
profileTab?: ProfilePanelTab;
profileView?: ProfilePanelView;
};

function validatePulseSearch(
Expand All @@ -22,10 +29,8 @@ function validatePulseSearch(
typeof search.profile === "string" && search.profile.length > 0
? search.profile
: undefined,
profileView:
search.profileView === "memories" || search.profileView === "channels"
? search.profileView
: undefined,
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
profileView: parseProfilePanelView(search.profileView) ?? undefined,
};
}

Expand Down
14 changes: 9 additions & 5 deletions desktop/src/features/agent-memory/ui/MemorySection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import { AlertTriangle, ChevronDown, RefreshCw } from "lucide-react";
import { AlertTriangle, Brain, ChevronDown, RefreshCw } from "lucide-react";

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

Expand Down
57 changes: 57 additions & 0 deletions desktop/src/features/agents/activeAgentTurnsStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test";
import {
syncAgentTurnsFromEvents,
getActiveTurnsForAgent,
getActiveTurnsByChannel,
resetActiveAgentTurnsStore,
subscribeActiveAgentTurns,
} from "./activeAgentTurnsStore.ts";
import { formatElapsed } from "./ui/agentSessionUtils.ts";

const AGENT =
"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
const AGENT_2 =
"dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321";

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

describe("channel aggregation", () => {
it("collapses active turns by channel across agents", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
turnId: "agent-1-early",
channelId: "shared",
timestamp: "2024-01-01T00:00:00Z",
}),
makeEvent({
seq: 2,
turnId: "agent-1-late",
channelId: "shared",
timestamp: "2024-01-01T00:01:00Z",
}),
]);
syncAgentTurnsFromEvents(AGENT_2, [
makeEvent({
seq: 1,
turnId: "agent-2",
channelId: "shared",
timestamp: "2024-01-01T00:02:00Z",
}),
]);

const summaries = getActiveTurnsByChannel();
assert.deepEqual(
summaries.map(({ channelId, agentCount }) => ({
channelId,
agentCount,
})),
[{ channelId: "shared", agentCount: 2 }],
);
assert.equal(
summaries[0].anchorAt,
getActiveTurnsForAgent(AGENT)[0].anchorAt,
);
});

it("removes a channel summary when the last active turn ends", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
makeEvent({
seq: 2,
kind: "turn_completed",
turnId: "t1",
channelId: "c1",
}),
]);

assert.deepEqual(getActiveTurnsByChannel(), []);
});
});

describe("endTurn turnId-vs-channelId fallback", () => {
it("ends turn by turnId when provided", () => {
syncAgentTurnsFromEvents(AGENT, [
Expand Down
68 changes: 68 additions & 0 deletions desktop/src/features/agents/activeAgentTurnsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export type ActiveTurnSummary = {
anchorAt: number;
};

/** One channel with active agent work, aggregated across agents. */
export type ActiveChannelTurnSummary = {
channelId: string;
anchorAt: number;
agentCount: number;
};

// Module-level state: agentPubkey → turnId → ActiveTurn
const activeTurnsByAgent = new Map<string, Map<string, ActiveTurn>>();
const listeners = new Set<() => void>();
Expand All @@ -68,6 +75,7 @@ const clockOffsetByAgent = new Map<string, number>();
// Cached snapshots for useSyncExternalStore reference stability.
// Only regenerated when the underlying turn map for an agent actually changes.
const cachedTurnSummaries = new Map<string, ActiveTurnSummary[]>();
let cachedChannelTurnSummaries: ActiveChannelTurnSummary[] | null = null;

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

function invalidateCache(agentKey: string) {
cachedTurnSummaries.delete(agentKey);
cachedChannelTurnSummaries = null;
}

function notifyListeners() {
Expand Down Expand Up @@ -427,6 +436,53 @@ export function getActiveTurnsForAgent(
}

const EMPTY_TURNS: ActiveTurnSummary[] = [];
const EMPTY_CHANNEL_TURNS: ActiveChannelTurnSummary[] = [];

/**
* Returns active working channels across all tracked agents, sorted by
* channelId and anchored to the earliest live turn in each channel.
*/
export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
if (cachedChannelTurnSummaries) return cachedChannelTurnSummaries;
if (activeTurnsByAgent.size === 0) return EMPTY_CHANNEL_TURNS;

const summaries = new Map<
string,
{ anchorAt: number; agentPubkeys: Set<string> }
>();

for (const [agentKey, agentTurns] of activeTurnsByAgent) {
if (agentTurns.size === 0) continue;
const offset = clockOffsetByAgent.get(agentKey) ?? 0;

for (const turn of agentTurns.values()) {
const anchorAt = turn.startedAt + offset;
const summary = summaries.get(turn.channelId);
if (!summary) {
summaries.set(turn.channelId, {
anchorAt,
agentPubkeys: new Set([agentKey]),
});
continue;
}

summary.agentPubkeys.add(agentKey);
if (anchorAt < summary.anchorAt) {
summary.anchorAt = anchorAt;
}
}
}

const result = [...summaries.entries()]
.map(([channelId, summary]) => ({
channelId,
anchorAt: summary.anchorAt,
agentCount: summary.agentPubkeys.size,
}))
.sort((a, b) => a.channelId.localeCompare(b.channelId));
cachedChannelTurnSummaries = result;
return result;
}

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

/**
* Hook: returns channels with active agent work across all tracked agents.
* Re-renders when the channel set changes — not when the clock ticks.
*/
export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] {
return React.useSyncExternalStore(
subscribeActiveAgentTurns,
getActiveTurnsByChannel,
);
}

/**
* Bridge hook: processes observer events into the active-turns store.
* Should be called by a parent component that has access to the observer events.
Expand All @@ -483,6 +550,7 @@ export function resetActiveAgentTurnsStore() {
lastProcessed.clear();
clockOffsetByAgent.clear();
cachedTurnSummaries.clear();
cachedChannelTurnSummaries = null;
terminalAtByAgent.clear();
notifyListeners();
}
Loading
Loading