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
9 changes: 4 additions & 5 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import {
MessageThreadPanelHeader,
ThreadMessageSkeleton,
} from "./MessageThreadPanelSkeleton";
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
import type { ThreadDepthGuideAction } from "./MessageRow";
import { MessageThreadRow } from "./MessageThreadRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { UnreadDivider } from "./UnreadDivider";
Expand Down Expand Up @@ -591,15 +592,14 @@ export function MessageThreadPanel({
data-testid="message-thread-head"
>
<div className="rounded-2xl">
<MessageRow
<MessageThreadRow
actionBarPlacement="inside"
channelId={channelId}
currentPubkey={currentPubkey}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThread={isFollowingThread}
isUnread={isMessageUnreadById?.(threadHead.id)}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
onDelete &&
Expand Down Expand Up @@ -725,7 +725,7 @@ export function MessageThreadPanel({
key={entry.message.renderKey ?? entry.message.id}
>
{showUnreadDivider ? <UnreadDivider /> : null}
<MessageRow
<MessageThreadRow
channelId={channelId}
currentPubkey={currentPubkey}
collapseDepthGuideActions={collapseDepthGuideActions}
Expand Down Expand Up @@ -753,7 +753,6 @@ export function MessageThreadPanel({
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isContinuation={isContinuation}
isUnread={isMessageUnreadById?.(entry.message.id)}
layoutVariant="thread-reply"
message={entry.message}
onCollapseDepthGuide={handleCollapseDepthGuide}
onCollapseDepthGuideHoverChange={
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/features/messages/ui/MessageThreadRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type * as React from "react";

import { MessageRow } from "./MessageRow";

type MessageThreadRowProps = Omit<
React.ComponentProps<typeof MessageRow>,
"layoutVariant"
>;

/** The canonical message-row presentation used inside channel threads. */
export function MessageThreadRow(props: MessageThreadRowProps) {
return <MessageRow {...props} layoutVariant="thread-reply" />;
}
75 changes: 75 additions & 0 deletions desktop/src/features/messages/ui/MessageThreadTranscript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as React from "react";

import {
hasSameMessageAuthor,
isWithinGroupingWindow,
} from "@/features/messages/lib/messageGrouping";
import { THREAD_PANEL_MESSAGE_GUTTER_CLASS } from "@/features/messages/lib/messageThreadPanelLayout";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { MessageThreadRow } from "./MessageThreadRow";

type MessageThreadTranscriptProps = {
channelId: string;
className?: string;
currentPubkey?: string;
messages: TimelineMessage[];
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
profiles?: UserProfileLookup;
testId?: string;
};

/**
* Channel-thread message presentation without the panel header or composer.
* Callers keep ownership of transport and compose semantics while sharing the
* same row layout, grouping, gutters, and actions as `MessageThreadPanel`.
*/
export function MessageThreadTranscript({
channelId,
className,
currentPubkey,
messages,
onToggleReaction,
profiles,
testId = "message-thread-transcript",
}: MessageThreadTranscriptProps) {
const renderItems = React.useMemo(() => {
let previousMessage: TimelineMessage | null = null;
return messages.map((message) => {
const isContinuation =
hasSameMessageAuthor(previousMessage, message) &&
isWithinGroupingWindow(previousMessage?.createdAt, message.createdAt);
previousMessage = message;
return { isContinuation, message };
});
}, [messages]);

return (
<div
className={cn(
THREAD_PANEL_MESSAGE_GUTTER_CLASS,
"space-y-0 pb-3 pt-0",
className,
)}
data-testid={testId}
>
{renderItems.map(({ isContinuation, message }) => (
<MessageThreadRow
channelId={channelId}
currentPubkey={currentPubkey}
isContinuation={isContinuation}
key={message.renderKey ?? message.id}
message={message}
onToggleReaction={onToggleReaction}
profiles={profiles}
showDepthGuides={false}
/>
))}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";

import { projectDetailSelectionItem } from "./projectDetailSelectionItem.ts";

const repository = {
channelId: "trusted-repository-channel",
};

test("detail work items ignore author-claimed origin channels", () => {
const issue = projectDetailSelectionItem({
issue: {
author: "issue-author",
channelId: "forged-origin-channel",
id: "issue-id",
repoAddress: null,
title: "Forged origin task",
},
projectChannelId: "trusted-project-channel",
projectId: "project-id",
repository,
});
const pullRequest = projectDetailSelectionItem({
projectChannelId: "trusted-project-channel",
projectId: "project-id",
pullRequest: {
author: "review-author",
channelId: "forged-origin-channel",
id: "review-id",
repoAddress: null,
title: "Forged origin review",
},
repository,
});

assert.equal(issue?.channelId, "trusted-repository-channel");
assert.equal(pullRequest?.channelId, "trusted-repository-channel");
});

test("detail items fall back to the trusted project channel", () => {
const item = projectDetailSelectionItem({
issue: {
author: "issue-author",
channelId: "forged-origin-channel",
id: "issue-id",
repoAddress: null,
title: "Forged origin task",
},
projectChannelId: "trusted-project-channel",
projectId: "project-id",
repository: { ...repository, channelId: null },
});

assert.equal(item?.channelId, "trusted-project-channel");
});
63 changes: 63 additions & 0 deletions desktop/src/features/projects/lib/projectDetailSelectionItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {
ProjectIssue,
ProjectPullRequest,
Repository,
} from "@/features/projects/hooks";
import {
type ProjectSelectionItem,
selectionItemFromCommit,
selectionItemFromReview,
selectionItemFromTask,
} from "@/features/projects/lib/projectSelection";
import {
commitShareLink,
issueShareLink,
pullRequestShareLink,
} from "@/features/projects/lib/projectShareLinks";
import type { ProjectRepoCommit } from "@/shared/api/types";

export function projectDetailSelectionItem({
commit,
issue,
projectChannelId,
projectId,
pullRequest,
repository,
}: {
commit?: ProjectRepoCommit | null;
issue?: ProjectIssue | null;
projectChannelId?: string | null;
projectId: string;
pullRequest?: ProjectPullRequest | null;
repository: Repository;
}): ProjectSelectionItem | null {
const channelId = repository.channelId ?? projectChannelId;
if (issue) {
return selectionItemFromTask({
author: issue.author,
channelId,
id: issue.id,
shareLink: issueShareLink(issue),
title: issue.title,
});
}
if (pullRequest) {
return selectionItemFromReview({
author: pullRequest.author,
channelId,
id: pullRequest.id,
shareLink: pullRequestShareLink(pullRequest),
title: pullRequest.title,
});
}
if (commit) {
return selectionItemFromCommit({
channelId,
commitHash: commit.hash,
projectId,
shareLink: commitShareLink(repository, commit.hash),
title: commit.subject,
});
}
return null;
}
21 changes: 15 additions & 6 deletions desktop/src/features/projects/ui/ProjectCards.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CircleAlert,
CircleDot,
FolderGit2,
Folders,
GitCommit,
GitPullRequest,
Expand Down Expand Up @@ -172,8 +173,7 @@ const PROJECT_STAT_ITEMS = [
] as const;

/**
* Textual commit/PR/issue counts. Repository lists show these next to the
* activity bar; project lists show the bar alone (counts via its tooltips).
* Textual commit/PR/issue counts for project and repository cards.
*/
export function ProjectStatsRow({
summary,
Expand Down Expand Up @@ -242,7 +242,10 @@ export function ProjectActivityBar({
<Tooltip key={item.barClass}>
<TooltipTrigger asChild>
<div
aria-label={`${item.count} ${item.text}`}
className={cn("h-full", item.barClass)}
data-testid="project-activity-segment"
role="img"
style={{ width: `${(item.count / total) * 100}%` }}
/>
</TooltipTrigger>
Expand Down Expand Up @@ -604,14 +607,18 @@ export function ProjectListRow({
});
return (
<ProjectEntityListRow
affiliation={`${repositoryCount} ${
affiliation={
<span className="flex items-center justify-end gap-1">
<FolderGit2 className="h-3.5 w-3.5" />
<span>{repositoryCount}</span>
</span>
}
affiliationTestId="projects-row-context"
affiliationTitle={`${repositoryCount} ${
repositoryCount === 1 ? "repository" : "repositories"
}`}
affiliationTestId="projects-row-context"
dateSeconds={getProjectUpdatedAt(project, summary)}
dateTestId="projects-row-date"
description={listRowDescription(project.description, project.name)}
descriptionTestId="projects-row-description"
icon={<Folders className="h-3.5 w-3.5 text-muted-foreground/70" />}
onClick={() => onOpen(project)}
people={people}
Expand All @@ -635,6 +642,8 @@ export function ProjectListRow({
</span>
}
titleAttr={project.name}
titleSecondary={listRowDescription(project.description, project.name)}
titleSecondaryTestId="projects-row-description"
trailing={
<ProjectActionsMenu
canDelete={canDelete}
Expand Down
9 changes: 7 additions & 2 deletions desktop/src/features/projects/ui/ProjectContextRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ export function ProjectContextRail({
open,
panelWidthPx,
resizing = false,
rounded = true,
testId = "project-context-rail",
}: {
children: React.ReactNode;
open: boolean;
panelWidthPx: number;
resizing?: boolean;
rounded?: boolean;
testId?: string;
}) {
return (
<div
aria-hidden={!open}
className={cn(
"relative h-full shrink-0 overflow-hidden motion-reduce:transition-none",
"relative z-30 h-full shrink-0 overflow-hidden motion-reduce:transition-none",
resizing
? "transition-none"
: "transition-[width] duration-200 ease-linear",
Expand All @@ -34,7 +36,10 @@ export function ProjectContextRail({
}}
>
<div
className="absolute inset-y-0 left-2 overflow-hidden rounded-2xl"
className={cn(
"absolute inset-y-0 left-2 overflow-hidden",
rounded && "rounded-2xl",
)}
data-testid={`${testId}-panel`}
style={{ width: panelWidthPx }}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export function ProjectConversationPanelController({
open={fallbackVisible}
panelWidthPx={fallbackPanelWidthPx}
resizing={fallbackPanelResizing}
rounded={detached}
>
{fallbackPanel}
</ProjectContextRail>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ export function ProjectDetailRightPanel({
context,
detachedRepository = false,
mode,
sharedHeaderBackdrop,
onClose,
...repositoryProps
}: RepositoryPanelProps & {
context: ProjectDetailAgentContext;
detachedRepository?: boolean;
mode: ProjectRightPanelMode;
sharedHeaderBackdrop?: boolean;
onClose: () => void;
}) {
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
Expand All @@ -43,9 +43,9 @@ export function ProjectDetailRightPanel({
constrainToAvailableSpace={false}
context={context}
key={`${relayScope}:${signerScope}:${context.repoAddress}`}
onClose={onClose}
onResetWidth={repositoryProps.onResetWidth}
onResizeStart={repositoryProps.onResizeStart}
sharedHeaderBackdrop={sharedHeaderBackdrop}
widthPx={repositoryProps.widthPx}
/>
);
Expand Down
Loading