Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 15 additions & 6 deletions desktop/src/features/channels/ui/useChannelIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -103,9 +107,9 @@ export function useChannelIntro({

if (onAddAgent) {
actions.push({
description: "Bring them in.",
icon: <Bot aria-hidden className="h-6 w-6" />,
label: "Add agents",
description: "Add an agent here.",
icon: <Bot aria-hidden className="h-5 w-5" />,
label: "Add agent",
onClick: onAddAgent,
testId: "channel-intro-action-create-agent",
});
Expand All @@ -114,7 +118,7 @@ export function useChannelIntro({
if (onOpenMembers) {
actions.push({
description: "Invite members.",
icon: <UserPlus aria-hidden className="h-6 w-6" />,
icon: <UserPlus aria-hidden className="h-5 w-5" />,
label: "Add people",
onClick: onOpenMembers,
testId: "channel-intro-action-add-people",
Expand All @@ -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 ? (
<ProjectChannelIcon className="h-7 w-7" />
) : undefined,
};
}, [
activeChannel,
Expand All @@ -136,5 +144,6 @@ export function useChannelIntro({
onCreateChannel,
onOpenMembers,
onWelcomeAddAgent,
projectHome,
]);
}
17 changes: 10 additions & 7 deletions desktop/src/features/messages/ui/ChannelIntroBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type ChannelIntro = {
channelKindLabel: string;
channelName: string;
description?: string | null;
hideBeginning?: boolean;
icon?: React.ReactNode;
};

Expand Down Expand Up @@ -50,13 +51,15 @@ export function ChannelIntroBlock({
<p className="mt-4 max-w-2xl truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{intro.channelName}
</p>
<p className="mt-1 max-w-2xl text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{intro.channelKindLabel}
</span>
.
</p>
{intro.hideBeginning ? null : (
<p className="mt-1 max-w-2xl text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{intro.channelKindLabel}
</span>
.
</p>
)}
{intro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{intro.description}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
16 changes: 16 additions & 0 deletions desktop/src/features/projects/lib/projectAgentSelection.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";

import {
collapseProjectRelatedChannelRows,
collectProjectRelatedChannelRows,
listProjectBoundChannels,
listProjectChildChannels,
Expand Down Expand Up @@ -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([
Expand Down
42 changes: 42 additions & 0 deletions desktop/src/features/projects/lib/projectRelatedChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, ProjectRelatedChannelDisplayRow>();
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]);
});
88 changes: 88 additions & 0 deletions desktop/src/features/projects/lib/projectsActivityDigest.ts
Original file line number Diff line number Diff line change
@@ -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<string, ProjectRepoSnapshot>;
summaries?: Record<string, ProjectActivitySummary>;
}): ProjectsActivityDigest {
const since = nowSeconds - WEEK_SECONDS;
const activeProjectIds = new Set<string>();
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: ".",
};
}
Loading
Loading