mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
feat: implement multi-user access and invite flows (#3784)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies. > - V1 needs to stay local-first while also supporting shared, authenticated deployments. > - Human operators need real identities, company membership, invite flows, profile surfaces, and company-scoped access controls. > - Agents and operators also need the existing issue, inbox, workspace, approval, and plugin flows to keep working under those authenticated boundaries. > - This branch accumulated the multi-user implementation, follow-up QA fixes, workspace/runtime refinements, invite UX improvements, release-branch conflict resolution, and review hardening. > - This pull request consolidates that branch onto the current `master` branch as a single reviewable PR. > - The benefit is a complete multi-user implementation path with tests and docs carried forward without dropping existing branch work. ## What Changed - Added authenticated human-user access surfaces: auth/session routes, company user directory, profile settings, company access/member management, join requests, and invite management. - Added invite creation, invite landing, onboarding, logo/branding, invite grants, deduped join requests, and authenticated multi-user E2E coverage. - Tightened company-scoped and instance-admin authorization across board, plugin, adapter, access, issue, and workspace routes. - Added profile-image URL validation hardening, avatar preservation on name-only profile updates, and join-request uniqueness migration cleanup for pending human requests. - Added an atomic member role/status/grants update path so Company Access saves no longer leave partially updated permissions. - Improved issue chat, inbox, assignee identity rendering, sidebar/account/company navigation, workspace routing, and execution workspace reuse behavior for multi-user operation. - Added and updated server/UI tests covering auth, invites, membership, issue workspace inheritance, plugin authz, inbox/chat behavior, and multi-user flows. - Merged current `public-gh/master` into this branch, resolved all conflicts, and verified no `pnpm-lock.yaml` change is included in this PR diff. ## Verification - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx` - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/workspace-runtime-service-authz.test.ts server/src/__tests__/access-validators.test.ts` - `pnpm exec vitest run server/src/__tests__/authz-company-access.test.ts server/src/__tests__/routines-routes.test.ts server/src/__tests__/sidebar-preferences-routes.test.ts server/src/__tests__/approval-routes-idempotency.test.ts server/src/__tests__/openclaw-invite-prompt-route.test.ts server/src/__tests__/agent-cross-tenant-authz-routes.test.ts server/src/__tests__/routines-e2e.test.ts` - `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts ui/src/pages/CompanyAccess.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm db:generate` - `npx playwright test --config tests/e2e/playwright.config.ts --list` - Confirmed branch has no uncommitted changes and is `0` commits behind `public-gh/master` before PR creation. - Confirmed no `pnpm-lock.yaml` change is staged or present in the PR diff. ## Risks - High review surface area: this PR contains the accumulated multi-user branch plus follow-up fixes, so reviewers should focus especially on company-boundary enforcement and authenticated-vs-local deployment behavior. - UI behavior changed across invites, inbox, issue chat, access settings, and sidebar navigation; no browser screenshots are included in this branch-consolidation PR. - Plugin install, upgrade, and lifecycle/config mutations now require instance-admin access, which is intentional but may change expectations for non-admin board users. - A join-request dedupe migration rejects duplicate pending human requests before creating unique indexes; deployments with unusual historical duplicates should review the migration behavior. - Company member role/status/grant saves now use a new combined endpoint; older separate endpoints remain for compatibility. - Full production build was not run locally in this heartbeat; CI should cover the full matrix. ## Model Used - OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use environment. Exact deployed model identifier and context window were not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Note on screenshots: this is a branch-consolidation PR for an already-developed multi-user branch, and no browser screenshots were captured during this heartbeat. --------- Co-authored-by: dotta <dotta@example.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e93e418cbf
commit
b9a80dcf22
150 changed files with 26872 additions and 1289 deletions
|
|
@ -4,6 +4,7 @@ import { timeAgo } from "../lib/timeAgo";
|
|||
import { cn } from "../lib/utils";
|
||||
import { formatActivityVerb } from "../lib/activity-format";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
|
||||
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
||||
switch (entityType) {
|
||||
|
|
@ -19,13 +20,14 @@ function entityLink(entityType: string, entityId: string, name?: string | null):
|
|||
interface ActivityRowProps {
|
||||
event: ActivityEvent;
|
||||
agentMap: Map<string, Agent>;
|
||||
userProfileMap?: Map<string, CompanyUserProfile>;
|
||||
entityNameMap: Map<string, string>;
|
||||
entityTitleMap?: Map<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap });
|
||||
export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap, userProfileMap });
|
||||
|
||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||
const heartbeatAgentId = isHeartbeatEvent
|
||||
|
|
@ -43,13 +45,16 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl
|
|||
: entityLink(event.entityType, event.entityId, name);
|
||||
|
||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
||||
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown");
|
||||
const userProfile = event.actorType === "user" ? userProfileMap?.get(event.actorId) : null;
|
||||
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : userProfile?.label ?? (event.actorType === "user" ? "Board" : event.actorId || "Unknown"));
|
||||
const actorAvatarUrl = userProfile?.image ?? null;
|
||||
|
||||
const inner = (
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0 truncate">
|
||||
<Identity
|
||||
name={actorName}
|
||||
avatarUrl={actorAvatarUrl}
|
||||
size="xs"
|
||||
className="align-baseline"
|
||||
/>
|
||||
|
|
|
|||
114
ui/src/components/CloudAccessGate.tsx
Normal file
114
ui/src/components/CloudAccessGate.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Navigate, Outlet, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
|
||||
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoBoardAccessPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">No company access</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This account is signed in, but it does not have an active company membership or instance-admin access on
|
||||
this Paperclip instance.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Use a company invite or sign in with an account that already belongs to this org.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloudAccessGate() {
|
||||
const location = useLocation();
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as
|
||||
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
|
||||
| undefined;
|
||||
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
|
||||
? 2000
|
||||
: false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: isAuthenticatedMode,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const boardAccessQuery = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
enabled: isAuthenticatedMode && !!sessionQuery.data,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (
|
||||
healthQuery.isLoading ||
|
||||
(isAuthenticatedMode && sessionQuery.isLoading) ||
|
||||
(isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading)
|
||||
) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (healthQuery.error || boardAccessQuery.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
|
||||
{healthQuery.error instanceof Error
|
||||
? healthQuery.error.message
|
||||
: boardAccessQuery.error instanceof Error
|
||||
? boardAccessQuery.error.message
|
||||
: "Failed to load app state"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
const next = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
return <Navigate to={`/auth?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
if (
|
||||
isAuthenticatedMode &&
|
||||
sessionQuery.data &&
|
||||
!boardAccessQuery.data?.isInstanceAdmin &&
|
||||
(boardAccessQuery.data?.companyIds.length ?? 0) === 0
|
||||
) {
|
||||
return <NoBoardAccessPage />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ interface CompanyPatternIconProps {
|
|||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
className?: string;
|
||||
logoFit?: "cover" | "contain";
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
|
|
@ -165,6 +166,7 @@ export function CompanyPatternIcon({
|
|||
logoUrl,
|
||||
brandColor,
|
||||
className,
|
||||
logoFit = "cover",
|
||||
}: CompanyPatternIconProps) {
|
||||
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
|
@ -189,7 +191,10 @@ export function CompanyPatternIcon({
|
|||
src={logo}
|
||||
alt={`${companyName} logo`}
|
||||
onError={() => setImageError(true)}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
className={cn(
|
||||
"absolute inset-0 h-full w-full",
|
||||
logoFit === "contain" ? "object-contain" : "object-cover",
|
||||
)}
|
||||
/>
|
||||
) : patternDataUrl ? (
|
||||
<img
|
||||
|
|
|
|||
137
ui/src/components/CompanySettingsSidebar.test.tsx
Normal file
137
ui/src/components/CompanySettingsSidebar.test.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
|
||||
const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" data-to={to} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarNavItem", () => ({
|
||||
SidebarNavItem: (props: {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
badge?: number;
|
||||
}) => {
|
||||
sidebarNavItemMock(props);
|
||||
return <div>{props.label}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CompanySettingsSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockSidebarBadgesApi.get.mockResolvedValue({
|
||||
inbox: 0,
|
||||
approvals: 0,
|
||||
failedRuns: 0,
|
||||
joinRequests: 2,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the company back link and the settings sections in the sidebar", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip");
|
||||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings",
|
||||
label: "General",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/access",
|
||||
label: "Access",
|
||||
badge: 2,
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/invites",
|
||||
label: "Invites",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
ui/src/components/CompanySettingsSidebar.tsx
Normal file
69
ui/src/components/CompanySettingsSidebar.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
export function CompanySettingsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { data: badges } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.sidebarBadges(selectedCompanyId)
|
||||
: ["sidebar-badges", "__disabled__"] as const,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await sidebarBadgesApi.get(selectedCompanyId!);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: !!selectedCompanyId,
|
||||
retry: false,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{selectedCompany?.name ?? "Company"}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">
|
||||
Company Settings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem
|
||||
to="/company/settings/access"
|
||||
label="Access"
|
||||
icon={Shield}
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees";
|
||||
import {
|
||||
buildExecutionPolicy,
|
||||
|
|
@ -34,14 +38,27 @@ export function ExecutionParticipantPicker({
|
|||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const values = stageType === "review" ? reviewerValues : approverValues;
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(issue.companyId),
|
||||
queryFn: () => accessApi.listUserDirectory(issue.companyId),
|
||||
enabled: !!issue.companyId,
|
||||
});
|
||||
|
||||
const sortedAgents = sortAgentsByRecency(
|
||||
agents.filter((a) => a.status !== "terminated"),
|
||||
getRecentAssigneeIds(),
|
||||
);
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const otherUserOptions = useMemo(
|
||||
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
|
||||
[companyMembers?.users, currentUserId, issue.createdByUserId],
|
||||
);
|
||||
|
||||
const userLabel = (userId: string | null | undefined) =>
|
||||
formatAssigneeUserLabel(userId, currentUserId);
|
||||
formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
|
||||
const agentName = (id: string) => {
|
||||
|
|
@ -138,6 +155,24 @@ export function ExecutionParticipantPicker({
|
|||
{creatorUserLabel ?? "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!search.trim()) return true;
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(option.id) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggle(option.id)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
|
|
|
|||
|
|
@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
|
||||
}}>
|
||||
<DialogContent className="max-h-[85vh] overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6 [&>*]:min-w-0">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionLabel}</DialogTitle>
|
||||
<DialogDescription className="break-words text-xs sm:text-sm">
|
||||
<DialogDescription className="break-words">
|
||||
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
|
||||
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{readinessQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
) : readinessQuery.error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-destructive">
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
||||
</div>
|
||||
) : readiness ? (
|
||||
<div className="min-w-0 space-y-3 sm:space-y-4">
|
||||
<div className={`rounded-xl border px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="font-medium">
|
||||
{readiness.state === "blocked"
|
||||
? "Close is blocked"
|
||||
|
|
@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{blockingIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking issues</h3>
|
||||
<div className="space-y-2">
|
||||
{blockingIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
|
|
@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.blockingReasons.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason, idx) => (
|
||||
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 sm:px-3 sm:py-2 text-destructive">
|
||||
<h3 className="text-sm font-medium">Blocking reasons</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.blockingReasons.map((reason) => (
|
||||
<li key={reason} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.warnings.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning, idx) => (
|
||||
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 sm:px-3 sm:py-2">
|
||||
<h3 className="text-sm font-medium">Warnings</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{readiness.warnings.map((warning) => (
|
||||
<li key={warning} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.git ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Git status</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">Git status</h3>
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||
<div className="truncate font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
|
||||
<div className="truncate font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
||||
|
|
@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{otherLinkedIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||
<div className="space-y-2">
|
||||
{otherLinkedIssues.map((issue) => (
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
|
|
@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.runtimeServices.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.runtimeServices.map((service) => (
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||
|
|
@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||
<div className="space-y-2">
|
||||
{readiness.plannedActions.map((action, index) => (
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||
{action.command ? (
|
||||
|
|
@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
</section>
|
||||
|
||||
{currentStatus === "cleanup_failed" ? (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
|
||||
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
|
||||
workspace status if it succeeds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentStatus === "archived" ? (
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
This workspace is already archived.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{readiness.git?.repoRoot ? (
|
||||
<div className="overflow-hidden break-words text-xs text-muted-foreground">
|
||||
<div className="break-words text-xs text-muted-foreground">
|
||||
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
|
||||
{readiness.git.workspacePath ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
|
@ -23,7 +23,9 @@ export function InstanceSidebar() {
|
|||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/instance/settings/profile" label="Profile" icon={UserRoundPen} end />
|
||||
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem to="/instance/settings/access" label="Access" icon={Shield} end />
|
||||
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
||||
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
|
||||
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import type { ReactNode } from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||
import {
|
||||
IssueChatThread,
|
||||
canStopIssueChatRun,
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
|
|
@ -661,4 +666,33 @@ describe("IssueChatThread", () => {
|
|||
activeRunIds: new Set<string>(),
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("uses company profile data to distinguish the current user from other humans", () => {
|
||||
const userProfileMap = new Map([
|
||||
["user-1", { label: "Dotta", image: "/avatars/dotta.png" }],
|
||||
["user-2", { label: "Alice", image: "/avatars/alice.png" }],
|
||||
]);
|
||||
|
||||
expect(resolveIssueChatHumanAuthor({
|
||||
authorName: "You",
|
||||
authorUserId: "user-1",
|
||||
currentUserId: "user-1",
|
||||
userProfileMap,
|
||||
})).toEqual({
|
||||
isCurrentUser: true,
|
||||
authorName: "Dotta",
|
||||
avatarUrl: "/avatars/dotta.png",
|
||||
});
|
||||
|
||||
expect(resolveIssueChatHumanAuthor({
|
||||
authorName: "Alice",
|
||||
authorUserId: "user-2",
|
||||
currentUserId: "user-1",
|
||||
userProfileMap,
|
||||
})).toEqual({
|
||||
isCurrentUser: false,
|
||||
authorName: "Alice",
|
||||
avatarUrl: "/avatars/alice.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import {
|
|||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -74,6 +74,7 @@ import {
|
|||
shouldPreserveComposerViewport,
|
||||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
describeToolInput,
|
||||
|
|
@ -96,6 +97,8 @@ interface IssueChatMessageContext {
|
|||
feedbackTermsUrl: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
|
|
@ -217,6 +220,8 @@ interface IssueChatThreadProps {
|
|||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
|
|
@ -477,12 +482,13 @@ function formatTimelineAssigneeLabel(
|
|||
assignee: IssueTimelineAssignee,
|
||||
agentMap?: Map<string, Agent>,
|
||||
currentUserId?: string | null,
|
||||
userLabelMap?: ReadonlyMap<string, string> | null,
|
||||
) {
|
||||
if (assignee.agentId) {
|
||||
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
|
||||
}
|
||||
if (assignee.userId) {
|
||||
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
|
||||
return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board";
|
||||
}
|
||||
return "Unassigned";
|
||||
}
|
||||
|
|
@ -495,6 +501,26 @@ function initialsForName(name: string) {
|
|||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function resolveIssueChatHumanAuthor(args: {
|
||||
authorName?: string | null;
|
||||
authorUserId?: string | null;
|
||||
currentUserId?: string | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
}) {
|
||||
const { authorName, authorUserId, currentUserId, userProfileMap } = args;
|
||||
const profile = authorUserId ? userProfileMap?.get(authorUserId) ?? null : null;
|
||||
const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId);
|
||||
const resolvedAuthorName = profile?.label?.trim()
|
||||
|| authorName?.trim()
|
||||
|| (authorUserId === "local-board" ? "Board" : (isCurrentUser ? "You" : "User"));
|
||||
|
||||
return {
|
||||
isCurrentUser,
|
||||
authorName: resolvedAuthorName,
|
||||
avatarUrl: profile?.image ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRunStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "timed_out":
|
||||
|
|
@ -906,108 +932,151 @@ function IssueChatToolPart({
|
|||
}
|
||||
|
||||
function IssueChatUserMessage() {
|
||||
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
|
||||
const {
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
} = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||||
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
|
||||
const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null;
|
||||
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
|
||||
const pending = custom.clientStatus === "pending";
|
||||
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const {
|
||||
isCurrentUser,
|
||||
authorName: resolvedAuthorName,
|
||||
avatarUrl,
|
||||
} = resolveIssueChatHumanAuthor({
|
||||
authorName,
|
||||
authorUserId,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
});
|
||||
const authorAvatar = (
|
||||
<Avatar size="sm" className="mt-1 shrink-0">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
|
||||
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
const messageBody = (
|
||||
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
|
||||
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||
queued
|
||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||
: "bg-muted",
|
||||
pending && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{queued ? (
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
{queueTargetRunId && onInterruptQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||||
>
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<div className={cn("mt-1 flex px-1 text-[11px] text-muted-foreground", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||
Sending...
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100",
|
||||
isCurrentUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div className="group flex items-start justify-end gap-2.5">
|
||||
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||||
queued
|
||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||
: "bg-muted",
|
||||
pending && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{queued ? (
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
{queueTargetRunId && onInterruptQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||||
>
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||||
onClick={() => onCancelQueued(commentId)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar size="sm" className="mt-1 shrink-0">
|
||||
<AvatarFallback>You</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
|
||||
{isCurrentUser ? (
|
||||
<>
|
||||
{messageBody}
|
||||
{authorAvatar}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{authorAvatar}
|
||||
{messageBody}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
|
@ -1463,7 +1532,7 @@ function IssueChatFeedbackButtons({
|
|||
}
|
||||
|
||||
function IssueChatSystemMessage() {
|
||||
const { agentMap, currentUserId } = useContext(IssueChatCtx);
|
||||
const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
|
|
@ -1519,11 +1588,11 @@ function IssueChatSystemMessage() {
|
|||
Assignee
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId)}
|
||||
{formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId)}
|
||||
{formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1855,6 +1924,8 @@ export function IssueChatThread({
|
|||
issueStatus,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
onVote,
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
|
|
@ -1947,6 +2018,7 @@ export function IssueChatThread({
|
|||
projectId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
}),
|
||||
[
|
||||
comments,
|
||||
|
|
@ -1961,6 +2033,7 @@ export function IssueChatThread({
|
|||
projectId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
],
|
||||
);
|
||||
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
|
||||
|
|
@ -2028,6 +2101,8 @@ export function IssueChatThread({
|
|||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
|
|
@ -2043,6 +2118,8 @@ export function IssueChatThread({
|
|||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ export function InboxIssueTrailingColumns({
|
|||
workspaceId,
|
||||
workspaceName,
|
||||
assigneeName,
|
||||
assigneeUserName,
|
||||
assigneeUserAvatarUrl,
|
||||
currentUserId,
|
||||
parentIdentifier,
|
||||
parentTitle,
|
||||
|
|
@ -209,6 +211,8 @@ export function InboxIssueTrailingColumns({
|
|||
workspaceId?: string | null;
|
||||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
assigneeUserName?: string | null;
|
||||
assigneeUserAvatarUrl?: string | null;
|
||||
currentUserId: string | null;
|
||||
parentIdentifier: string | null;
|
||||
parentTitle: string | null;
|
||||
|
|
@ -216,7 +220,7 @@ export function InboxIssueTrailingColumns({
|
|||
onFilterWorkspace?: (workspaceId: string) => void;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
||||
return (
|
||||
<span
|
||||
|
|
@ -243,8 +247,13 @@ export function InboxIssueTrailingColumns({
|
|||
|
||||
if (issue.assigneeUserId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||
{userLabel}
|
||||
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||
<Identity
|
||||
name={userLabel}
|
||||
avatarUrl={assigneeUserAvatarUrl}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|||
import { Link } from "@/lib/router";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
|
|
@ -187,6 +189,11 @@ export function IssueProperties({
|
|||
queryFn: () => agentsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(companyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId!),
|
||||
|
|
@ -257,13 +264,21 @@ export function IssueProperties({
|
|||
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
||||
[agents, recentAssigneeIds],
|
||||
);
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const otherUserOptions = useMemo(
|
||||
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
|
||||
[companyMembers?.users, currentUserId, issue.createdByUserId],
|
||||
);
|
||||
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const reviewerValues = stageParticipantValues(issue.executionPolicy, "review");
|
||||
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
|
||||
|
|
@ -499,6 +514,31 @@ export function IssueProperties({
|
|||
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
const q = assigneeSearch.toLowerCase();
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q);
|
||||
})
|
||||
.map((option) => {
|
||||
const userId = option.id.slice("user:".length);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
issue.assigneeUserId === userId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
|
||||
setAssigneeOpen(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sortedAgents
|
||||
.filter((a) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
|
|
@ -571,6 +611,24 @@ export function IssueProperties({
|
|||
{creatorUserLabel ? creatorUserLabel : "Requester"}
|
||||
</button>
|
||||
)}
|
||||
{otherUserOptions
|
||||
.filter((option) => {
|
||||
if (!search.trim()) return true;
|
||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
.map((option) => (
|
||||
<button
|
||||
key={`${stageType}:${option.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
values.includes(option.id) && "bg-accent",
|
||||
)}
|
||||
onClick={() => toggleExecutionParticipant(stageType, option.id)}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{sortedAgents
|
||||
.filter((agent) => {
|
||||
if (!search.trim()) return true;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ const mockAuthApi = vi.hoisted(() => ({
|
|||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessApi = vi.hoisted(() => ({
|
||||
listMembers: vi.fn(),
|
||||
listUserDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listSummaries: vi.fn(),
|
||||
|
|
@ -51,6 +56,10 @@ vi.mock("../api/auth", () => ({
|
|||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/access", () => ({
|
||||
accessApi: mockAccessApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/execution-workspaces", () => ({
|
||||
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||
}));
|
||||
|
|
@ -183,12 +192,16 @@ describe("IssuesList", () => {
|
|||
mockIssuesApi.list.mockReset();
|
||||
mockIssuesApi.listLabels.mockReset();
|
||||
mockAuthApi.getSession.mockReset();
|
||||
mockAccessApi.listMembers.mockReset();
|
||||
mockAccessApi.listUserDirectory.mockReset();
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||
mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} });
|
||||
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
|
|
@ -498,6 +511,50 @@ describe("IssuesList", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows human assignee names from company member profiles", async () => {
|
||||
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"]));
|
||||
mockAccessApi.listUserDirectory.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
principalId: "user-2",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-2",
|
||||
name: "Jordan Lee",
|
||||
email: "jordan@example.com",
|
||||
image: "https://example.com/jordan.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const assignedIssue = createIssue({
|
||||
id: "issue-human",
|
||||
identifier: "PAP-12",
|
||||
title: "Human assigned issue",
|
||||
assigneeUserId: "user-2",
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[assignedIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Jordan Lee");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stored grouping across refresh when initial assignees are applied", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
|
|
@ -282,6 +284,11 @@ export function IssuesList({
|
|||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
|
|
@ -376,6 +383,15 @@ export function IssuesList({
|
|||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
}, [agents]);
|
||||
|
||||
const companyUserLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const companyUserProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
|
||||
const projectById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; color: string | null }>();
|
||||
for (const project of projects ?? []) {
|
||||
|
|
@ -601,11 +617,11 @@ export function IssuesList({
|
|||
key === "__unassigned"
|
||||
? "Unassigned"
|
||||
: key.startsWith("__user:")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
|
||||
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId, companyUserLabelMap) ?? "User")
|
||||
: (agentName(key) ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewState.viewMode !== "list") return;
|
||||
|
|
@ -910,6 +926,14 @@ export function IssuesList({
|
|||
const useDeferredRowRendering = !(hasChildren && isExpanded);
|
||||
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
||||
const assigneeUserProfile = issue.assigneeUserId
|
||||
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
|
||||
: null;
|
||||
const assigneeUserLabel = formatAssigneeUserLabel(
|
||||
issue.assigneeUserId,
|
||||
currentUserId,
|
||||
companyUserLabelMap,
|
||||
) ?? assigneeUserProfile?.label ?? null;
|
||||
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -994,6 +1018,8 @@ export function IssuesList({
|
|||
})}
|
||||
onFilterWorkspace={filterToWorkspace}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
assigneeUserName={assigneeUserLabel}
|
||||
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={parentIssue?.identifier ?? null}
|
||||
parentTitle={parentIssue?.title ?? null}
|
||||
|
|
@ -1007,18 +1033,18 @@ export function IssuesList({
|
|||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
className="flex w-full shrink-0 items-center overflow-hidden rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" className="min-w-0" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||
</span>
|
||||
<Identity
|
||||
name={assigneeUserLabel ?? "User"}
|
||||
avatarUrl={assigneeUserProfile?.image ?? null}
|
||||
size="sm"
|
||||
className="min-w-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
|
|
|
|||
249
ui/src/components/Layout.test.tsx
Normal file
249
ui/src/components/Layout.test.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Layout } from "./Layout";
|
||||
|
||||
const mockHealthApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
let currentPathname = "/PAP/dashboard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Outlet: () => <div>Outlet content</div>,
|
||||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
|
||||
useNavigate: () => mockNavigate,
|
||||
useNavigationType: () => "PUSH",
|
||||
useParams: () => ({ companyPrefix: "PAP" }),
|
||||
}));
|
||||
|
||||
vi.mock("./CompanyRail", () => ({
|
||||
CompanyRail: () => <div>Company rail</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./Sidebar", () => ({
|
||||
Sidebar: () => <div>Main company nav</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./InstanceSidebar", () => ({
|
||||
InstanceSidebar: () => <div>Instance sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./CompanySettingsSidebar", () => ({
|
||||
CompanySettingsSidebar: () => <div>Company settings sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./BreadcrumbBar", () => ({
|
||||
BreadcrumbBar: () => <div>Breadcrumbs</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./PropertiesPanel", () => ({
|
||||
PropertiesPanel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./CommandPalette", () => ({
|
||||
CommandPalette: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewIssueDialog", () => ({
|
||||
NewIssueDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewProjectDialog", () => ({
|
||||
NewProjectDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewGoalDialog", () => ({
|
||||
NewGoalDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./NewAgentDialog", () => ({
|
||||
NewAgentDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./KeyboardShortcutsCheatsheet", () => ({
|
||||
KeyboardShortcutsCheatsheet: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./ToastViewport", () => ({
|
||||
ToastViewport: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./MobileBottomNav", () => ({
|
||||
MobileBottomNav: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./WorktreeBanner", () => ({
|
||||
WorktreeBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./DevRestartBanner", () => ({
|
||||
DevRestartBanner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAccountMenu", () => ({
|
||||
SidebarAccountMenu: () => <div>Account menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
openOnboarding: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
usePanel: () => ({
|
||||
togglePanelVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||
loading: false,
|
||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||
selectedCompanyId: "company-1",
|
||||
selectionSource: "manual",
|
||||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
sidebarOpen: true,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
toggleSidebar: vi.fn(),
|
||||
isMobile: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useKeyboardShortcuts", () => ({
|
||||
useKeyboardShortcuts: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useCompanyPageMemory", () => ({
|
||||
useCompanyPageMemory: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../api/health", () => ({
|
||||
healthApi: mockHealthApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/company-selection", () => ({
|
||||
shouldSyncCompanySelectionFromRoute: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/instance-settings", () => ({
|
||||
DEFAULT_INSTANCE_SETTINGS_PATH: "/instance/settings/general",
|
||||
normalizeRememberedInstanceSettingsPath: (value: string | null | undefined) =>
|
||||
value ?? "/instance/settings/general",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/main-content-focus", () => ({
|
||||
scheduleMainContentFocus: () => () => undefined,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Layout", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPathname = "/PAP/dashboard";
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
version: "1.2.3",
|
||||
});
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not render the deployment explainer in the shared layout", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockHealthApi.get).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Breadcrumbs");
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("Authenticated private");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Sign-in is required and this instance is intended for private-network access.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the company settings sidebar on company settings routes", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Layout />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company settings sidebar");
|
||||
expect(container.textContent).not.toContain("Instance sidebar");
|
||||
expect(container.textContent).not.toContain("Main company nav");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
|
|
@ -17,12 +17,12 @@ import { ToastViewport } from "./ToastViewport";
|
|||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
|
|
@ -34,15 +34,12 @@ import {
|
|||
} from "../lib/instance-settings";
|
||||
import {
|
||||
resetNavigationScroll,
|
||||
SIDEBAR_SCROLL_RESET_STATE,
|
||||
shouldResetScrollOnNavigation,
|
||||
} from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||
import { cn } from "../lib/utils";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
|
||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||
|
||||
|
|
@ -67,12 +64,12 @@ export function Layout() {
|
|||
selectionSource,
|
||||
setSelectedCompanyId,
|
||||
} = useCompany();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
|
||||
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const previousPathname = useRef<string | null>(null);
|
||||
|
|
@ -80,7 +77,6 @@ export function Layout() {
|
|||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
|
|
@ -341,53 +337,19 @@ export function Layout() {
|
|||
>
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<CompanyRail />
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
instanceSettingsTarget={instanceSettingsTarget}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
|
|
@ -399,54 +361,20 @@ export function Layout() {
|
|||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Documentation</span>
|
||||
</a>
|
||||
{health?.version && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>v{health.version}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isInstanceSettingsRoute ? (
|
||||
<InstanceSidebar />
|
||||
) : isCompanySettingsRoute ? (
|
||||
<CompanySettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode={health?.deploymentMode}
|
||||
instanceSettingsTarget={instanceSettingsTarget}
|
||||
version={health?.version}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, buildUserMentionHref } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
|
@ -75,17 +75,19 @@ describe("MarkdownBody", () => {
|
|||
expect(html).toContain('alt="Org chart"');
|
||||
});
|
||||
|
||||
it("renders agent, project, and skill mentions as chips", () => {
|
||||
it("renders user, agent, project, and skill mentions as chips", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/company/settings/access"');
|
||||
expect(html).toContain('data-mention-kind="user"');
|
||||
expect(html).toContain('href="/agents/agent-123"');
|
||||
expect(html).toContain('data-mention-kind="agent"');
|
||||
expect(html).toContain("--paperclip-mention-icon-mask");
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ export function MarkdownBody({
|
|||
? `/projects/${parsed.projectId}`
|
||||
: parsed.kind === "skill"
|
||||
? `/skills/${parsed.skillId}`
|
||||
: parsed.kind === "user"
|
||||
? "/company/settings/access"
|
||||
: `/agents/${parsed.agentId}`;
|
||||
return (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ import {
|
|||
thematicBreakPlugin,
|
||||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildUserMentionHref } from "@paperclipai/shared";
|
||||
import { Boxes, User } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
||||
|
|
@ -48,11 +48,12 @@ import { useEditorAutocomplete, type SkillCommandOption } from "../context/Edito
|
|||
export interface MentionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
kind?: "agent" | "project";
|
||||
kind?: "agent" | "project" | "user";
|
||||
agentId?: string;
|
||||
agentIcon?: string | null;
|
||||
projectId?: string;
|
||||
projectColor?: string | null;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/* ---- Editor props ---- */
|
||||
|
|
@ -354,6 +355,9 @@ function mentionMarkdown(option: MentionOption): string {
|
|||
if (option.kind === "project" && option.projectId) {
|
||||
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
|
||||
}
|
||||
if (option.kind === "user" && option.userId) {
|
||||
return `[@${option.name}](${buildUserMentionHref(option.userId)}) `;
|
||||
}
|
||||
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
||||
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
|
||||
}
|
||||
|
|
@ -400,6 +404,9 @@ function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string)
|
|||
if (option.kind === "project" && option.projectId) {
|
||||
return parsed.kind === "project" && parsed.projectId === option.projectId;
|
||||
}
|
||||
if (option.kind === "user" && option.userId) {
|
||||
return parsed.kind === "user" && parsed.userId === option.userId;
|
||||
}
|
||||
|
||||
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
||||
return parsed.kind === "agent" && parsed.agentId === agentId;
|
||||
|
|
@ -527,6 +534,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const agentId = mention.agentId ?? mention.id.replace(/^agent:/, "");
|
||||
map.set(`agent:${agentId}`, mention);
|
||||
}
|
||||
if (mention.kind === "user" && mention.userId) {
|
||||
map.set(`user:${mention.userId}`, mention);
|
||||
}
|
||||
if (mention.kind === "project" && mention.projectId) {
|
||||
map.set(`project:${mention.projectId}`, mention);
|
||||
}
|
||||
|
|
@ -717,6 +727,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
continue;
|
||||
}
|
||||
|
||||
if (parsed.kind === "user") {
|
||||
applyMentionChipDecoration(link, parsed);
|
||||
continue;
|
||||
}
|
||||
|
||||
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
|
||||
applyMentionChipDecoration(link, {
|
||||
...parsed,
|
||||
|
|
@ -1098,7 +1113,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={mentionMenuPosition ?? undefined}
|
||||
style={{
|
||||
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
|
||||
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
|
||||
}}
|
||||
>
|
||||
{filteredMentions.map((option, i) => (
|
||||
<button
|
||||
|
|
@ -1125,6 +1143,8 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
||||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||
/>
|
||||
) : option.kind === "user" ? (
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<AgentIcon
|
||||
icon={option.agentIcon}
|
||||
|
|
@ -1137,6 +1157,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
Project
|
||||
</span>
|
||||
)}
|
||||
{option.kind === "user" && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
User
|
||||
</span>
|
||||
)}
|
||||
{option.kind === "skill" && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Skill
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import { issuesApi } from "../api/issues";
|
|||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
|
|
@ -353,6 +355,11 @@ export function NewIssueDialog() {
|
|||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(effectiveCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(effectiveCompanyId!),
|
||||
enabled: Boolean(effectiveCompanyId) && newIssueOpen,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
|
|
@ -379,30 +386,12 @@ export function NewIssueDialog() {
|
|||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||
);
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
agentIcon: agent.icon,
|
||||
});
|
||||
}
|
||||
for (const project of orderedProjects) {
|
||||
options.push({
|
||||
id: `project:${project.id}`,
|
||||
name: project.name,
|
||||
kind: "project",
|
||||
projectId: project.id,
|
||||
projectColor: project.color,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents, orderedProjects]);
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
projects: orderedProjects,
|
||||
members: companyMembers?.users,
|
||||
});
|
||||
}, [agents, companyMembers?.users, orderedProjects]);
|
||||
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey:
|
||||
|
|
@ -868,6 +857,7 @@ export function NewIssueDialog() {
|
|||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||
() => [
|
||||
...currentUserAssigneeOption(currentUserId),
|
||||
...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }),
|
||||
...sortAgentsByRecency(
|
||||
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||
recentAssigneeIds,
|
||||
|
|
@ -877,7 +867,7 @@ export function NewIssueDialog() {
|
|||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||
})),
|
||||
],
|
||||
[agents, currentUserId, recentAssigneeIds],
|
||||
[agents, companyMembers?.users, currentUserId, recentAssigneeIds],
|
||||
);
|
||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import { useMemo, useRef, useState } from "react";
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { accessApi } from "../api/access";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -75,22 +77,18 @@ export function NewProjectDialog() {
|
|||
enabled: !!selectedCompanyId && newProjectOpen,
|
||||
});
|
||||
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newProjectOpen,
|
||||
});
|
||||
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
agentIcon: agent.icon,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents]);
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
members: companyMembers?.users,
|
||||
});
|
||||
}, [agents, companyMembers?.users]);
|
||||
|
||||
const createProject = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
|
|
@ -50,15 +51,7 @@ export function Sidebar() {
|
|||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
{selectedCompany?.brandColor && (
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm shrink-0 ml-1"
|
||||
style={{ backgroundColor: selectedCompany.brandColor }}
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
</span>
|
||||
<SidebarCompanyMenu />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
|
|
|||
117
ui/src/components/SidebarAccountMenu.test.tsx
Normal file
117
ui/src/components/SidebarAccountMenu.test.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
signInEmail: vi.fn(),
|
||||
signUpEmail: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
const mockToggleTheme = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/ThemeContext", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "dark",
|
||||
toggleTheme: mockToggleTheme,
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("SidebarAccountMenu", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: "https://example.com/jane.png",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the signed-in user and opens the account card menu", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
instanceSettingsTarget="/instance/settings/general"
|
||||
version="1.2.3"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Jane Example");
|
||||
expect(container.textContent).not.toContain("jane@example.com");
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open account menu"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Edit profile");
|
||||
expect(document.body.textContent).toContain("Documentation");
|
||||
expect(document.body.textContent).toContain("Paperclip v1.2.3");
|
||||
expect(document.body.textContent).toContain("jane@example.com");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
227
ui/src/components/SidebarAccountMenu.tsx
Normal file
227
ui/src/components/SidebarAccountMenu.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
LogOut,
|
||||
type LucideIcon,
|
||||
Moon,
|
||||
Settings,
|
||||
Sun,
|
||||
UserRoundPen,
|
||||
} from "lucide-react";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const PROFILE_SETTINGS_PATH = "/instance/settings/profile";
|
||||
const DOCS_URL = "https://docs.paperclip.ing/";
|
||||
|
||||
interface SidebarAccountMenuProps {
|
||||
deploymentMode?: DeploymentMode;
|
||||
instanceSettingsTarget: string;
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
interface MenuActionProps {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
function deriveInitials(name: string) {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
|
||||
const className =
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} className={className} onClick={onClick}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarAccountMenu({
|
||||
deploymentMode,
|
||||
instanceSettingsTarget,
|
||||
version,
|
||||
}: SidebarAccountMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = session?.user.name?.trim() || "Board";
|
||||
const secondaryLabel =
|
||||
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
||||
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
|
||||
const initials = deriveInitials(displayName);
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-r border-border bg-background px-3 py-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-[13px] font-medium text-foreground/80 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
aria-label="Open account menu"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
|
||||
>
|
||||
<div className="h-24 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]" />
|
||||
<div className="-mt-8 px-4 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-2xl border-4 border-popover bg-popover p-0.5 shadow-sm">
|
||||
<Avatar size="lg">
|
||||
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{accountBadge}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
|
||||
{version ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
description="Update your display name and avatar."
|
||||
icon={UserRoundPen}
|
||||
href={PROFILE_SETTINGS_PATH}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Instance settings"
|
||||
description="Jump back to the last settings page you opened."
|
||||
icon={Settings}
|
||||
href={instanceSettingsTarget}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Documentation"
|
||||
description="Open Paperclip docs in a new tab."
|
||||
icon={BookOpen}
|
||||
href={DOCS_URL}
|
||||
external
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<MenuAction
|
||||
label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
description="Toggle the app appearance."
|
||||
icon={theme === "dark" ? Sun : Moon}
|
||||
onClick={() => {
|
||||
toggleTheme();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
{deploymentMode === "authenticated" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-destructive/10",
|
||||
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">
|
||||
<LogOut className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
End this browser session.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
ui/src/components/SidebarCompanyMenu.test.tsx
Normal file
125
ui/src/components/SidebarCompanyMenu.test.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
signInEmail: vi.fn(),
|
||||
signUpEmail: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: {
|
||||
id: "company-1",
|
||||
name: "Acme Labs",
|
||||
brandColor: "#3366ff",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("SidebarCompanyMenu", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
},
|
||||
});
|
||||
mockAuthApi.signOut.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the requested company actions and signs out through the dropdown", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenu />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Acme Labs");
|
||||
|
||||
const trigger = container.querySelector('button[aria-label="Open Acme Labs menu"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||
expect(document.body.textContent).toContain("Company settings");
|
||||
expect(document.body.textContent).toContain("Sign out");
|
||||
|
||||
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Sign out"));
|
||||
expect(signOutButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
102
ui/src/components/SidebarCompanyMenu.tsx
Normal file
102
ui/src/components/SidebarCompanyMenu.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, LogOut, Settings, UserPlus } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
|
||||
export function SidebarCompanyMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompany } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
},
|
||||
});
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto flex-1 justify-start gap-1 px-2 py-1.5 text-left"
|
||||
aria-label={selectedCompany ? `Open ${selectedCompany.name} menu` : "Open company menu"}
|
||||
disabled={!selectedCompany}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{selectedCompany?.brandColor ? (
|
||||
<span
|
||||
className="size-4 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: selectedCompany.brandColor }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-sm font-bold text-foreground">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="truncate">
|
||||
{selectedCompany?.name ?? "Company"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings/invites" onClick={closeNavigationChrome}>
|
||||
<UserPlus className="size-4" />
|
||||
<span className="truncate">
|
||||
{selectedCompany ? `Invite people to ${selectedCompany.name}` : "Invite people"}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings" onClick={closeNavigationChrome}>
|
||||
<Settings className="size-4" />
|
||||
<span>Company settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{session?.session ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
<span>{signOutMutation.isPending ? "Signing out..." : "Sign out"}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
99
ui/src/components/access/CompanySettingsNav.test.tsx
Normal file
99
ui/src/components/access/CompanySettingsNav.test.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanySettingsNav, getCompanySettingsTab } from "./CompanySettingsNav";
|
||||
|
||||
let currentPathname = "/company/settings";
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const pageTabBarMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "" }),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
Tabs: ({ children }: { children: React.ReactNode }) => <div data-testid="tabs-root">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/PageTabBar", () => ({
|
||||
PageTabBar: (props: {
|
||||
items: Array<{ value: string; label: string }>;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}) => {
|
||||
pageTabBarMock(props);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="active-tab">{props.value}</div>
|
||||
<button type="button" onClick={() => props.onValueChange?.("invites")}>
|
||||
switch-tab
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CompanySettingsNav", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentPathname = "/company/settings";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps company settings routes to the expected shared tab value", () => {
|
||||
expect(getCompanySettingsTab("/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
|
||||
});
|
||||
|
||||
it("renders the active tab and navigates when a different tab is selected", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CompanySettingsNav />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("access");
|
||||
expect(pageTabBarMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: "access",
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "access", label: "Access" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/company/settings/invites");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
46
ui/src/components/access/CompanySettingsNav.tsx
Normal file
46
ui/src/components/access/CompanySettingsNav.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
|
||||
const items = [
|
||||
{ value: "general", label: "General", href: "/company/settings" },
|
||||
{ value: "access", label: "Access", href: "/company/settings/access" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
] as const;
|
||||
|
||||
type CompanySettingsTab = (typeof items)[number]["value"];
|
||||
|
||||
export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
if (pathname.includes("/company/settings/access")) {
|
||||
return "access";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/invites")) {
|
||||
return "invites";
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
export function CompanySettingsNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeTab = getCompanySettingsTab(location.pathname);
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
const nextTab = items.find((item) => item.value === value);
|
||||
if (!nextTab || nextTab.value === activeTab) return;
|
||||
navigate(nextTab.href);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<PageTabBar
|
||||
items={items.map(({ value, label }) => ({ value, label }))}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
align="start"
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
19
ui/src/components/access/ModeBadge.tsx
Normal file
19
ui/src/components/access/ModeBadge.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function ModeBadge({
|
||||
deploymentMode,
|
||||
deploymentExposure,
|
||||
}: {
|
||||
deploymentMode?: DeploymentMode;
|
||||
deploymentExposure?: DeploymentExposure;
|
||||
}) {
|
||||
if (!deploymentMode) return null;
|
||||
|
||||
const label =
|
||||
deploymentMode === "local_trusted"
|
||||
? "Local trusted"
|
||||
: `Authenticated ${deploymentExposure ?? "private"}`;
|
||||
|
||||
return <Badge variant="outline">{label}</Badge>;
|
||||
}
|
||||
|
|
@ -113,11 +113,23 @@ export function ToggleField({
|
|||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
{hint && <HintIcon text={hint} />}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
<button
|
||||
data-slot="toggle"
|
||||
data-testid={toggleTestId}
|
||||
/>
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
checked ? "bg-green-600" : "bg-muted"
|
||||
)}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
checked ? "translate-x-4.5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue