Skip to content
Merged
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
15 changes: 2 additions & 13 deletions desktop/src/app/routes/projects.$projectId.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from "react";
import { createFileRoute, useLocation } from "@tanstack/react-router";

import { parseProjectDetailSearch } from "@/features/projects/lib/projectDetailSearch";
import { usePreviewFeatureWarning } from "@/shared/features";
import { isEntityLinkTab } from "@/shared/lib/entityLink";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

const ProjectDetailScreen = React.lazy(async () => {
Expand All @@ -12,18 +12,7 @@ const ProjectDetailScreen = React.lazy(async () => {

export const Route = createFileRoute("/projects/$projectId")({
component: ProjectDetailRouteComponent,
validateSearch: (search: Record<string, unknown>) => ({
commitHash:
typeof search.commitHash === "string" ? search.commitHash : undefined,
pullRequestId:
typeof search.pullRequestId === "string"
? search.pullRequestId
: undefined,
issueId: typeof search.issueId === "string" ? search.issueId : undefined,
repositoryId:
typeof search.repositoryId === "string" ? search.repositoryId : undefined,
tab: isEntityLinkTab(search.tab) ? search.tab : undefined,
}),
validateSearch: parseProjectDetailSearch,
});

function ProjectDetailRouteComponent() {
Expand Down
31 changes: 30 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";

import { getChannelIntroKind } from "./ChannelPane.helpers.ts";
import {
getChannelIntroKind,
shouldUseFocusIdleDrawer,
} from "./ChannelPane.helpers.ts";

function channel(overrides = {}) {
return {
Expand All @@ -12,6 +15,32 @@ function channel(overrides = {}) {
};
}

test("focus idle drawers yield to every higher-priority auxiliary surface", () => {
const idleDrawer = {
channelManagementOpen: false,
hasAgentSession: false,
hasIdleAuxiliaryPanel: true,
hasIdlePanelCloseHandler: true,
hasProfilePanel: false,
hasThreadSurface: false,
useSplitAuxiliaryPane: true,
};

assert.equal(shouldUseFocusIdleDrawer(idleDrawer), true);
for (const surface of [
"channelManagementOpen",
"hasAgentSession",
"hasProfilePanel",
"hasThreadSurface",
]) {
assert.equal(
shouldUseFocusIdleDrawer({ ...idleDrawer, [surface]: true }),
false,
`idle drawer must yield when ${surface} is open`,
);
}
});

test("getChannelIntroKind names project homes ahead of regular streams", () => {
assert.equal(getChannelIntroKind(channel(), true), "project channel");
assert.equal(getChannelIntroKind(channel(), false), "regular channel");
Expand Down
28 changes: 28 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@ import type { TimelineMessage } from "@/features/messages/types";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";

export function shouldUseFocusIdleDrawer({
channelManagementOpen,
hasAgentSession,
hasIdleAuxiliaryPanel,
hasIdlePanelCloseHandler,
hasProfilePanel,
hasThreadSurface,
useSplitAuxiliaryPane,
}: {
channelManagementOpen: boolean;
hasAgentSession: boolean;
hasIdleAuxiliaryPanel: boolean;
hasIdlePanelCloseHandler: boolean;
hasProfilePanel: boolean;
hasThreadSurface: boolean;
useSplitAuxiliaryPane: boolean;
}): boolean {
return (
useSplitAuxiliaryPane &&
!channelManagementOpen &&
!hasAgentSession &&
!hasProfilePanel &&
!hasThreadSurface &&
hasIdleAuxiliaryPanel &&
hasIdlePanelCloseHandler
);
}

export function getChannelIntroKind(
channel: Channel,
projectHome = false,
Expand Down
38 changes: 23 additions & 15 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ import {
WelcomeComposerGuidanceLayer,
} from "@/features/channels/ui/WelcomeComposerBanner";
import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner";
import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers";
import {
mentionsKnownAgent,
shouldUseFocusIdleDrawer,
} from "@/features/channels/ui/ChannelPane.helpers";
import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle";
import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph";
import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
Expand Down Expand Up @@ -464,10 +467,25 @@ export const ChannelPane = React.memo(function ChannelPane({
threadViewMode === "focus" &&
useSplitAuxiliaryPane &&
(Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
const useFocusIdleDrawer =
useSplitAuxiliaryPane &&
Boolean(idleAuxiliaryPanel) &&
Boolean(onCloseIdleAuxiliaryPanel);
const selectedAgent = React.useMemo(
() =>
agentSessionSelection.resolveSelectedAgentSession({
agentSessionAgents,
openAgentSessionPubkey,
profilePanelPubkey,
profiles,
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
const useFocusIdleDrawer = shouldUseFocusIdleDrawer({
channelManagementOpen,
hasAgentSession: Boolean(activeChannel && selectedAgent),
hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel),
hasIdlePanelCloseHandler: Boolean(onCloseIdleAuxiliaryPanel),
hasProfilePanel: Boolean(profilePanelPubkey),
hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton,
useSplitAuxiliaryPane,
});
const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
useFocusThreadDrawer || useFocusIdleDrawer,
useFocusThreadDrawer
Expand All @@ -481,16 +499,6 @@ export const ChannelPane = React.memo(function ChannelPane({
onExternalTargetResolved: onThreadScrollTargetResolved,
onModeChange: markExitComplete,
});
const selectedAgent = React.useMemo(
() =>
agentSessionSelection.resolveSelectedAgentSession({
agentSessionAgents,
openAgentSessionPubkey,
profilePanelPubkey,
profiles,
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
const hasSplitAuxiliaryPane =
useSplitAuxiliaryPane &&
(channelManagementOpen ||
Expand Down
12 changes: 10 additions & 2 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 Down Expand Up @@ -124,9 +128,12 @@ export function useChannelIntro({

return {
actions,
channelKindLabel: getChannelIntroKind(activeChannel),
channelKindLabel: getChannelIntroKind(activeChannel, projectHome),
channelName: activeChannel.name,
description: getChannelIntroDescription(activeChannel),
icon: projectHome ? (
<ProjectChannelIcon className="h-7 w-7" />
) : undefined,
};
}, [
activeChannel,
Expand All @@ -136,5 +143,6 @@ export function useChannelIntro({
onCreateChannel,
onOpenMembers,
onWelcomeAddAgent,
projectHome,
]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,53 @@ test("pointers to unknown channels or agents are not restorable", () => {
);
});

test("a stored project-channel pointer restores when it matches the home channel", () => {
const home = {
id: "project-channel-1",
channelType: "stream",
isMember: true,
memberPubkeys: [SELF_PUBKEY, AGENT_PUBKEY],
participantPubkeys: [],
};
const restored = restoreProjectsAgentConversation({
stored: {
agentPubkey: AGENT_PUBKEY,
channelId: home.id,
opener: OPENER,
},
channels: [home],
candidates: [AGENT],
currentPubkey: SELF_PUBKEY,
homeChannelId: home.id,
});
assert.equal(restored?.channel, home);
assert.equal(restored?.agent, AGENT);
});

test("a stored project-channel pointer does not restore a different home", () => {
const home = {
id: "project-channel-1",
channelType: "stream",
isMember: true,
memberPubkeys: [SELF_PUBKEY],
participantPubkeys: [],
};
assert.equal(
restoreProjectsAgentConversation({
stored: {
agentPubkey: AGENT_PUBKEY,
channelId: home.id,
opener: OPENER,
},
channels: [home],
candidates: [AGENT],
currentPubkey: SELF_PUBKEY,
homeChannelId: "other-project-channel",
}),
null,
);
});

test("a pointer naming a non-DM or foreign-participant channel is not restorable", () => {
const stored = {
agentPubkey: AGENT_PUBKEY,
Expand Down Expand Up @@ -532,6 +579,29 @@ test("the captured scope rides every relay side effect of a first send", async (
assert.equal(result.channel.id, "dm-on-wss://tenant-a.example");
});

test("a home channel first send does not open a DM", async () => {
const backend = makeScopedBackend("wss://tenant-a.example");
const home = { id: "project-channel-1" };
const result = await submitProjectAgentMessage({
agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true },
conversation: null,
content: "build this project",
mentionPubkeys: [AGENT_PUBKEY],
relayScope: "wss://tenant-a.example",
signerScope: SELF_PUBKEY,
homeChannel: home,
startAgent: backend.startAgent,
openDm: () => {
throw new Error("project home chat must use the project channel");
},
send: backend.send,
});

assert.deepEqual(backend.state.dmOpens, []);
assert.equal(result.channel.id, home.id);
assert.equal(backend.state.sends[0].request.channelId, home.id);
});

test("follow-ups reply to the opener so same-second id ordering cannot hide them", async () => {
const backend = makeScopedBackend("wss://tenant-a.example");
await submitProjectAgentMessage({
Expand Down
18 changes: 16 additions & 2 deletions desktop/src/features/projects/lib/projectAgentConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,14 @@ export function restoreProjectsAgentConversation<
channels,
candidates,
currentPubkey,
homeChannelId,
}: {
stored: StoredProjectsAgentConversation | null;
channels: readonly Channel[];
candidates: readonly Agent[];
currentPubkey: string | null;
/** When set, a stored pointer to this project channel (not a DM) can restore. */
homeChannelId?: string | null;
}): {
channel: Channel;
agent: Agent;
Expand All @@ -74,9 +77,16 @@ export function restoreProjectsAgentConversation<
const agent = candidates.find(
(candidate) => candidate.pubkey === agentPubkey,
);
if (!channel || !agent || channel.channelType !== "dm") return null;
const participants = channel.participantPubkeys.map(normalizePubkey);
if (!channel || !agent) return null;
const self = normalizePubkey(currentPubkey);
if (homeChannelId && channel.id === homeChannelId) {
// Project-home chat lives on the project channel. Membership is the
// restore proof — the channel is not a 1:1 DM.
if (!channel.isMember) return null;
return { agent, channel, opener: stored.opener };
}
if (channel.channelType !== "dm") return null;
const participants = channel.participantPubkeys.map(normalizePubkey);
const hasAgent = participants.includes(agentPubkey);
// The contract is participants === {agent, self}: requiring the current
// user's own membership matters as much as rejecting strangers — a stored
Expand Down Expand Up @@ -159,6 +169,7 @@ export async function submitProjectAgentMessage<Ch extends { id: string }>({
mediaTags,
relayScope,
signerScope,
homeChannel,
startAgent,
openDm,
send,
Expand All @@ -174,6 +185,8 @@ export async function submitProjectAgentMessage<Ch extends { id: string }>({
/** Signing identity (owner pubkey, hex) captured together with
* `relayScope`; null when unknown. */
signerScope: string | null;
/** When set, the first message lands here instead of opening a 1:1 DM. */
homeChannel?: Ch | null;
startAgent: (input: {
pubkey: string;
expectedRelayUrl?: string;
Expand Down Expand Up @@ -205,6 +218,7 @@ export async function submitProjectAgentMessage<Ch extends { id: string }>({
}
const channel =
conversation?.channel ??
homeChannel ??
(await openDm({
pubkeys: [agent.pubkey],
expectedRelayUrl,
Expand Down
Loading
Loading