diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx
index 910d836ff08..3374fb7a654 100644
--- a/desktop/src/features/channels/ui/useChannelIntro.tsx
+++ b/desktop/src/features/channels/ui/useChannelIntro.tsx
@@ -9,6 +9,8 @@ import {
isWelcomeChannel,
isWelcomeExperienceChannel,
} from "@/features/onboarding/welcome";
+import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
+import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon";
import type { Channel } from "@/shared/api/types";
import { HashSearch } from "@/shared/ui/icons";
@@ -43,6 +45,8 @@ export function useChannelIntro({
onOpenMembers?: () => void;
onWelcomeAddAgent?: () => void;
}) {
+ const projectHome = useIsProjectHomeChannel(activeChannel?.id);
+
return React.useMemo(() => {
if (!activeChannel || activeChannel.channelType === "dm") {
return null;
@@ -81,7 +85,7 @@ export function useChannelIntro({
actions,
channelKindLabel: isWelcomeChannel(activeChannel)
? "private welcome channel"
- : getChannelIntroKind(activeChannel),
+ : getChannelIntroKind(activeChannel, projectHome),
channelName: activeChannel.name,
description: isWelcomeChannel(activeChannel)
? null
@@ -103,9 +107,9 @@ export function useChannelIntro({
if (onAddAgent) {
actions.push({
- description: "Bring them in.",
- icon: ,
- label: "Add agents",
+ description: "Add an agent here.",
+ icon: ,
+ label: "Add agent",
onClick: onAddAgent,
testId: "channel-intro-action-create-agent",
});
@@ -114,7 +118,7 @@ export function useChannelIntro({
if (onOpenMembers) {
actions.push({
description: "Invite members.",
- icon: ,
+ icon: ,
label: "Add people",
onClick: onOpenMembers,
testId: "channel-intro-action-add-people",
@@ -124,9 +128,13 @@ export function useChannelIntro({
return {
actions,
- channelKindLabel: getChannelIntroKind(activeChannel),
+ channelKindLabel: getChannelIntroKind(activeChannel, projectHome),
channelName: activeChannel.name,
description: getChannelIntroDescription(activeChannel),
+ hideBeginning: projectHome,
+ icon: projectHome ? (
+
+ ) : undefined,
};
}, [
activeChannel,
@@ -136,5 +144,6 @@ export function useChannelIntro({
onCreateChannel,
onOpenMembers,
onWelcomeAddAgent,
+ projectHome,
]);
}
diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
index c69fd46a32d..fc4379f8b7f 100644
--- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
+++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
@@ -16,6 +16,7 @@ export type ChannelIntro = {
channelKindLabel: string;
channelName: string;
description?: string | null;
+ hideBeginning?: boolean;
icon?: React.ReactNode;
};
@@ -50,13 +51,15 @@ export function ChannelIntroBlock({
#{intro.channelName}
-
- This is the beginning of the{" "}
-
- {intro.channelKindLabel}
-
- .
-
+ {intro.hideBeginning ? null : (
+
+ This is the beginning of the{" "}
+
+ {intro.channelKindLabel}
+
+ .
+
+ )}
{intro.description ? (
{intro.description}
diff --git a/desktop/src/features/projects/lib/projectAgentSelection.test.mjs b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs
new file mode 100644
index 00000000000..2c59d6351b9
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { pickDefaultProjectsAgent } from "./projectAgentSelection.ts";
+
+test("prefers Fizz over the first running agent", () => {
+ const implementationPartner = {
+ name: "Implementation Partner",
+ personaId: "custom:implementation",
+ };
+ const fizz = { name: "Fizz", personaId: "builtin:fizz" };
+ assert.equal(pickDefaultProjectsAgent([implementationPartner, fizz]), fizz);
+});
+
+test("keeps legacy Kit compatibility and falls back to first", () => {
+ const first = { name: "Builder" };
+ const kit = { name: "Kit" };
+ assert.equal(pickDefaultProjectsAgent([first, kit]), kit);
+ assert.equal(pickDefaultProjectsAgent([first]), first);
+ assert.equal(pickDefaultProjectsAgent([]), null);
+});
diff --git a/desktop/src/features/projects/lib/projectAgentSelection.ts b/desktop/src/features/projects/lib/projectAgentSelection.ts
new file mode 100644
index 00000000000..6aa43392ee9
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectAgentSelection.ts
@@ -0,0 +1,16 @@
+const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz";
+
+/** Prefers the built-in welcome lead for a new Projects conversation. */
+export function pickDefaultProjectsAgent<
+ Agent extends { name: string; personaId?: string | null },
+>(agents: readonly Agent[]): Agent | null {
+ return (
+ agents.find((agent) => agent.personaId === WELCOME_GUIDE_PERSONA_ID) ??
+ agents.find((agent) => {
+ const name = agent.name.trim().toLocaleLowerCase();
+ return name === "fizz" || name === "kit";
+ }) ??
+ agents[0] ??
+ null
+ );
+}
diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs
index ad1795ad735..e6fc3196f20 100644
--- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs
+++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
+ collapseProjectRelatedChannelRows,
collectProjectRelatedChannelRows,
listProjectBoundChannels,
listProjectChildChannels,
@@ -106,6 +107,37 @@ test("collects one row per repository channel binding", () => {
);
});
+test("collapses repositories sharing one project channel", () => {
+ const rows = collectProjectRelatedChannelRows([
+ makeProject({
+ repositories: [
+ makeRepository({ name: "web" }),
+ makeRepository({ id: "repo-mobile", name: "mobile" }),
+ makeRepository({
+ channelId: CHANNEL_B,
+ id: "repo-relay",
+ name: "relay",
+ }),
+ ],
+ }),
+ ]);
+
+ assert.deepEqual(collapseProjectRelatedChannelRows(rows), [
+ {
+ channelId: CHANNEL_A,
+ projectId: "project-buzz",
+ projectName: "buzz",
+ repositoryNames: ["web", "mobile"],
+ },
+ {
+ channelId: CHANNEL_B,
+ projectId: "project-buzz",
+ projectName: "buzz",
+ repositoryNames: ["relay"],
+ },
+ ]);
+});
+
test("keeps a project channel only when no repository in that project shares it", () => {
assert.deepEqual(
collectProjectRelatedChannelRows([
diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.ts b/desktop/src/features/projects/lib/projectRelatedChannels.ts
index 525eb700c26..d5166a48536 100644
--- a/desktop/src/features/projects/lib/projectRelatedChannels.ts
+++ b/desktop/src/features/projects/lib/projectRelatedChannels.ts
@@ -20,6 +20,14 @@ export type ProjectRelatedChannelRow = {
repositoryName: string | null;
};
+/** One display row per distinct channel within a project. */
+export type ProjectRelatedChannelDisplayRow = {
+ channelId: string;
+ projectId: string;
+ projectName: string;
+ repositoryNames: string[];
+};
+
function trimmedChannelId(value: string | null | undefined) {
const channelId = value?.trim() ?? "";
return channelId.length > 0 ? channelId : null;
@@ -88,6 +96,40 @@ export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) {
return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`;
}
+/** Collapses repository bindings that point at the same project channel. */
+export function collapseProjectRelatedChannelRows(
+ rows: readonly ProjectRelatedChannelRow[],
+): ProjectRelatedChannelDisplayRow[] {
+ const collapsed = new Map();
+ for (const row of rows) {
+ const key = `${row.projectId}:${row.channelId}`;
+ const current = collapsed.get(key);
+ if (current) {
+ if (
+ row.repositoryName &&
+ !current.repositoryNames.includes(row.repositoryName)
+ ) {
+ current.repositoryNames.push(row.repositoryName);
+ }
+ continue;
+ }
+ collapsed.set(key, {
+ channelId: row.channelId,
+ projectId: row.projectId,
+ projectName: row.projectName,
+ repositoryNames: row.repositoryName ? [row.repositoryName] : [],
+ });
+ }
+ return [...collapsed.values()];
+}
+
+/** Stable key for one collapsed project-channel row. */
+export function projectRelatedChannelDisplayRowKey(
+ row: ProjectRelatedChannelDisplayRow,
+) {
+ return `${row.channelId}:${row.projectId}`;
+}
+
export type ProjectBoundChannel = {
channelId: string;
repositoryId: string | null;
diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs
new file mode 100644
index 00000000000..38832d05060
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { buildProjectsActivityDigest } from "./projectsActivityDigest.ts";
+
+const NOW = 2_000_000_000;
+
+test("summarizes recent activity in a short highlighted sentence", () => {
+ const project = { id: "project-a" };
+ const digest = buildProjectsActivityDigest({
+ issues: [
+ { project, issue: { createdAt: NOW - 60 } },
+ { project, issue: { createdAt: NOW - 120 } },
+ ],
+ nowSeconds: NOW,
+ projects: [project],
+ pullRequests: [{ project, pullRequest: { createdAt: NOW - 180 } }],
+ snapshots: {
+ "project-a": {
+ commits: [
+ { timestamp: NOW - 30 },
+ { timestamp: NOW - 90 },
+ { timestamp: NOW - 8 * 24 * 60 * 60 },
+ ],
+ },
+ },
+ });
+
+ assert.equal(digest.prefix, "This week:");
+ assert.deepEqual(digest.highlights, [
+ "2 new commits",
+ "2 tasks opened",
+ "1 review opened",
+ "1 active project",
+ ]);
+ assert.ok(
+ `${digest.prefix} ${digest.highlights.join(", ")}${digest.suffix}`.split(
+ /\s+/,
+ ).length <= 30,
+ );
+});
+
+test("falls back to current totals when no recent activity is loaded", () => {
+ const digest = buildProjectsActivityDigest({
+ issues: [],
+ nowSeconds: NOW,
+ projects: [{ id: "a" }, { id: "b" }],
+ pullRequests: [],
+ summaries: {
+ a: { issueCount: 4, prCount: 2 },
+ b: { issueCount: 1, prCount: 3 },
+ },
+ });
+
+ assert.equal(digest.prefix, "Currently tracking");
+ assert.deepEqual(digest.highlights, ["2 projects", "5 tasks", "5 reviews"]);
+});
diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.ts b/desktop/src/features/projects/lib/projectsActivityDigest.ts
new file mode 100644
index 00000000000..08edc4fdda5
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectsActivityDigest.ts
@@ -0,0 +1,88 @@
+import type {
+ Project,
+ ProjectActivitySummary,
+ ProjectIssueListItem,
+ ProjectPullRequestListItem,
+ ProjectRepoSnapshot,
+} from "@/features/projects/hooks";
+
+const WEEK_SECONDS = 7 * 24 * 60 * 60;
+
+export type ProjectsActivityDigest = {
+ highlights: string[];
+ prefix: string;
+ suffix: string;
+};
+
+function plural(count: number, singular: string, pluralForm = `${singular}s`) {
+ return `${count} ${count === 1 ? singular : pluralForm}`;
+}
+
+/** Builds a short, deterministic sentence from the currently loaded activity. */
+export function buildProjectsActivityDigest({
+ issues,
+ nowSeconds,
+ projects,
+ pullRequests,
+ snapshots,
+ summaries,
+}: {
+ issues: ProjectIssueListItem[];
+ nowSeconds: number;
+ projects: Project[];
+ pullRequests: ProjectPullRequestListItem[];
+ snapshots?: Record;
+ summaries?: Record;
+}): ProjectsActivityDigest {
+ const since = nowSeconds - WEEK_SECONDS;
+ const activeProjectIds = new Set();
+ let commitCount = 0;
+ for (const [projectId, snapshot] of Object.entries(snapshots ?? {})) {
+ const recent = snapshot.commits.filter(
+ (commit) => commit.timestamp >= since,
+ ).length;
+ commitCount += recent;
+ if (recent > 0) activeProjectIds.add(projectId);
+ }
+ const taskCount = issues.filter(({ issue, project }) => {
+ const recent = issue.createdAt >= since;
+ if (recent) activeProjectIds.add(project.id);
+ return recent;
+ }).length;
+ const reviewCount = pullRequests.filter(({ project, pullRequest }) => {
+ const recent = pullRequest.createdAt >= since;
+ if (recent) activeProjectIds.add(project.id);
+ return recent;
+ }).length;
+ const highlights = [
+ commitCount > 0 ? `${plural(commitCount, "new commit")}` : null,
+ taskCount > 0 ? `${plural(taskCount, "task")} opened` : null,
+ reviewCount > 0 ? `${plural(reviewCount, "review")} opened` : null,
+ ].filter((value): value is string => value !== null);
+
+ if (highlights.length > 0) {
+ highlights.push(`${plural(activeProjectIds.size, "active project")}`);
+ return {
+ highlights,
+ prefix: "This week:",
+ suffix: ".",
+ };
+ }
+
+ const totals = Object.values(summaries ?? {}).reduce(
+ (result, summary) => ({
+ reviews: result.reviews + summary.prCount,
+ tasks: result.tasks + summary.issueCount,
+ }),
+ { reviews: 0, tasks: 0 },
+ );
+ return {
+ highlights: [
+ plural(projects.length, "project"),
+ plural(totals.tasks, "task"),
+ plural(totals.reviews, "review"),
+ ],
+ prefix: "Currently tracking",
+ suffix: ".",
+ };
+}
diff --git a/desktop/src/features/projects/lib/projectsSearch.test.mjs b/desktop/src/features/projects/lib/projectsSearch.test.mjs
new file mode 100644
index 00000000000..47fa06e89dc
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectsSearch.test.mjs
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { matchesProjectsSearch } from "./projectsSearch.ts";
+
+test("matches every case-insensitive token across fields", () => {
+ assert.equal(
+ matchesProjectsSearch("buzz mobile", [
+ "Buzz Platform",
+ "Desktop, relay, and mobile clients",
+ ]),
+ true,
+ );
+ assert.equal(
+ matchesProjectsSearch("buzz missing", [
+ "Buzz Platform",
+ "Desktop, relay, and mobile clients",
+ ]),
+ false,
+ );
+});
+
+test("empty search matches everything", () => {
+ assert.equal(matchesProjectsSearch(" ", []), true);
+});
diff --git a/desktop/src/features/projects/lib/projectsSearch.ts b/desktop/src/features/projects/lib/projectsSearch.ts
new file mode 100644
index 00000000000..290e985e468
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectsSearch.ts
@@ -0,0 +1,10 @@
+/** Case-insensitive token matching for Projects-local search. */
+export function matchesProjectsSearch(
+ query: string,
+ values: ReadonlyArray,
+) {
+ const tokens = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
+ if (tokens.length === 0) return true;
+ const haystack = values.filter(Boolean).join(" ").toLocaleLowerCase();
+ return tokens.every((token) => haystack.includes(token));
+}
diff --git a/desktop/src/features/projects/lib/useProjectSelection.tsx b/desktop/src/features/projects/lib/useProjectSelection.tsx
index 551e59afd64..eec6883d4a7 100644
--- a/desktop/src/features/projects/lib/useProjectSelection.tsx
+++ b/desktop/src/features/projects/lib/useProjectSelection.tsx
@@ -25,10 +25,12 @@ const ProjectSelectionContext =
export function ProjectSelectionProvider({
children,
+ onClear,
onSelect,
resetKey,
}: {
children: React.ReactNode;
+ onClear?: () => void;
onSelect?: () => void;
resetKey: string;
}) {
@@ -42,6 +44,9 @@ export function ProjectSelectionProvider({
}
const onSelectRef = React.useRef(onSelect);
onSelectRef.current = onSelect;
+ const onClearRef = React.useRef(onClear);
+ onClearRef.current = onClear;
+ const wasActiveRef = React.useRef(false);
const clear = React.useCallback(() => {
setState(EMPTY_PROJECT_SELECTION);
@@ -61,7 +66,10 @@ export function ProjectSelectionProvider({
}, []);
React.useEffect(() => {
- if (state.items.length > 0) onSelectRef.current?.();
+ const active = state.items.length > 0;
+ if (active && !wasActiveRef.current) onSelectRef.current?.();
+ if (!active && wasActiveRef.current) onClearRef.current?.();
+ wasActiveRef.current = active;
}, [state.items.length]);
React.useEffect(() => {
diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx
index 665f77b6f2a..69a9e3e69bd 100644
--- a/desktop/src/features/projects/ui/DiscussionChannels.tsx
+++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx
@@ -27,6 +27,7 @@ import {
ProjectEntityFacepile,
ProjectEntityListRow,
} from "./ProjectEntityListRow";
+import { ProjectPanelState } from "./ProjectPanelState";
import { useProjectConversationPanel } from "./ProjectConversationPanelContext";
// Relay search caps a page at 500. Use the full page and surface a lower-bound
@@ -400,13 +401,11 @@ export function DiscussionChannelsPanel({
}
if (channels.length === 0) {
return (
-
- No channels reference this repository yet. Paste its link (or a review
- or task link) in a channel and it will show up here.
-
+
);
}
diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx
index 1820dc17408..50449df4f9f 100644
--- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx
@@ -9,6 +9,7 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage";
import { useCommunities } from "@/features/communities/useCommunities";
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext";
+import { pickDefaultProjectsAgent } from "@/features/projects/lib/projectAgentSelection";
import {
restoreProjectsAgentConversation,
submitProjectAgentMessage,
@@ -102,7 +103,8 @@ export function ProjectAgentChatPanel({
const profileQuery = useProfileQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();
- const selectedAgent = conversation?.agent ?? candidates[0] ?? null;
+ const selectedAgent =
+ conversation?.agent ?? pickDefaultProjectsAgent(candidates);
const candidateProfilesQuery = useUsersBatchQuery(
selectedAgent ? [selectedAgent.pubkey] : [],
);
diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx
index e0135fc19b3..ca7a84ce069 100644
--- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx
+++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx
@@ -19,6 +19,7 @@ import { useIdentityQuery } from "@/shared/api/hooks";
import type { RelayEvent } from "@/shared/api/types";
import type { EntityLinkTab } from "@/shared/lib/entityLink";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
+import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
@@ -117,6 +118,7 @@ export function ProjectChannelHome({
const [workspaceDetail, setWorkspaceDetail] =
React.useState(null);
const summaryWidth = useThreadPanelWidth(undefined, {
+ minWidthPx: SIDEBAR_WIDTH_MIN,
sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY,
});
const homeChannel =
diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx
index 71d269a84a8..9c774c9a3b2 100644
--- a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx
+++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx
@@ -41,33 +41,37 @@ export function ProjectChannelManagement({
? project.owner
: undefined;
- if (!canEdit) return null;
-
return (
<>
- {
- const result = await createMutation.mutateAsync({
- ...input,
- ownerControlAgentPubkey,
- project,
- });
- toast.success(`Channel "#${result.channel.name}" created.`);
- await goChannel(result.channel.id);
- }}
- onOpenChange={setCreateOpen}
- testId="create-project-channel-dialog"
- title="Create a project channel"
- />
+ {canEdit ? (
+ {
+ const result = await createMutation.mutateAsync({
+ ...input,
+ ownerControlAgentPubkey,
+ project,
+ });
+ toast.success(`Channel "#${result.channel.name}" created.`);
+ await goChannel(result.channel.id);
+ }}
+ onOpenChange={setCreateOpen}
+ testId="create-project-channel-dialog"
+ title="Create a project channel"
+ />
+ ) : null}
setCreateOpen(true)}
size="icon"
+ title={
+ canEdit ? "Add channel" : "Only the project owner can add channels"
+ }
type="button"
variant="ghost"
>
diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx
index 36238689857..5fa082b3b00 100644
--- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx
+++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx
@@ -30,12 +30,10 @@ import {
} from "lucide-react";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
-import {
- PROJECT_DETAIL_PANEL_CLASS,
- PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
-} from "./projectPanelStyles";
+import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
+import { ProjectPanelState } from "./ProjectPanelState";
function pluralize(count: number, singular: string, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
@@ -152,12 +150,10 @@ export function ContributorsPanel({
if (rows.length === 0) {
return (
-
- No git contributors are available yet.
-
+
);
}
@@ -288,14 +284,15 @@ export function ActivityPanel({
if (commits.length === 0) {
return (
-
- {error
- ? "Could not load repository activity from git."
- : "No commits are available yet."}
-
+
);
}
diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx
index d0769e31e50..8acfd90b129 100644
--- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx
+++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx
@@ -295,7 +295,7 @@ export function ProjectEntityListRow({
{affiliation ? (
{peopleContent}
- {count != null ? (
+ {count != null || countTestId ? (
-
-
- {count}
- {countSuffix}
-
+ {count != null ? (
+ <>
+
+
+ {count}
+ {countSuffix}
+
+ >
+ ) : null}
) : null}
{beforeDate ? (
diff --git a/desktop/src/features/projects/ui/ProjectHomeColumn.tsx b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx
index 86b47902068..5b911a1367b 100644
--- a/desktop/src/features/projects/ui/ProjectHomeColumn.tsx
+++ b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx
@@ -40,14 +40,16 @@ export function ProjectHomeColumn({
widthPx={widthPx}
>
-
+
-
+
{children}
diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx
index 22f844715f0..0b76ccbbd31 100644
--- a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx
@@ -135,16 +135,10 @@ function ChannelContextRow({
projectHome?: boolean;
testId: string;
}) {
- const count = presentContextCount(channel.memberCount);
const Icon = projectHome ? ProjectChannelIcon : Hash;
if (onClick) {
return (
- }
- onClick={onClick}
- testId={testId}
- >
+ } onClick={onClick} testId={testId}>
{channel.name}
);
@@ -154,9 +148,7 @@ function ChannelContextRow({
className={`${PROJECT_HOME_SIDEBAR_ROW_CLASS} pointer-events-none flex items-center`}
data-testid={testId}
>
- }>
- {channel.name}
-
+ }>{channel.name}
);
}
diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx
index 1f1046a422f..1142a499bda 100644
--- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx
@@ -56,6 +56,7 @@ import {
} from "./ProjectStatusProgressIcon";
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
+import { ProjectPanelState } from "./ProjectPanelState";
export function issueStatusClassName(status: ProjectIssue["status"]) {
if (status === "Triage" || status === "In Progress") return "text-amber-500";
@@ -435,11 +436,15 @@ export function ProjectIssuesPanel({
if (issues.length === 0) {
return (
-
- {issuesQuery.error
- ? "Could not load tasks for this repository."
- : "No tasks yet."}
-
+
);
}
diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx
index 36a937ad5a4..22927798504 100644
--- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx
@@ -62,7 +62,7 @@ export function ProjectOverviewPanel({
unavailableReason,
}: ProjectOverviewPanelProps) {
return (
-
+
{/* ReadmePanel renders its own "no README" fallback while keeping
repository recovery actions reachable. */}
+
+
+
{title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {action}
+
+ );
+}
diff --git a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx
index 9bc89e52131..9f6bb57c51d 100644
--- a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx
@@ -4,6 +4,7 @@ import {
ExternalLink,
Globe,
Loader2,
+ MessageCircle,
} from "lucide-react";
import type { ProjectRepoFile } from "@/features/projects/hooks";
@@ -26,6 +27,7 @@ import {
} from "./ProjectRepositorySource";
import { GitHubMark } from "./GitHubMark";
import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState";
+import { ProjectPanelState } from "./ProjectPanelState";
export function findReadmeFile(files: ProjectRepoFile[]) {
const readmes = files.filter((file) =>
@@ -260,16 +262,37 @@ export function ReadmePanel({
}
if (!file || !fileContent.content) {
+ const loadError = Boolean(fileContent.error);
+ const emptyRepository = gitDataState === "empty";
return (
-
+
{header}
-
- {fileContent.error
- ? "Could not load this README. Try again after refreshing the repository."
- : gitDataState === "empty"
- ? "No files have been pushed to this repository yet."
- : "Add a README to this repository to describe setup, usage, and project context."}
-
+
+
+ Chat with an agent
+
+ ) : undefined
+ }
+ description={
+ loadError
+ ? "Refresh the repository or ask an agent to investigate."
+ : emptyRepository
+ ? "Ask an agent to create the initial codebase or connect an existing repository."
+ : "Add a README to describe setup, usage, and project context."
+ }
+ error={loadError}
+ panel={false}
+ title={
+ loadError
+ ? "Could not load the README"
+ : emptyRepository
+ ? "No files have been pushed yet"
+ : "No README yet"
+ }
+ />
);
}
diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx
index 84bde732974..c31db004e55 100644
--- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx
+++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx
@@ -142,7 +142,7 @@ export function ProjectRepositoryManagement({
project={project}
repositories={attachCandidates}
/>
- {canEdit && !hideTriggers ? (
+ {!hideTriggers ? (
diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
index 02be786cacd..89c192777af 100644
--- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
@@ -42,11 +42,9 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { SyntaxHighlightedCode } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
-import {
- PROJECT_DETAIL_PANEL_CLASS,
- PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
-} from "./projectPanelStyles";
+import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles";
import { ProjectRepositoryLatestCommitRow } from "./ProjectRepositoryLatestCommitRow";
+import { ProjectPanelState } from "./ProjectPanelState";
import {
type RepoSourceHeaderControls,
RepoSourceDropdown,
@@ -736,15 +734,20 @@ export function RepositoryFilesPanel({
? "No files have been pushed yet."
: null;
if (stateMessage) {
+ const state = (
+
+ );
if (!sourceControls) {
- return (
-
- {stateMessage}
-
- );
+ return state;
}
return (
@@ -768,7 +771,7 @@ export function RepositoryFilesPanel({
- {stateMessage}
+ {state}
);
}
diff --git a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx
index 1e382326e2b..0127bc43a3b 100644
--- a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx
+++ b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx
@@ -1,4 +1,4 @@
-import { Info, MessageCircle } from "lucide-react";
+import { MessageCircle } from "lucide-react";
import {
toggleTerminalPanel,
@@ -6,6 +6,7 @@ import {
} from "@/features/terminal/terminalPanelStore";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
+import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
import { TerminalPanelIcon } from "@/shared/ui/TerminalPanelIcon";
export type ProjectRightPanelMode = "chat" | "repository";
@@ -122,12 +123,10 @@ export function ProjectRightPanelControls({
type="button"
variant="ghost"
>
-
diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
index 40588934b5e..cfac55fa69f 100644
--- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
+++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
@@ -53,10 +53,10 @@ import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailabl
import {
PROJECT_COLUMN_HEADER_BACKDROP_CLASS,
PROJECT_DETAIL_PANEL_CLASS,
- PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
PROJECT_SECTION_HEADER_CLASS,
} from "./projectPanelStyles";
import { ProjectSectionHeader } from "./ProjectSectionHeader";
+import { ProjectPanelState } from "./ProjectPanelState";
import { CreatePullRequestDialog } from "./CreatePullRequestDialog";
import {
CreateIssueDialog,
@@ -444,7 +444,10 @@ export function WorkspaceTabs({
>
{sectionHeader}
-
+
-
- No local checkout found.
-
-
+
) : (
;
};
@@ -117,54 +104,6 @@ function contentPreview(content: string) {
return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280);
}
-function activitySelectionItem(
- item: ProjectActivityItem,
-): ProjectSelectionItem | null {
- const project = item.target.project;
- const repository =
- item.target.type === "issue" || item.target.type === "pull-request"
- ? item.target.repository
- : project.repositories[0];
- const channelId = repository?.channelId ?? project.projectChannelId;
- if (item.target.type === "commit") {
- return selectionItemFromCommit({
- author: item.actorPubkey,
- channelId,
- commitHash: item.target.commitHash,
- projectId: project.id,
- shareLink: repository
- ? commitShareLink(repository, item.target.commitHash)
- : null,
- title: item.title,
- });
- }
- if (item.target.type === "issue") {
- return selectionItemFromTask({
- author: item.target.issue.author,
- channelId,
- id: item.target.issue.id,
- shareLink: issueShareLink(item.target.issue),
- title: item.target.issue.title,
- });
- }
- if (item.target.type === "pull-request") {
- return selectionItemFromReview({
- author: item.target.pullRequest.author,
- channelId,
- id: item.target.pullRequest.id,
- shareLink: pullRequestShareLink(item.target.pullRequest),
- title: item.target.pullRequest.title,
- });
- }
- return selectionItemFromProject({
- channelId: project.projectChannelId,
- id: project.id,
- owner: project.owner,
- shareLink: projectShareLink(project),
- title: project.name,
- });
-}
-
function buildActivityItems({
issues,
projects,
@@ -402,7 +341,6 @@ function ActivityCard({
onOpen,
onOpenProject,
profiles,
- rangeItems,
}: {
compact: boolean;
isFirst: boolean;
@@ -411,7 +349,6 @@ function ActivityCard({
onOpen: () => void;
onOpenProject: () => void;
profiles?: UserProfileLookup;
- rangeItems: ProjectSelectionItem[];
}) {
const visual = PROJECT_EVENT_VISUALS[item.kind];
const TypeIcon = visual.icon;
@@ -421,21 +358,12 @@ function ActivityCard({
const actorLabel = item.actorPubkey
? resolveUserLabel({ profiles, pubkey: item.actorPubkey })
: item.actorName || "Someone";
- const selection = useProjectSelection();
- const selectionItem = activitySelectionItem(item);
- const selected = Boolean(
- selectionItem && selection?.isSelected(selectionItem.id),
- );
- const showSelectControl = Boolean(selectionItem && selection && selected);
-
return (
- {selectionItem && selection ? (
-
-
- selection.toggle(selectionItem, {
- rangeItems,
- shiftKey,
- })
- }
- />
-
- ) : (
-
- )}
{/* Avatar gutter: a vertical spine runs through the avatar centers
to connect consecutive cards. Segments extend into the card's
vertical padding so they meet the neighbouring card's segments;
@@ -614,10 +521,28 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
// Memoized: this feed re-renders with every parent state change (profiles
// landing, selection, hover), and an unmemoized rebuild re-flattened and
// re-sorted the whole community's activity each time.
- const items = React.useMemo(
+ const allItems = React.useMemo(
() => buildActivityItems({ issues, projects, pullRequests, snapshots }),
[issues, projects, pullRequests, snapshots],
);
+ const items = React.useMemo(
+ () =>
+ allItems.filter((item) => {
+ const repository =
+ item.target.type === "issue" || item.target.type === "pull-request"
+ ? item.target.repository.name
+ : null;
+ return matchesProjectsSearch(props.searchQuery ?? "", [
+ item.action,
+ item.body,
+ item.detail,
+ item.target.project.name,
+ item.title,
+ repository,
+ ]);
+ }),
+ [allItems, props.searchQuery],
+ );
// Week buckets are clock-derived; ticked so the memo cannot freeze "This
// week" across a week boundary. Coarse cadence — the boundary moves weekly.
const now = useNow(600_000);
@@ -625,25 +550,22 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
() => groupActivityItems(items, now),
[items, now],
);
- const rangeItems = groups.flatMap((group) =>
- group.items.flatMap((item) => {
- const selectionItem = activitySelectionItem(item);
- return selectionItem ? [selectionItem] : [];
- }),
- );
if (props.isLoading && items.length === 0) {
return
;
}
if (items.length === 0) {
+ const searching = Boolean(props.searchQuery?.trim());
return (
- No project activity yet
+ {searching ? "No matching activity" : "No project activity yet"}
- Commits, reviews, review decisions, and tasks will appear here.
+ {searching
+ ? "Try a different search."
+ : "Commits, reviews, review decisions, and tasks will appear here."}
);
@@ -697,7 +619,6 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
props.onOpenProject(item.target.project)
}
profiles={props.profiles}
- rangeItems={rangeItems}
/>
);
diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx
index 9a465d3da0e..7bdd26b9f5b 100644
--- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx
+++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx
@@ -41,6 +41,7 @@ import type { TimelineMessage } from "@/features/messages/types";
import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import type { Project } from "@/features/projects/hooks";
+import { pickDefaultProjectsAgent } from "@/features/projects/lib/projectAgentSelection";
import { AgentContextPayloadPreview } from "./AgentContextPayloadPreview";
import {
PROJECT_WORKSPACE_CONTEXT_MARKER,
@@ -85,6 +86,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
export type AgentCandidate = {
pubkey: string;
name: string;
+ personaId?: string | null;
/** Managed agents can be auto-started before the prompt is sent. */
isManaged: boolean;
isActive: boolean;
@@ -177,6 +179,7 @@ export function useAgentCandidates() {
const candidates: AgentCandidate[] = managed.map((agent) => ({
pubkey: normalizePubkey(agent.pubkey),
name: agent.name,
+ personaId: agent.personaId,
isManaged: true,
isActive: isManagedAgentActive(agent),
}));
@@ -468,8 +471,7 @@ export function ProjectsAgentPromptPage({
const selectedAgent =
conversation?.agent ??
candidates.find((candidate) => candidate.pubkey === selectedPubkey) ??
- candidates[0] ??
- null;
+ pickDefaultProjectsAgent(candidates);
const richText = useRichTextEditor({
editable: !isSending,
onEditLink: (info) => onEditLinkRef.current?.(info),
diff --git a/desktop/src/features/projects/ui/ProjectsChannelsList.tsx b/desktop/src/features/projects/ui/ProjectsChannelsList.tsx
index b2904afc4e4..b655653d471 100644
--- a/desktop/src/features/projects/ui/ProjectsChannelsList.tsx
+++ b/desktop/src/features/projects/ui/ProjectsChannelsList.tsx
@@ -1,4 +1,4 @@
-import { FolderKanban, Hash } from "lucide-react";
+import { FolderKanban, Hash, LockKeyhole } from "lucide-react";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -6,14 +6,17 @@ import { useChannelsQuery } from "@/features/channels/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { Project } from "@/features/projects/hooks";
import {
+ collapseProjectRelatedChannelRows,
collectProjectRelatedChannelRows,
- projectRelatedChannelRowKey,
+ projectRelatedChannelDisplayRowKey,
} from "@/features/projects/lib/projectRelatedChannels";
import { selectionItemFromChannel } from "@/features/projects/lib/projectSelection";
+import { matchesProjectsSearch } from "@/features/projects/lib/projectsSearch";
import { listRowDescription } from "@/features/projects/lib/projectsViewHelpers";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { ProjectEntityListRow } from "./ProjectEntityListRow";
import { ProjectSelectableGroup } from "./ProjectSelectableGroup";
+import { ProjectPanelState } from "./ProjectPanelState";
function lastMessageAtSeconds(value: string | null | undefined) {
if (!value) return null;
@@ -21,7 +24,13 @@ function lastMessageAtSeconds(value: string | null | undefined) {
return Number.isFinite(ms) ? Math.floor(ms / 1_000) : null;
}
-export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
+export function ProjectsChannelsList({
+ projects,
+ searchQuery = "",
+}: {
+ projects: Project[];
+ searchQuery?: string;
+}) {
const { goChannel } = useAppNavigation();
const channelsQuery = useChannelsQuery({ enabled: projects.length > 0 });
const channelsById = React.useMemo(() => {
@@ -30,25 +39,39 @@ export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
);
}, [channelsQuery.data]);
const rows = React.useMemo(() => {
- const collected = collectProjectRelatedChannelRows(projects);
- return [...collected].sort((left, right) => {
- const leftChannel = channelsById.get(left.channelId);
- const rightChannel = channelsById.get(right.channelId);
- const leftName = leftChannel?.name ?? left.channelId;
- const rightName = rightChannel?.name ?? right.channelId;
- const leftActivity =
- lastMessageAtSeconds(leftChannel?.lastMessageAt) ?? 0;
- const rightActivity =
- lastMessageAtSeconds(rightChannel?.lastMessageAt) ?? 0;
- return (
- rightActivity - leftActivity ||
- leftName.localeCompare(rightName) ||
- left.projectName.localeCompare(right.projectName) ||
- (left.repositoryName ?? "").localeCompare(right.repositoryName ?? "") ||
- left.channelId.localeCompare(right.channelId)
- );
- });
- }, [channelsById, projects]);
+ const collected = collapseProjectRelatedChannelRows(
+ collectProjectRelatedChannelRows(projects),
+ );
+ return collected
+ .filter((row) => {
+ const channel = channelsById.get(row.channelId);
+ return matchesProjectsSearch(searchQuery, [
+ channel?.name,
+ channel?.description,
+ row.projectName,
+ ...row.repositoryNames,
+ ]);
+ })
+ .sort((left, right) => {
+ const leftChannel = channelsById.get(left.channelId);
+ const rightChannel = channelsById.get(right.channelId);
+ const leftName = leftChannel?.name ?? "Channel unavailable";
+ const rightName = rightChannel?.name ?? "Channel unavailable";
+ const leftActivity =
+ lastMessageAtSeconds(leftChannel?.lastMessageAt) ?? 0;
+ const rightActivity =
+ lastMessageAtSeconds(rightChannel?.lastMessageAt) ?? 0;
+ return (
+ rightActivity - leftActivity ||
+ leftName.localeCompare(rightName) ||
+ left.projectName.localeCompare(right.projectName) ||
+ left.repositoryNames
+ .join(",")
+ .localeCompare(right.repositoryNames.join(",")) ||
+ left.channelId.localeCompare(right.channelId)
+ );
+ });
+ }, [channelsById, projects, searchQuery]);
const participantPubkeys = React.useMemo(
() => [
...new Set(
@@ -71,12 +94,12 @@ export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
>();
for (const row of rows) {
const channel = channelsById.get(row.channelId);
- const name = channel?.name ?? row.channelId.slice(0, 8);
- const rowKey = projectRelatedChannelRowKey(row);
+ const name = channel?.name ?? "Channel unavailable";
+ const rowKey = projectRelatedChannelDisplayRowKey(row);
const item = selectionItemFromChannel({
channelId: row.channelId,
people: channel?.participantPubkeys ?? channel?.participants ?? [],
- title: `#${name}`,
+ title: channel ? `#${name}` : name,
});
items.set(rowKey, { ...item, id: `${item.id}:${rowKey}` });
}
@@ -110,11 +133,17 @@ export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
return
;
}
if (rows.length === 0) {
+ const searching = Boolean(searchQuery.trim());
return (
-
- No channels are bound to these projects yet. Link a discussion channel
- to a project or repository and it will show up here.
-
+
);
}
@@ -129,7 +158,7 @@ export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
icon={
}
items={group.rows.flatMap((row) => {
const item = selectionItemsByRowKey.get(
- projectRelatedChannelRowKey(row),
+ projectRelatedChannelDisplayRowKey(row),
);
return item ? [item] : [];
})}
@@ -140,14 +169,18 @@ export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
>
{group.rows.map((row) => {
- const rowKey = projectRelatedChannelRowKey(row);
+ const rowKey = projectRelatedChannelDisplayRowKey(row);
const channel = channelsById.get(row.channelId);
- const name = channel?.name ?? row.channelId.slice(0, 8);
+ const name = channel?.name ?? "Channel unavailable";
const lastActivityAt = lastMessageAtSeconds(
channel?.lastMessageAt,
);
const repositoryLabel =
- row.repositoryName?.trim() || "Project channel";
+ row.repositoryNames.length === 0
+ ? "Project channel"
+ : row.repositoryNames.length === 1
+ ? row.repositoryNames[0]
+ : `${row.repositoryNames.length} repositories`;
const people =
channel?.participantPubkeys ?? channel?.participants ?? [];
const selectionItem = selectionItemsByRowKey.get(rowKey);
@@ -171,9 +204,17 @@ export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
}
dateSeconds={lastActivityAt}
dateTestId="project-channel-row-date"
- description={listRowDescription(channel?.description, name)}
+ description={
+ channel
+ ? listRowDescription(channel.description, name)
+ : "Channel details are unavailable"
+ }
icon={
-
+ channel ? (
+
+ ) : (
+
+ )
}
onClick={() => void goChannel(row.channelId)}
people={people}
@@ -185,8 +226,10 @@ export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
: undefined
}
testId="project-channel-row"
- title={`#${name}`}
- titleAttr={`Open #${name}`}
+ title={channel ? `#${name}` : name}
+ titleAttr={
+ channel ? `Open #${name}` : "Open unavailable channel"
+ }
/>
);
diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx
index c7c55367be5..aa464bf3f1f 100644
--- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx
+++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx
@@ -21,6 +21,7 @@ import {
} from "@/shared/hooks/useIncrementalMount";
import { cn } from "@/shared/lib/cn";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
+import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
@@ -30,6 +31,7 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectSelectableGroup } from "./ProjectSelectableGroup";
+import { ProjectPanelState } from "./ProjectPanelState";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import { groupProjectWorkItemsByProject } from "./projectWorkItemGroups";
@@ -253,21 +255,37 @@ export function ProjectsIssuesList({
);
if (error && issues.length === 0) {
- return loadNotice;
+ return (
+
+ {isRetrying ? "Retrying..." : "Retry"}
+
+ }
+ description={
+ error instanceof Error ? error.message : "The relay request failed."
+ }
+ error
+ panel={false}
+ title="Could not load tasks"
+ />
+ );
}
if (issues.length === 0) {
return (
{loadNotice}
-
- {emptyMessage}
-
+
);
}
diff --git a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx
index 99587133974..4f1f1696ea0 100644
--- a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx
+++ b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx
@@ -1,128 +1,48 @@
import type {
- ProjectsFilter,
- ProjectsRepositoryScope,
ProjectsSort,
ProjectsViewMode,
- ProjectsWorkItemScope,
} from "@/features/projects/lib/projectsViewHelpers";
-import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown";
import { ProjectsViewModeToggle } from "@/features/projects/ui/ProjectsToolbar";
-const PROJECT_SCOPE_OPTIONS: Array<{
- label: string;
- value: ProjectsRepositoryScope;
-}> = [
- { label: "All", value: "all" },
- { label: "Accessible", value: "accessible" },
- { label: "My Projects", value: "mine" },
- { label: "Local", value: "local" },
-];
-const REPOSITORY_SCOPE_OPTIONS: Array<{
- label: string;
- value: ProjectsRepositoryScope;
-}> = [
- { label: "All", value: "all" },
- { label: "Accessible", value: "accessible" },
- { label: "My Repositories", value: "mine" },
- { label: "Local", value: "local" },
- { label: "Buzz-hosted", value: "buzz" },
- { label: "Linked", value: "linked" },
-];
-const PULL_REQUEST_SCOPE_OPTIONS: Array<{
- label: string;
- value: ProjectsWorkItemScope;
-}> = [
- { label: "All", value: "all" },
- { label: "My Reviews", value: "mine" },
-];
-const ISSUE_SCOPE_OPTIONS: Array<{
- label: string;
- value: ProjectsWorkItemScope;
-}> = [
- { label: "All", value: "all" },
- { label: "My Tasks", value: "mine" },
- { label: "Assigned to me", value: "assigned" },
-];
-
type ProjectsListHeaderBarProps = {
- filter: ProjectsFilter;
- issueScope: ProjectsWorkItemScope;
- onIssueScopeChange: (scope: ProjectsWorkItemScope) => void;
- onPullRequestScopeChange: (scope: ProjectsWorkItemScope) => void;
- onRepositoryScopeChange: (scope: ProjectsRepositoryScope) => void;
- onSortChange: (sort: ProjectsSort) => void;
onViewModeChange: (viewMode: ProjectsViewMode) => void;
- pullRequestScope: ProjectsWorkItemScope;
- repositoryScope: ProjectsRepositoryScope;
- sort: ProjectsSort;
viewMode: ProjectsViewMode;
};
-/**
- * Compact controls rendered in the Projects section header.
- */
+/** Shared Projects sort control used by the top navigation/search row. */
+export function ProjectsSortSelect({
+ onChange,
+ sort,
+}: {
+ onChange: (sort: ProjectsSort) => void;
+ sort: ProjectsSort;
+}) {
+ return (
+
+ Sort projects
+ onChange(event.target.value as ProjectsSort)}
+ value={sort}
+ >
+ Recent activity
+ Created date
+ Name
+
+
+ );
+}
+
+/** Compact layout controls rendered in the Projects section header. */
export function ProjectsListHeaderBar({
- filter,
- issueScope,
- onIssueScopeChange,
- onPullRequestScopeChange,
- onRepositoryScopeChange,
- onSortChange,
onViewModeChange,
- pullRequestScope,
- repositoryScope,
- sort,
viewMode,
}: ProjectsListHeaderBarProps) {
- const scopeDropdown =
- filter === "prs" ? (
-
- ) : filter === "issues" ? (
-
- ) : filter === "projects" ? (
-
- ) : (
-
- );
-
return (
- {scopeDropdown}
-
- Sort projects
- onSortChange(event.target.value as ProjectsSort)}
- value={sort}
- >
- Recent activity
- Created date
- Name
-
-
void;
onCreatePullRequest: () => void;
onSelectSection: (section: ProjectsOverviewSection) => void;
- profiles?: UserProfileLookup;
projects: Project[];
pullRequests: ProjectPullRequest[];
summaries?: Record;
@@ -79,7 +76,7 @@ function OverviewActionButton({
}) {
return (
{label}
- {count}
+
+ {count}
+
);
}
@@ -128,43 +127,40 @@ export function ProjectsOverviewPanel({
);
}
-export function ProjectsActivityIntro() {
- const { activeCommunity } = useCommunities();
- const communityIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl);
- const communityIcon = communityIconQuery.data ?? null;
-
+export function ProjectsActivityIntro({
+ digest,
+}: {
+ digest: ProjectsActivityDigest;
+}) {
return (
-
- {communityIcon ? (
-
- ) : (
-
- 🐝
-
- )}
-
Projects Activity
-
- Keeping up with the community has never been easier—or mattered more.
+
+ {digest.prefix}{" "}
+ {digest.highlights.map((highlight, index) => (
+
+ {index > 0
+ ? index === digest.highlights.length - 1
+ ? ", and "
+ : ", "
+ : null}
+
+ {highlight}
+
+
+ ))}
+ {digest.suffix}
);
@@ -178,7 +174,6 @@ export function ProjectsOverviewContextPanel({
onCreateProject,
onCreatePullRequest,
onSelectSection,
- profiles,
projects,
pullRequests,
summaries,
@@ -211,7 +206,10 @@ export function ProjectsOverviewContextPanel({
return (
@@ -239,41 +237,31 @@ export function ProjectsOverviewContextPanel({
)}
{selectionPresentation ? null : (
- <>
-
- {context.action ? (
-
-
- {context.action.label}
-
- ) : null}
-
+ {context.action ? (
+
- {context.stats.map((stat) => (
- onSelectSection(stat.section)}
- />
- ))}
-
-
- {context.people.length > 0 ? (
-
+
+ {context.action.label}
+
) : null}
- >
+
+ {context.stats.map((stat) => (
+ onSelectSection(stat.section)}
+ />
+ ))}
+
+
)}
diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx
index f4315576f57..1a209df6de2 100644
--- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx
+++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx
@@ -21,6 +21,7 @@ import {
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
+import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
@@ -30,12 +31,14 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { ProjectSelectableGroup } from "./ProjectSelectableGroup";
+import { ProjectPanelState } from "./ProjectPanelState";
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
import { groupProjectWorkItemsByProject } from "./projectWorkItemGroups";
type ProjectsPullRequestsListProps = {
/** Render without container chrome — a parent table container provides border and rounding. */
embedded?: boolean;
+ emptyMessage?: string;
error: unknown;
failedSections: ProjectWorkItemSection[];
isLoading: boolean;
@@ -199,6 +202,7 @@ const PullRequestListRow = React.memo(function PullRequestListRow({
export function ProjectsPullRequestsList({
embedded,
+ emptyMessage = "No reviews yet",
error,
failedSections,
isLoading,
@@ -258,21 +262,37 @@ export function ProjectsPullRequestsList({
);
if (error && pullRequests.length === 0) {
- return loadNotice;
+ return (
+
+ {isRetrying ? "Retrying..." : "Retry"}
+
+ }
+ description={
+ error instanceof Error ? error.message : "The relay request failed."
+ }
+ error
+ panel={false}
+ title="Could not load reviews"
+ />
+ );
}
if (pullRequests.length === 0) {
return (
{loadNotice}
-
- No reviews yet.
-
+
);
}
diff --git a/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx
new file mode 100644
index 00000000000..c6b05cc9b1c
--- /dev/null
+++ b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx
@@ -0,0 +1,157 @@
+import { Search, X } from "lucide-react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import * as React from "react";
+
+import type {
+ ProjectsFilter,
+ ProjectsSort,
+} from "@/features/projects/lib/projectsViewHelpers";
+import { ProjectsSortSelect } from "@/features/projects/ui/ProjectsListHeaderBar";
+import { projectsSectionTitle } from "@/features/projects/ui/projectsSectionMeta";
+import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar";
+import { Button } from "@/shared/ui/button";
+
+export function ProjectsSectionSearch({
+ filter,
+ onFilterChange,
+ onQueryChange,
+ onSortChange,
+ sort,
+}: {
+ filter: ProjectsFilter;
+ onFilterChange: (filter: ProjectsFilter) => void;
+ onQueryChange: (query: string) => void;
+ onSortChange: (sort: ProjectsSort) => void;
+ sort: ProjectsSort;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const [query, setQuery] = React.useState("");
+ const deferredQuery = React.useDeferredValue(query);
+ const focusFrameRef = React.useRef(null);
+ const reduceMotion = useReducedMotion();
+ const transition = {
+ duration: reduceMotion ? 0 : 0.06,
+ ease: [0.2, 0.8, 0.2, 1] as const,
+ };
+ const close = React.useCallback(() => {
+ setOpen(false);
+ setQuery("");
+ onQueryChange("");
+ }, [onQueryChange]);
+
+ React.useEffect(() => {
+ onQueryChange(deferredQuery);
+ }, [deferredQuery, onQueryChange]);
+ const focusSearchInput = React.useCallback(
+ (input: HTMLInputElement | null) => {
+ if (!input) return;
+ focusFrameRef.current = window.requestAnimationFrame(() => input.focus());
+ },
+ [],
+ );
+ React.useEffect(
+ () => () => {
+ if (focusFrameRef.current !== null) {
+ window.cancelAnimationFrame(focusFrameRef.current);
+ }
+ },
+ [],
+ );
+
+ return (
+
+
setOpen(true)}
+ size="icon"
+ title={open ? "Close search" : `Search ${projectsSectionTitle(filter)}`}
+ type="button"
+ variant="ghost"
+ >
+
+
+ {open ? (
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx
index b03d5e243a3..910155c4eb7 100644
--- a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx
+++ b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx
@@ -1,4 +1,4 @@
-import { Bot, GitPullRequest, Link2, X } from "lucide-react";
+import { Bot, GitPullRequest, Link2, ListChecks, X } from "lucide-react";
import * as React from "react";
import {
@@ -67,13 +67,26 @@ export function ProjectsSelectionCountMenu({
return (
-
- {presentation.title}
-
-
+
+
+
+
+
+ Selection
+
+
+ {presentation.title}
+
+
+
+
{presentation.actions
.filter(
(action) =>
diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx
index 24a933bcd66..84cf3f678e3 100644
--- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx
+++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx
@@ -1,4 +1,5 @@
import { LayoutGrid, List } from "lucide-react";
+import { motion } from "motion/react";
import * as React from "react";
import type {
@@ -26,6 +27,7 @@ const MASK_RIGHT =
type ProjectsToolbarProps = {
filter: ProjectsFilter;
onFilterChange: (filter: ProjectsFilter) => void;
+ reduceMotion?: boolean;
};
export function ProjectsViewModeToggle({
@@ -104,6 +106,7 @@ function useHorizontalOverflow(ref: React.RefObject) {
export function ProjectsToolbar({
filter,
onFilterChange,
+ reduceMotion = false,
}: ProjectsToolbarProps) {
const scrollRef = React.useRef(null);
const overflow = useHorizontalOverflow(scrollRef);
@@ -149,29 +152,45 @@ export function ProjectsToolbar({
>
Project owner filter
{filterOptions.map((option) => (
- onFilterChange(option.value)}
- type="button"
- variant="ghost"
+ variants={{
+ hidden: {
+ opacity: 0,
+ transition: { duration: reduceMotion ? 0 : 0.025 },
+ },
+ visible: {
+ opacity: 1,
+ transition: { duration: reduceMotion ? 0 : 0.025 },
+ },
+ }}
>
-
-
- {option.label}
+ onFilterChange(option.value)}
+ type="button"
+ variant="ghost"
+ >
+
+
+ {option.label}
+
+
+ {option.label}
+
- {option.label}
-
-
+
+
))}
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx
index be0d3da1f0c..d8b8d86e527 100644
--- a/desktop/src/features/projects/ui/ProjectsView.tsx
+++ b/desktop/src/features/projects/ui/ProjectsView.tsx
@@ -1,4 +1,3 @@
-import { Search } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -17,17 +16,17 @@ import {
} from "@/features/projects/hooks";
import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks";
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
+import { isExplicitProject } from "@/features/projects/projectModels";
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
+import { buildProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest";
+import { matchesProjectsSearch } from "@/features/projects/lib/projectsSearch";
import type { ProjectSelectionItem } from "@/features/projects/lib/projectSelection";
import {
useMemberChannelIds,
useRepositoryUnavailableReasonFor,
} from "@/features/projects/useRepositoryAccess";
-import {
- projectRepoHostForProject,
- projectRepoHostForRepository,
-} from "@/features/projects/lib/projectRepoHost";
+import { projectRepoHostForProject } from "@/features/projects/lib/projectRepoHost";
import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed";
import { ProjectsChannelsList } from "@/features/projects/ui/ProjectsChannelsList";
import {
@@ -42,7 +41,6 @@ import {
import { ProjectsOverviewChromeActions } from "@/features/projects/ui/ProjectsOverviewChromeActions";
import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail";
import {
- openAppSearch,
projectsSectionIcon,
projectsSectionTitle,
} from "@/features/projects/ui/projectsSectionMeta";
@@ -60,37 +58,23 @@ import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChr
import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice";
import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar";
+import { ProjectsSectionSearch } from "@/features/projects/ui/ProjectsSectionSearch";
import { ProjectSectionHeader } from "@/features/projects/ui/ProjectSectionHeader";
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "@/features/projects/ui/projectPanelStyles";
-import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar";
import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection";
-import {
- hasLocalCheckout,
- hasLocalRepositoryCheckout,
-} from "@/features/projects/lib/projectLocalRepos";
+import { hasLocalRepositoryCheckout } from "@/features/projects/lib/projectLocalRepos";
import {
getProjectUpdatedAt,
- isProjectAccessibleToViewer,
- isProjectMine,
- isRepositoryAccessibleToViewer,
projectHasAgent,
projectOwnerIsUser,
projectPeople,
type ProjectsFilter,
- type ProjectsRepositoryScope,
type ProjectsSort,
type ProjectsViewMode,
- type ProjectsWorkItemScope,
readStoredFilter,
- readStoredIssueScope,
- readStoredPullRequestScope,
- readStoredRepositoryScope,
readStoredSort,
readStoredViewMode,
writeStoredFilter,
- writeStoredIssueScope,
- writeStoredPullRequestScope,
- writeStoredRepositoryScope,
writeStoredSort,
writeStoredViewMode,
} from "@/features/projects/lib/projectsViewHelpers";
@@ -130,7 +114,11 @@ export function ProjectsView() {
useProjectsScrollIndicator();
const projectsQuery = useProjectsQuery();
const identityQuery = useIdentityQuery();
- const projects = projectsQuery.data ?? [];
+ const projectReadModels = projectsQuery.data ?? [];
+ const projects = React.useMemo(
+ () => projectReadModels.filter(isExplicitProject),
+ [projectReadModels],
+ );
const localRepositoriesQuery = useProjectLocalRepositoriesQuery(
activeCommunity?.reposDir,
);
@@ -140,9 +128,14 @@ export function ProjectsView() {
? "repositories"
: storedFilter;
});
+ const [searchQuery, setSearchQuery] = React.useState("");
const [overviewPanelOpen, setOverviewPanelOpen] = React.useState(true);
const [narrowContextOpen, setNarrowContextOpen] = React.useState(false);
const contextToggleRef = React.useRef
(null);
+ const selectionDrawerStateRef = React.useRef<{
+ narrow: boolean;
+ open: boolean;
+ } | null>(null);
const isNarrowProjectsLayout = useMediaBreakpoint(
PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX,
);
@@ -150,20 +143,7 @@ export function ProjectsView() {
useProjectPanelWidths("chat");
const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery(
- filter === "repositories" ? projects : [],
- );
- const [repositoryScope, setRepositoryScope] =
- React.useState(() => {
- const storedScope = readStoredRepositoryScope();
- return filter === "projects" &&
- (storedScope === "buzz" || storedScope === "linked")
- ? "all"
- : storedScope;
- });
- const [pullRequestScope, setPullRequestScope] =
- React.useState(() => readStoredPullRequestScope());
- const [issueScope, setIssueScope] = React.useState(
- () => readStoredIssueScope(),
+ filter === "repositories" ? projectReadModels : [],
);
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projects);
// One blobless clone per primary Buzz repository, only while the overview
@@ -231,6 +211,23 @@ export function ProjectsView() {
enabled: projectPubkeys.length > 0,
});
const profiles = profilesQuery.data?.profiles;
+ const activityDigest = React.useMemo(
+ () =>
+ buildProjectsActivityDigest({
+ issues: projectsWorkItemsQuery.data?.issues.items ?? [],
+ nowSeconds: Math.floor(Date.now() / 1_000),
+ projects,
+ pullRequests: projectsWorkItemsQuery.data?.pullRequests.items ?? [],
+ snapshots: repoSnapshotsQuery.data?.snapshots,
+ summaries: activitySummariesQuery.data,
+ }),
+ [
+ activitySummariesQuery.data,
+ projects,
+ projectsWorkItemsQuery.data,
+ repoSnapshotsQuery.data?.snapshots,
+ ],
+ );
const deleteProjectMutation = useDeleteProjectMutation();
const currentPubkey = identityQuery.data?.pubkey;
@@ -242,30 +239,6 @@ export function ProjectsView() {
[],
);
- const handleRepositoryScopeChange = React.useCallback(
- (scope: ProjectsRepositoryScope) => {
- setRepositoryScope(scope);
- writeStoredRepositoryScope(scope);
- },
- [],
- );
-
- const handlePullRequestScopeChange = React.useCallback(
- (scope: ProjectsWorkItemScope) => {
- setPullRequestScope(scope);
- writeStoredPullRequestScope(scope);
- },
- [],
- );
-
- const handleIssueScopeChange = React.useCallback(
- (scope: ProjectsWorkItemScope) => {
- setIssueScope(scope);
- writeStoredIssueScope(scope);
- },
- [],
- );
-
const handleSortChange = React.useCallback((nextSort: ProjectsSort) => {
setSort(nextSort);
writeStoredSort(nextSort);
@@ -281,16 +254,6 @@ export function ProjectsView() {
[localRepositoriesQuery.data],
);
- const repositoryAccessInput = React.useMemo(
- () => ({
- currentPubkey,
- localRepoNames,
- memberChannelIds,
- relayOrigin,
- }),
- [currentPubkey, localRepoNames, memberChannelIds, relayOrigin],
- );
-
const visibleProjects = React.useMemo(() => {
if (filter !== "projects" && filter !== "agents" && filter !== "users") {
return [];
@@ -298,22 +261,20 @@ export function ProjectsView() {
const sortedProjects = projects
.filter((project) => {
+ if (
+ !matchesProjectsSearch(searchQuery, [
+ project.name,
+ project.description,
+ ...project.repositories.flatMap((repository) => [
+ repository.name,
+ repository.description,
+ ]),
+ ])
+ ) {
+ return false;
+ }
const summary = activitySummariesQuery.data?.[project.id];
const people = projectPeople(project, summary);
- if (repositoryScope === "accessible")
- return isProjectAccessibleToViewer(project, repositoryAccessInput);
- if (repositoryScope === "mine")
- return isProjectMine(project, currentPubkey);
- if (repositoryScope === "local")
- return hasLocalCheckout(project, localRepoNames);
- if (repositoryScope === "buzz")
- return (
- projectRepoHostForProject(project, relayOrigin).kind === "buzz"
- );
- if (repositoryScope === "linked")
- return (
- projectRepoHostForProject(project, relayOrigin).kind === "external"
- );
if (filter === "agents") {
return projectHasAgent(project, people, profiles);
}
@@ -338,14 +299,10 @@ export function ProjectsView() {
return sortedProjects;
}, [
activitySummariesQuery.data,
- currentPubkey,
filter,
- localRepoNames,
profiles,
projects,
- relayOrigin,
- repositoryAccessInput,
- repositoryScope,
+ searchQuery,
sort,
]);
@@ -353,7 +310,7 @@ export function ProjectsView() {
if (filter !== "repositories") return [];
const repositories = [
...new Map(
- projects
+ projectReadModels
.flatMap((project) =>
project.repositories.map((repository) => ({
project,
@@ -364,40 +321,13 @@ export function ProjectsView() {
).values(),
];
return repositories
- .filter(({ repository }) => {
- if (repositoryScope === "accessible") {
- return isRepositoryAccessibleToViewer(
- repository,
- repositoryAccessInput,
- );
- }
- if (repositoryScope === "mine") {
- if (!currentPubkey) return false;
- const normalizedCurrentPubkey = normalizePubkey(currentPubkey);
- return (
- normalizePubkey(repository.owner) === normalizedCurrentPubkey ||
- repository.contributors.some(
- (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey,
- )
- );
- }
- if (repositoryScope === "local") {
- return hasLocalRepositoryCheckout(repository, localRepoNames);
- }
- if (repositoryScope === "buzz") {
- return (
- projectRepoHostForRepository(repository, relayOrigin).kind ===
- "buzz"
- );
- }
- if (repositoryScope === "linked") {
- return (
- projectRepoHostForRepository(repository, relayOrigin).kind ===
- "external"
- );
- }
- return true;
- })
+ .filter(({ project, repository }) =>
+ matchesProjectsSearch(searchQuery, [
+ repository.name,
+ repository.description,
+ project.name,
+ ]),
+ )
.sort((left, right) => {
if (sort === "name") {
return left.repository.name.localeCompare(right.repository.name);
@@ -414,61 +344,58 @@ export function ProjectsView() {
return rightUpdatedAt - leftUpdatedAt;
});
}, [
- currentPubkey,
filter,
- localRepoNames,
- projects,
- relayOrigin,
- repositoryAccessInput,
+ projectReadModels,
repositoryActivitySummariesQuery.data,
- repositoryScope,
+ searchQuery,
sort,
]);
const visiblePullRequests = React.useMemo(() => {
const pullRequests = projectsWorkItemsQuery.data?.pullRequests.items ?? [];
- const scopedPullRequests =
- pullRequestScope === "mine" && currentPubkey
- ? pullRequests.filter(
- ({ pullRequest }) =>
- normalizePubkey(pullRequest.author) ===
- normalizePubkey(currentPubkey),
- )
- : pullRequests;
- return [...scopedPullRequests].sort((left, right) => {
- if (sort === "name") {
- return left.pullRequest.title.localeCompare(right.pullRequest.title);
- }
- if (sort === "created") {
- return right.pullRequest.createdAt - left.pullRequest.createdAt;
- }
- return right.pullRequest.updatedAt - left.pullRequest.updatedAt;
- });
- }, [currentPubkey, projectsWorkItemsQuery.data, pullRequestScope, sort]);
+ return pullRequests
+ .filter(({ project, pullRequest, repository }) =>
+ matchesProjectsSearch(searchQuery, [
+ pullRequest.title,
+ pullRequest.content,
+ pullRequest.status,
+ project.name,
+ repository.name,
+ ]),
+ )
+ .sort((left, right) => {
+ if (sort === "name") {
+ return left.pullRequest.title.localeCompare(right.pullRequest.title);
+ }
+ if (sort === "created") {
+ return right.pullRequest.createdAt - left.pullRequest.createdAt;
+ }
+ return right.pullRequest.updatedAt - left.pullRequest.updatedAt;
+ });
+ }, [projectsWorkItemsQuery.data, searchQuery, sort]);
const visibleIssues = React.useMemo(() => {
const issues = projectsWorkItemsQuery.data?.issues.items ?? [];
- const viewer = currentPubkey ? normalizePubkey(currentPubkey) : null;
- const scopedIssues =
- issueScope === "mine" && viewer
- ? issues.filter(({ issue }) => normalizePubkey(issue.author) === viewer)
- : issueScope === "assigned" && viewer
- ? issues.filter(({ issue }) =>
- issue.assignees.some(
- (assignee) => normalizePubkey(assignee) === viewer,
- ),
- )
- : issues;
- return [...scopedIssues].sort((left, right) => {
- if (sort === "name") {
- return left.issue.title.localeCompare(right.issue.title);
- }
- if (sort === "created") {
- return right.issue.createdAt - left.issue.createdAt;
- }
- return right.issue.updatedAt - left.issue.updatedAt;
- });
- }, [currentPubkey, issueScope, projectsWorkItemsQuery.data, sort]);
+ return issues
+ .filter(({ issue, project, repository }) =>
+ matchesProjectsSearch(searchQuery, [
+ issue.title,
+ issue.content,
+ issue.status,
+ project.name,
+ repository.name,
+ ]),
+ )
+ .sort((left, right) => {
+ if (sort === "name") {
+ return left.issue.title.localeCompare(right.issue.title);
+ }
+ if (sort === "created") {
+ return right.issue.createdAt - left.issue.createdAt;
+ }
+ return right.issue.updatedAt - left.issue.updatedAt;
+ });
+ }, [projectsWorkItemsQuery.data, searchQuery, sort]);
const {
agentContext: selectionAgentContext,
overviewContext: overviewAgentContext,
@@ -491,18 +418,11 @@ export function ProjectsView() {
// lets React keep the click responsive and paint the previous tab until
// the new tree is ready instead of blocking the main thread.
React.startTransition(() => {
- if (
- nextFilter === "projects" &&
- (repositoryScope === "buzz" || repositoryScope === "linked")
- ) {
- setRepositoryScope("all");
- writeStoredRepositoryScope("all");
- }
setSelectionAgentContext(null);
setFilter(nextFilter);
});
},
- [repositoryScope, setSelectionAgentContext],
+ [setSelectionAgentContext],
);
// Route by the canonical `owner:dtag` project ID — a bare dtag is
@@ -630,16 +550,7 @@ export function ProjectsView() {
const listHeaderBar = (
);
@@ -675,6 +586,7 @@ export function ProjectsView() {
pullRequests={
projectsWorkItemsQuery.data?.pullRequests.items ?? EMPTY_ITEMS
}
+ searchQuery={searchQuery}
snapshots={repoSnapshotsQuery.data?.snapshots}
/>
>
@@ -688,7 +600,6 @@ export function ProjectsView() {
onCreateIssue: () => setCreateIssueOpen(true),
onCreateProject: () => setCreateProjectOpen(true),
onCreatePullRequest: () => setCreatePullRequestOpen(true),
- profiles,
projects,
pullRequests: contextPullRequests,
summaries: activitySummariesQuery.data,
@@ -722,8 +633,27 @@ export function ProjectsView() {
return (
{
+ const previous = selectionDrawerStateRef.current;
+ selectionDrawerStateRef.current = null;
+ if (!previous) return;
+ if (previous.narrow) {
+ setNarrowContextOpen(previous.open);
+ } else {
+ setOverviewPanelOpen(previous.open);
+ }
+ }}
onSelect={() => {
- if (!isNarrowProjectsLayout) setOverviewPanelOpen(true);
+ if (selectionDrawerStateRef.current) return;
+ selectionDrawerStateRef.current = {
+ narrow: isNarrowProjectsLayout,
+ open: isNarrowProjectsLayout ? narrowContextOpen : overviewPanelOpen,
+ };
+ if (isNarrowProjectsLayout) {
+ setNarrowContextOpen(true);
+ } else {
+ setOverviewPanelOpen(true);
+ }
}}
resetKey={filter}
>
@@ -758,15 +688,8 @@ export function ProjectsView() {
isCreating={createProjectMutation.isPending}
onCreate={async (input) => {
const result = await createProjectMutation.mutateAsync(input);
- if (result.compatibilityWarning) {
- toast.warning("Created as a standalone project", {
- description: result.compatibilityWarning,
- });
- } else {
- toast.success(`Project "${result.project.name}" created.`);
- }
- handleRepositoryScopeChange("all");
- handleFilterChange("projects");
+ toast.success(`Project "${result.project.name}" created.`);
+ await goProject(result.project.id);
}}
onOpenChange={setCreateProjectOpen}
open={createProjectOpen}
@@ -821,33 +744,22 @@ export function ProjectsView() {
)}
data-testid="projects-page-tabs"
>
-
-
-
-
+
{filter === "all" ? (
-
+
@@ -855,7 +767,7 @@ export function ProjectsView() {
) : (
<>
) : filter === "channels" ? (
-
+
) : filter === "projects" ? (
projectItems
) : (
@@ -947,11 +872,12 @@ export function ProjectsView() {
- {error
- ? "Could not load reviews for this repository."
- : "No reviews yet."}
-
+
);
}
return list;
diff --git a/desktop/src/features/projects/ui/projectPanelStyles.ts b/desktop/src/features/projects/ui/projectPanelStyles.ts
index 62055c5fa26..4700520466d 100644
--- a/desktop/src/features/projects/ui/projectPanelStyles.ts
+++ b/desktop/src/features/projects/ui/projectPanelStyles.ts
@@ -13,10 +13,6 @@ export const PROJECT_PICKER_TRIGGER_CLASS =
export const PROJECT_DETAIL_PANEL_CLASS =
"overflow-hidden rounded-xl border border-border/60 bg-transparent";
-/** Empty or loading state using the same transparent project panel shell. */
-export const PROJECT_DETAIL_PANEL_MESSAGE_CLASS =
- "rounded-xl border border-border/60 bg-transparent p-4 text-sm text-muted-foreground";
-
/** Centered, borderless reading column used by selected work-item details. */
export const PROJECT_DETAIL_READING_COLUMN_CLASS =
"mx-auto w-full max-w-3xl overflow-hidden";
diff --git a/desktop/src/features/projects/ui/useRetainedProjectGitViews.test.mjs b/desktop/src/features/projects/ui/useRetainedProjectGitViews.test.mjs
index 72906a50eea..2e111369cc9 100644
--- a/desktop/src/features/projects/ui/useRetainedProjectGitViews.test.mjs
+++ b/desktop/src/features/projects/ui/useRetainedProjectGitViews.test.mjs
@@ -480,9 +480,9 @@ test("selected review chrome and diff query stay aligned across fetch phases", a
selectedPullRequest: result.current.selectedPullRequest,
selectedPullRequestId: REVIEW_A_ID,
});
- assert.equal(
+ assert.match(
screen.getByTestId("project-pull-requests-empty").textContent,
- "No reviews yet.",
+ /^No reviews yet/,
);
assert.equal(screen.queryByTestId("project-pull-request-detail"), null);
} finally {
diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
index 1e0db29cac1..4a2b0349289 100644
--- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
@@ -1,7 +1,6 @@
import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
-import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection";
import { FeatureGate } from "@/shared/features";
import type { Channel, SearchHit } from "@/shared/api/types";
import {
@@ -99,91 +98,88 @@ export function AppSidebarPrimaryMenu({
selectedView,
}: AppSidebarPrimaryMenuProps) {
return (
- <>
-
-
+
+
+
+
+
+ Inbox
+
+ {homeBadgeCount > 0 ? (
+
+ {Math.min(homeBadgeCount, 99)}
+
+ ) : null}
+
+
+
+
+
+ Pulse
+
+
+
+
-
- Inbox
+
+ Projects
- {homeBadgeCount > 0 ? (
-
- {Math.min(homeBadgeCount, 99)}
-
- ) : null}
-
-
-
-
- Pulse
-
-
-
-
-
-
-
- Projects
-
-
-
+
+
+
+
+ Agents
+
+
+
-
- Agents
+
+ Workflows
-
-
-
-
- Workflows
-
-
-
-
-
-
- >
+
+
+
);
}
diff --git a/desktop/src/shared/layout/sidebarLayout.ts b/desktop/src/shared/layout/sidebarLayout.ts
new file mode 100644
index 00000000000..3cb09d0a68f
--- /dev/null
+++ b/desktop/src/shared/layout/sidebarLayout.ts
@@ -0,0 +1,2 @@
+/** Minimum width shared by desktop left and right navigation sidebars. */
+export const SIDEBAR_WIDTH_MIN = 220;
diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx
index 09b6707c34f..d382bb07bf7 100644
--- a/desktop/src/shared/ui/sidebar.tsx
+++ b/desktop/src/shared/ui/sidebar.tsx
@@ -6,6 +6,7 @@ import { cn } from "@/shared/lib/cn";
import { performSidebarDefaultHaptic } from "@/shared/lib/haptics";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { useIsMobile } from "@/shared/hooks/use-mobile";
+import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout";
import { Button } from "@/shared/ui/button";
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
import { Input } from "@/shared/ui/input";
@@ -31,7 +32,6 @@ const SIDEBAR_WIDTH_DEFAULT = 300;
const SIDEBAR_WIDTH_DEFAULT_HAPTIC_THRESHOLD = 2;
const SIDEBAR_WIDTH_DEFAULT_SNAP_DISTANCE = 8;
const SIDEBAR_WIDTH_DEFAULT_MAGNET_DISTANCE = 28;
-const SIDEBAR_WIDTH_MIN = 220;
const SIDEBAR_WIDTH_MAX = 420;
const SIDEBAR_WIDTH_MOBILE = "288px";
const SIDEBAR_WIDTH_ICON = "48px";
diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts
index d7ca393e7a8..13bb447884a 100644
--- a/desktop/tests/e2e/project-commit-detail.spec.ts
+++ b/desktop/tests/e2e/project-commit-detail.spec.ts
@@ -191,11 +191,9 @@ test("top-level project lists show metadata and overflow actions", async ({
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Reviews", exact: true }).click();
- await page.getByRole("button", { name: "Filter reviews" }).click();
await expect(
- page.getByRole("menuitem", { name: "My Reviews" }),
- ).toBeVisible();
- await page.keyboard.press("Escape");
+ page.getByRole("button", { name: "Filter reviews" }),
+ ).toHaveCount(0);
await page.getByTestId("projects-create-menu").hover();
await expect(page.getByRole("menuitem", { name: "Project" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Task" })).toBeVisible();
@@ -224,9 +222,9 @@ test("top-level project lists show metadata and overflow actions", async ({
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Tasks", exact: true }).click();
- await page.getByRole("button", { name: "Filter tasks" }).click();
- await expect(page.getByRole("menuitem", { name: "My Tasks" })).toBeVisible();
- await page.keyboard.press("Escape");
+ await expect(page.getByRole("button", { name: "Filter tasks" })).toHaveCount(
+ 0,
+ );
const issueRow = page.locator('[data-testid^="projects-issue-row-"]').first();
await expect(issueRow).toBeVisible();
const issuePositions = await trailingPositions(issueRow);
@@ -296,8 +294,8 @@ test("creating a project opens its channel conversation", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("multi-repo-demo");
await expect(page.getByTestId("project-agent-chat-panel")).toHaveCount(0);
await expect(page.getByTestId("message-channel-intro")).toBeVisible();
- await expect(page.getByTestId("message-channel-intro")).toContainText(
- "project channel",
+ await expect(page.getByTestId("message-channel-intro")).not.toContainText(
+ "This is the beginning",
);
await expect(
page
@@ -348,6 +346,7 @@ test("creating a project opens its channel conversation", async ({ page }) => {
page.getByTestId("project-home-context-channel"),
).not.toContainText("people in this channel");
await expect(page.getByTestId("add-project-channel")).toBeVisible();
+ await expect(page.getByTestId("add-project-repository")).toBeVisible();
await page.getByTestId("add-project-channel").click();
await expect(page.getByTestId("create-project-channel-dialog")).toBeVisible();
await page.keyboard.press("Escape");
diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts
index 09d606e2dcc..aafce50226d 100644
--- a/desktop/tests/e2e/project-pr-review.spec.ts
+++ b/desktop/tests/e2e/project-pr-review.spec.ts
@@ -1047,15 +1047,15 @@ test("project pull requests report aggregate root query failures", async ({
await page.getByTestId("open-projects-view").click();
await page.getByRole("button", { name: "Reviews", exact: true }).click();
- await expect(page.getByText("Could not load reviews.")).toBeVisible();
+ await expect(page.getByText("Could not load reviews")).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
- await expect(page.getByText("No reviews yet.")).toHaveCount(0);
+ await expect(page.getByText("No reviews yet")).toHaveCount(0);
await page.evaluate(() => {
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [];
});
await page.getByRole("button", { name: "Retry" }).click();
- await expect(page.getByText("Could not load reviews.")).toHaveCount(0);
+ await expect(page.getByText("Could not load reviews")).toHaveCount(0);
await expect(
page.getByRole("button", { name: /^View / }).first(),
).toBeVisible();
@@ -1704,9 +1704,12 @@ test("project overview presents collapsible context beside grouped activity", as
"Projects Activity",
);
await expect(page.getByTestId("projects-activity-search")).toBeVisible();
- await expect(page.getByTestId("projects-activity-intro")).toContainText(
- "Keeping up with the community has never been easier—or mattered more.",
+ await expect(page.getByTestId("projects-activity-summary")).toContainText(
+ /^(This week:|Currently tracking)/,
);
+ await expect(
+ page.getByTestId("projects-activity-summary").locator("strong").first(),
+ ).toBeVisible();
await expect(
page.getByTestId("projects-overview-context-panel"),
).toBeVisible();
@@ -1862,6 +1865,19 @@ test("project overview presents collapsible context beside grouped activity", as
await expect(page.getByTestId("project-channel-repository")).toHaveCount(
channelCount,
);
+ await expect(
+ page.getByTestId("project-channel-repository").first(),
+ ).toHaveCSS("text-align", "left");
+ const affiliationXs = (
+ await page
+ .getByTestId("project-channel-repository")
+ .evaluateAll((elements) =>
+ elements.map((element) => element.getBoundingClientRect().x),
+ )
+ ).filter((x) => Number.isFinite(x));
+ expect(
+ Math.max(...affiliationXs) - Math.min(...affiliationXs),
+ ).toBeLessThanOrEqual(2);
await page.getByTestId("projects-section-projects").click();
await expect(page.getByTestId("projects-page-header")).toContainText(
"Projects",
@@ -2139,6 +2155,38 @@ test("project overview info control animates the context rail", async ({
await expect(toggle).toHaveAttribute("aria-pressed", "true");
});
+test("Projects search replaces and restores the section tabs", async ({
+ page,
+}) => {
+ await enableProjectsFeature(page);
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-projects-view").click();
+ await page.getByTestId("projects-section-projects").click();
+
+ await expect(
+ page.getByTestId("projects-page-tabs").getByRole("combobox"),
+ ).toHaveCount(0);
+ await page.getByTestId("projects-activity-search").click();
+ const search = page.getByTestId("projects-section-search-input");
+ await expect(search).toBeFocused();
+ await expect(page.getByTestId("projects-section-projects")).toHaveCount(0);
+ await expect(
+ page.getByTestId("projects-page-tabs").getByRole("combobox"),
+ ).toHaveValue("updated");
+
+ await search.fill("Space Invaders");
+ await expect(page.getByTestId("project-row-space-invaders-3d")).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("projects-section-search")).toHaveCount(0);
+ await expect(page.getByTestId("projects-section-projects")).toBeVisible();
+
+ await page.getByTestId("projects-activity-search").click();
+ await page.getByTestId("projects-section-search-close").click();
+ await expect(page.getByTestId("projects-section-search")).toHaveCount(0);
+});
+
test("selecting overview list rows switches the context pod to the cluster", async ({
page,
}) => {
@@ -2168,6 +2216,9 @@ test("selecting overview list rows switches the context pod to the cluster", asy
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"1 task",
);
+ await expect(page.getByTestId("projects-selection-summary")).toContainText(
+ "Selection",
+ );
await expect(page.getByTestId("projects-selection-items")).toHaveCount(0);
await expect(page.getByTestId("projects-selection-clear")).toBeVisible();
await expect(page.getByTestId("projects-overview-stats-pod")).toHaveCount(0);
@@ -2224,6 +2275,36 @@ test("selecting overview list rows switches the context pod to the cluster", asy
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
"Tasks",
);
+ await expect(page.getByTestId("projects-overview-context-rail")).toHaveCSS(
+ "width",
+ "288px",
+ );
+});
+
+test("selection restores a previously collapsed Projects context drawer", async ({
+ page,
+}) => {
+ await enableProjectsFeature(page);
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-projects-view").click();
+ await page.getByRole("button", { name: "Tasks", exact: true }).click();
+ await page.getByRole("button", { name: "List layout" }).click();
+
+ const toggle = page.getByTestId("projects-overview-context-toggle");
+ const rail = page.getByTestId("projects-overview-context-rail");
+ await toggle.click();
+ await expect(rail).toHaveCSS("width", "0px");
+
+ const row = page.locator('[data-testid^="projects-issue-row-"]').first();
+ await row.hover();
+ await row.getByTestId("projects-row-select").click();
+ await expect(rail).toHaveCSS("width", "288px");
+ await expect(page.getByTestId("projects-selection-clear")).toBeVisible();
+
+ await page.getByTestId("projects-selection-clear").click();
+ await expect(rail).toHaveCSS("width", "0px");
+ await expect(toggle).toHaveAttribute("aria-pressed", "false");
});
test("repository changes discard captured selection context before agent sends", async ({
@@ -2408,7 +2489,7 @@ test("overview lists position identifying and generic icons consistently", async
}
});
-test("repository info control animates the context rail from the far right", async ({
+test("repository drawer control animates the context rail from the far right", async ({
page,
}) => {
await enableProjectsFeature(page);
@@ -2418,7 +2499,7 @@ test("repository info control animates the context rail from the far right", asy
const chat = page.getByTestId("project-right-panel-chat-tab");
const terminal = page.getByTestId("project-terminal-toggle");
const info = page.getByTestId("project-right-panel-repository-tab");
- const infoIcon = page.getByTestId("project-right-panel-repository-icon");
+ const contextIcon = page.getByTestId("project-right-panel-repository-icon");
const rail = page.getByTestId("project-context-rail");
const repositoryPanel = page.getByTestId("project-repository-actions-panel");
const layout = page.getByTestId("project-panel-layout");
@@ -2450,7 +2531,8 @@ test("repository info control animates the context rail from the far right", asy
expect(terminalBounds?.x).toBeLessThan(chatBounds?.x ?? 0);
expect(chatBounds?.x).toBeLessThan(infoBounds?.x ?? 0);
await expect(info).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
- await expect(infoIcon).toHaveCSS("opacity", "1");
+ await expect(contextIcon).toBeVisible();
+ await expect(info).toHaveAttribute("aria-pressed", "true");
await expect(rail).toHaveCSS("width", "288px");
await expect(layout).toHaveCSS("padding-right", "8px");
await expect(rail).toHaveCSS("transition-duration", "0.2s");
@@ -2458,7 +2540,7 @@ test("repository info control animates the context rail from the far right", asy
await info.click();
await expect(rail).toHaveCSS("width", "0px");
- await expect(infoIcon).toHaveCSS("opacity", "0.6");
+ await expect(info).toHaveAttribute("aria-pressed", "false");
await expect(layout).toHaveAttribute("data-project-context-detached", "true");
expect(
await contentSurface.evaluate((element) => {
@@ -2474,7 +2556,7 @@ test("repository info control animates the context rail from the far right", asy
await info.click();
await expect(rail).toHaveCSS("width", "288px");
- await expect(infoIcon).toHaveCSS("opacity", "1");
+ await expect(info).toHaveAttribute("aria-pressed", "true");
await expect(repositoryPanel).toBeVisible();
});
diff --git a/desktop/tests/e2e/projects-v3-screenshots.spec.ts b/desktop/tests/e2e/projects-v3-screenshots.spec.ts
index 2ac902b1c3f..8964698b869 100644
--- a/desktop/tests/e2e/projects-v3-screenshots.spec.ts
+++ b/desktop/tests/e2e/projects-v3-screenshots.spec.ts
@@ -73,17 +73,11 @@ test("projects activity overview screenshot", async ({ page }) => {
await page.getByTestId("open-projects-view").click();
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
const activityHeader = page.getByTestId("projects-page-header");
- const relayIcon = page.getByTestId("projects-activity-relay-icon");
await expect(activityHeader).toBeVisible();
- await expect(relayIcon).toBeVisible();
- const [activityHeaderBox, relayIconBox] = await Promise.all([
- activityHeader.boundingBox(),
- relayIcon.boundingBox(),
- ]);
- expect(activityHeaderBox).not.toBeNull();
- expect(relayIconBox).not.toBeNull();
- expect((relayIconBox?.y ?? 0) + (relayIconBox?.height ?? 0)).toBeLessThan(
- activityHeaderBox?.y ?? 0,
+ await expect(page.getByTestId("projects-activity-relay-icon")).toHaveCount(0);
+ await expect(page.getByTestId("projects-activity-intro")).toHaveCSS(
+ "text-align",
+ "left",
);
await expect(page.getByTestId("projects-activity-search")).toBeVisible();
await expect(page.getByTestId("projects-activity-intro")).toContainText(