Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions client/dashboard/src/hooks/useDismissedCtaStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useSyncExternalStore } from "react";

type ScopedStorageStore<T> = {
useValue: (slug: string | undefined) => T;
/** Current value outside React, e.g. to patch state from an async callback. */
read: (slug: string | undefined) => T;
write: (slug: string, value: T) => void;
};

Expand All @@ -14,12 +16,21 @@ export function createScopedStorageStore<T>(
const listeners = new Set<() => void>();
const storageKey = (slug: string) => `${prefix}:${slug}`;
const memory = new Map<string, T>();
// Decoded snapshots keyed by the raw stored string. `useSyncExternalStore`
// compares snapshots by identity, so an object-valued store must hand back
// the same decoded value until the underlying string actually changes.
const decoded = new Map<string, { raw: string | null; value: T }>();

function read(slug: string | undefined): T {
if (!slug) return defaultValue;
if (memory.has(slug)) return memory.get(slug)!;
try {
return decode(localStorage.getItem(storageKey(slug)));
const raw = localStorage.getItem(storageKey(slug));
const cached = decoded.get(slug);
if (cached && cached.raw === raw) return cached.value;
const value = decode(raw);
decoded.set(slug, { raw, value });
return value;
} catch {
return defaultValue;
}
Expand Down Expand Up @@ -70,7 +81,7 @@ export function createScopedStorageStore<T>(
);
}

return { useValue, write };
return { useValue, read, write };
}

/**
Expand Down
2 changes: 1 addition & 1 deletion client/dashboard/src/pages/security/PolicyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ import {
type ShadowMCPDisposition,
} from "./policy-shadow-mcp-setup";
import { SupersedeDecisionsDialog } from "./SupersedeDecisionsDialog";
import { type Step } from "@/pages/setup/components/onboarding-stepper";
import type { Step } from "@/pages/setup/types";
import {
DETECTION_RULES,
RULE_CATEGORY_META,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/Avatar";
import { getIdentityTint } from "@/components/gradient-colors";
import { getInitials } from "@/lib/initials";
import { cn } from "@/lib/utils";
import { type Assignee, assigneeIdentity } from "./board-store";

/**
* Two letters for the fallback face. Names go through the shared helper; an
* email uses its local part so "ops-lead@example.com" reads "OL", not "O".
*/
function assigneeInitials(assignee: Assignee): string {
if (assignee.kind === "user") return getInitials(assignee.name) || "?";
const localPart = assignee.email.split("@")[0] ?? "";
const words = localPart.split(/[^a-z0-9]+/i).filter(Boolean);
const initials =
words.length > 1
? words
.slice(0, 2)
.map((word) => word[0])
.join("")
: localPart.slice(0, 2);
return initials.toUpperCase() || "?";
}

export function AssigneeAvatar({
assignee,
className,
}: {
assignee: Assignee;
className?: string;
}): JSX.Element {
return (
<Avatar className={cn("size-6", className)}>
{assignee.kind === "user" && assignee.photoUrl && (
<AvatarImage src={assignee.photoUrl} alt={assignee.name} />
)}
<AvatarFallback
className="text-[10px] font-semibold"
style={getIdentityTint(assigneeIdentity(assignee))}
>
{assigneeInitials(assignee)}
</AvatarFallback>
</Avatar>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { useMemo, useState } from "react";
import { Check, Mail, UserRoundPlus, X } from "lucide-react";
import { useListOrganizationUsers } from "@gram/client/react-query/listOrganizationUsers.js";
import type { OrganizationUser } from "@gram/client/models/components/organizationuser.js";
import { Button } from "@/components/ui/Button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/Command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/Popover";
import { AssigneeAvatar } from "./assignee-avatar";
import { type Assignee, assigneeLabel } from "./board-store";

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function looksLikeEmail(value: string): boolean {
return EMAIL_PATTERN.test(value.trim());
}

function toAssignee(user: OrganizationUser): Assignee {
return {
kind: "user",
userId: user.userId,
name: user.name,
email: user.email,
photoUrl: user.photoUrl,
};
}

function matchesQuery(user: OrganizationUser, query: string): boolean {
return (
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}

interface AssigneePickerProps {
assignee: Assignee | undefined;
onChange: (assignee: Assignee | undefined) => void;
/** Trigger label while nobody is assigned. */
placeholder?: string;
size?: "xs" | "sm";
}

/**
* Hands a task to a member of the organization, or to an email address for
* someone who has not joined yet. Team members come from the organization's
* user list; typing a full address that matches nobody offers it as an
* outside assignee.
*/
export function AssigneePicker({
assignee,
onChange,
placeholder = "Assign",
size = "xs",
}: AssigneePickerProps): JSX.Element {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const { data, isLoading } = useListOrganizationUsers(undefined, undefined, {
enabled: open,
});

const users = useMemo(
() => [...(data?.users ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
[data],
);
const normalizedQuery = query.trim().toLowerCase();
const matches = useMemo(
() =>
normalizedQuery
? users.filter((user) => matchesQuery(user, normalizedQuery))
: users,
[users, normalizedQuery],
);
const outsideEmail =
looksLikeEmail(query) &&
!users.some((user) => user.email.toLowerCase() === normalizedQuery)
? query.trim()
: null;

const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setQuery("");
};

const select = (next: Assignee | undefined) => {
onChange(next);
handleOpenChange(false);
};

const currentIdentity =
assignee?.kind === "user" ? assignee.userId : undefined;

return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="tertiary"
size={size}
className="min-w-0 max-w-full gap-1.5 px-1.5 font-normal"
aria-label={
assignee ? `Assigned to ${assigneeLabel(assignee)}` : placeholder
}
>
<Button.LeftIcon>
{assignee ? (
<AssigneeAvatar assignee={assignee} className="size-4" />
) : (
<UserRoundPlus />
)}
</Button.LeftIcon>
{/* Button.Text trims its box to cap height and baseline, and that
trim reaches into nested blocks, so the overflow clip `truncate`
needs would cut off ascenders and descenders. Padding the
clipping span and pulling it back with negative margins keeps
the layout height while giving the glyphs room. */}
<Button.Text className="min-w-0">
<span className="-my-1 block truncate py-1">
{assignee ? assigneeLabel(assignee) : placeholder}
</span>
</Button.Text>
</Button>
</PopoverTrigger>
{/* The popover is portaled, so React still bubbles its events up to the
card that owns this picker; stop them here so choosing a person never
doubles as clicking the card. */}
<PopoverContent
align="start"
className="w-72 p-0"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Command shouldFilter={false} label="Assign task">
<CommandInput
placeholder="Search team or enter an email"
value={query}
onValueChange={setQuery}
className="h-9"
/>
<CommandList>
{assignee && (
<CommandGroup>
<CommandItem
value="__unassign"
onSelect={() => select(undefined)}
className="cursor-pointer"
>
<X />
Unassign
</CommandItem>
</CommandGroup>
)}
<CommandGroup heading="Team">
{isLoading && (
<CommandItem value="__loading" disabled>
Loading team…
</CommandItem>
)}
{matches.map((user) => (
<CommandItem
key={user.userId}
value={user.userId}
onSelect={() => select(toAssignee(user))}
className="cursor-pointer"
>
<AssigneeAvatar assignee={toAssignee(user)} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{user.name}</div>
<div className="text-muted-foreground truncate text-xs">
{user.email}
</div>
</div>
{currentIdentity === user.userId && (
<Check className="size-4 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
{outsideEmail && (
<CommandGroup heading="Outside the team">
<CommandItem
value={outsideEmail}
onSelect={() =>
select({ kind: "email", email: outsideEmail })
}
className="cursor-pointer"
>
<Mail />
<span className="truncate">Assign {outsideEmail}</span>
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>
No team member matches. Enter a full email address to assign
someone who has not joined yet.
</CommandEmpty>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
Loading
Loading