Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ChipTag } from '@sim/emcn'
import { IdentityTile } from '@/components/identity-tile/identity-tile'
import type { InvitationDetails } from '@/lib/api/contracts/invitations'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'

interface InvitationWorkspaceAccessProps {
Expand All @@ -15,12 +14,7 @@ export function InvitationWorkspaceAccess({ grants }: InvitationWorkspaceAccessP
<li key={grant.workspaceId}>
<SettingsResourceRow
flush
icon={
<IdentityTile
initial={getWorkspaceInitial(grant.workspaceName ?? undefined)}
logoUrl={grant.workspaceLogoUrl}
/>
}
icon={<IdentityTile glyphSeed={grant.workspaceId} logoUrl={grant.workspaceLogoUrl} />}
iconVariant='custom'
title={grant.workspaceName || 'Unnamed workspace'}
badge={<ChipTag variant='gray'>{grant.permission} access</ChipTag>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { getErrorMessage } from '@sim/utils/errors'
import { IdentityTile } from '@/components/identity-tile/identity-tile'
import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-menu'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces'
import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions'
Expand Down Expand Up @@ -84,10 +83,7 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis
const isPinned = pinnedWorkspaceIds.has(workspace.id)
const label = (
<>
<IdentityTile
initial={getWorkspaceInitial(workspace.name)}
logoUrl={workspace.logoUrl}
/>
<IdentityTile glyphSeed={workspace.id} logoUrl={workspace.logoUrl} />
<OverflowText
label={workspace.name}
className='flex-1 text-[var(--text-body)]'
Expand All @@ -114,10 +110,7 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis
key={workspace.id}
ref={rename.inputRef}
leadingAdornment={
<IdentityTile
initial={getWorkspaceInitial(workspace.name)}
logoUrl={workspace.logoUrl}
/>
<IdentityTile glyphSeed={workspace.id} logoUrl={workspace.logoUrl} />
}
aria-label={`Rename workspace ${workspace.name}`}
value={rename.value}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { Folder, Plus } from '@sim/emcn/icons'
import { IdentityTile } from '@/components/identity-tile/identity-tile'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { isTerminalAvailable } from '@/lib/terminal/transport'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import {
type AvailableItemsByType,
type AvailableResources,
Expand Down Expand Up @@ -463,9 +462,7 @@ export function WorkspaceResourceSubmenu({
onSelectWorkspace,
}: WorkspaceResourceSubmenuProps) {
const [open, setOpen] = useState(false)
const icon = (
<IdentityTile initial={getWorkspaceInitial(workspace.name)} logoUrl={workspace.logoUrl} />
)
const icon = <IdentityTile glyphSeed={workspace.id} logoUrl={workspace.logoUrl} />
return (
<DropdownMenuSub open={open} onOpenChange={setOpen}>
<DropdownMenuSubTrigger>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { OverflowText } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { Command } from 'cmdk'
import { IdentityTile } from '@/components/identity-tile/identity-tile'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { BlockTile } from '@/blocks/block-tile'
Expand Down Expand Up @@ -245,20 +244,22 @@ export const MemoizedWorkspaceItem = memo(
function WorkspaceItem({
value,
onSelect,
workspaceId,
name,
isCurrent,
logoUrl,
meta,
}: {
value: string
onSelect: () => void
workspaceId: string
name: string
isCurrent?: boolean
logoUrl?: string | null
} & ResultMetaProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<IdentityTile initial={getWorkspaceInitial(name)} logoUrl={logoUrl} slot='workspace-icon' />
<IdentityTile glyphSeed={workspaceId} logoUrl={logoUrl} slot='workspace-icon' />
<span className='flex min-w-0 text-[var(--text-body)]'>
<OverflowText label={name} />
{isCurrent && <span className='shrink-0 whitespace-pre'> (current)</span>}
Expand All @@ -269,6 +270,7 @@ export const MemoizedWorkspaceItem = memo(
},
(prev, next) =>
prev.value === next.value &&
prev.workspaceId === next.workspaceId &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.logoUrl === next.logoUrl &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ function renderSearchEntry(
key={key}
value={`${entry.item.name} ${key}`}
onSelect={() => handlers.onSelectWorkspace(entry.item)}
workspaceId={entry.item.id}
name={entry.item.name}
isCurrent={entry.item.isCurrent}
logoUrl={entry.item.logoUrl}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { useRouter } from 'next/navigation'
import { IdentityTile } from '@/components/identity-tile/identity-tile'
import type { MyInvitation } from '@/lib/api/contracts/invitations'
import { getInvitationErrorMessage } from '@/lib/invitations/error-messages'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { InvitationDisclosure } from '@/app/invite/components/invitation-disclosure'
import { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access'
import {
Expand Down Expand Up @@ -160,9 +159,7 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa
icon={
singleWorkspaceGrant && (
<IdentityTile
initial={getWorkspaceInitial(
singleWorkspaceGrant.workspaceName ?? undefined
)}
glyphSeed={singleWorkspaceGrant.workspaceId}
logoUrl={singleWorkspaceGrant.workspaceLogoUrl}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link
import { WorkspaceContextMenu } from '@/components/workspaces/workspace-context-menu'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { WORKSPACE_SEARCH_THRESHOLD } from '@/lib/workspaces/constants'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SidebarRenameRow } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-rename-row'
Expand Down Expand Up @@ -308,8 +307,6 @@ function WorkspaceHeaderImpl({
}
}, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace])

const workspaceInitial = getWorkspaceInitial(activeWorkspace?.name)

/**
* Opens the context menu for a workspace at the specified position
*/
Expand Down Expand Up @@ -457,7 +454,7 @@ function WorkspaceHeaderImpl({
{activeWorkspace ? (
<>
<IdentityTile
initial={workspaceInitial}
glyphSeed={workspaceId}
logoUrl={activeWorkspaceFull?.logoUrl}
alt={activeWorkspaceFull?.name || 'Workspace logo'}
className='group-hover:invisible'
Expand Down Expand Up @@ -508,7 +505,7 @@ function WorkspaceHeaderImpl({
}}
leftAdornment={
<IdentityTile
initial={workspaceInitial}
glyphSeed={activeWorkspaceFull.id}
logoUrl={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
/>
Expand Down Expand Up @@ -603,7 +600,6 @@ function WorkspaceHeaderImpl({
</div>
)}
{filteredWorkspaces.map((workspace, idx) => {
const initial = getWorkspaceInitial(workspace.name)
const isActive = workspace.id === workspaceId
const isMenuOpen = menuOpenWorkspaceId === workspace.id
const isKeyboardHighlighted = showSearch && isKeyboardNav && idx === activeIndex
Expand Down Expand Up @@ -633,7 +629,7 @@ function WorkspaceHeaderImpl({
aria-label={`Rename workspace ${workspace.name}`}
leadingAdornment={
<IdentityTile
initial={initial}
glyphSeed={workspace.id}
logoUrl={workspace.logoUrl}
alt={workspace.name || 'Workspace logo'}
/>
Expand Down Expand Up @@ -726,7 +722,7 @@ function WorkspaceHeaderImpl({
onContextMenu={(e) => handleContextMenu(e, workspace)}
>
<IdentityTile
initial={initial}
glyphSeed={workspace.id}
logoUrl={workspace.logoUrl}
alt={workspace.name || 'Workspace logo'}
/>
Expand Down Expand Up @@ -799,7 +795,7 @@ function WorkspaceHeaderImpl({
>
{activeWorkspace ? (
<IdentityTile
initial={workspaceInitial}
glyphSeed={workspaceId}
logoUrl={activeWorkspaceFull?.logoUrl}
alt={activeWorkspaceFull?.name || 'Workspace logo'}
/>
Expand Down
132 changes: 119 additions & 13 deletions apps/sim/components/identity-tile/identity-tile.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useId } from 'react'
import { cn } from '@sim/emcn'
import { type GlyphShape, generateGlyph } from '@/lib/workspaces/glyph'

interface IdentityTileProps {
/** Letter shown when there is no uploaded mark. */
initial: string
interface IdentityTileBaseProps {
logoUrl?: string | null
/** Accessible name for an uploaded mark; empty when the name is already beside it. */
alt?: string
Expand All @@ -14,30 +14,133 @@ interface IdentityTileProps {
size?: 'sm' | 'lg'
}

type IdentityTileProps = IdentityTileBaseProps &
(
| {
/** Letter shown when there is no uploaded mark. */
initial: string
glyphSeed?: never
}
| {
/** Seed (a workspace id) for the generated glyph shown when there is no uploaded mark. */
glyphSeed: string
initial?: never
}
)

const SIZE_CLASS = {
sm: 'size-[16px] rounded-sm text-micro',
lg: 'size-9 rounded-lg text-base',
} as const

const GLYPH_SIZE_CLASS = {
sm: 'size-[16px]',
lg: 'size-9',
} as const

/** The loader's goo: blur, then crush alpha so parts within the blur radius melt together. */
const GOO_ALPHA_MATRIX = '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9'

/**
* Crops inside the 76-unit span `generateGlyph` fits into so the glyph slightly
* overfills the tile; the svg overflows so the outermost tips bleed under half a
* pixel instead of clipping. Zooming via the viewBox, not the geometry, keeps the
* gaps the goo blur is tuned to.
*/
const GLYPH_VIEW_BOX = '14 14 72 72'

function glyphShape(shape: GlyphShape, key: number) {
switch (shape.kind) {
case 'circle':
return <circle key={key} cx={shape.cx} cy={shape.cy} r={shape.r} />
case 'ring':
return (
<circle
key={key}
cx={shape.cx}
cy={shape.cy}
r={shape.r}
fill='none'
strokeWidth={shape.w}
/>
)
case 'capsule': {
const length = Math.hypot(shape.x2 - shape.x1, shape.y2 - shape.y1)
const deg = (Math.atan2(shape.y2 - shape.y1, shape.x2 - shape.x1) * 180) / Math.PI
return (
<rect
key={key}
x={shape.x1 - shape.w / 2}
y={shape.y1 - shape.w / 2}
width={length + shape.w}
height={shape.w}
rx={shape.w / 2}
transform={`rotate(${deg} ${shape.x1} ${shape.y1})`}
/>
)
}
case 'poly':
return <polygon key={key} points={shape.points.map((p) => p.join(',')).join(' ')} />
}
}

interface GlyphProps {
seed: string
className?: string
slot?: string
size: 'sm' | 'lg'
}

function Glyph({ seed, className, slot, size }: GlyphProps) {
const filterId = `identity-glyph-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`
return (
<svg
data-slot={slot}
aria-hidden='true'
viewBox={GLYPH_VIEW_BOX}
className={cn(
'shrink-0 overflow-visible text-[var(--text-icon)] dark:text-[var(--text-secondary)]',
GLYPH_SIZE_CLASS[size],
className
)}
>
<filter
id={filterId}
x='-30%'
y='-30%'
width='160%'
height='160%'
colorInterpolationFilters='sRGB'
>
<feGaussianBlur stdDeviation='5' />
<feColorMatrix values={GOO_ALPHA_MATRIX} />
</filter>
<g filter={`url(#${filterId})`} fill='currentColor' stroke='currentColor'>
{generateGlyph(seed).map(glyphShape)}
Comment on lines +107 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Repeated glyph rendering work

The workspace switcher renders every filtered workspace. For each logo-less row, this component mounts a Gaussian-blur SVG filter and recalculates glyph shapes on every render, even when the workspace ID has not changed. Typing in the switcher repeats that work across the list, which can make larger workspace lists less responsive. Memoizing the glyph or limiting rendered rows would reduce the cost.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

</g>
</svg>
)
}

/**
* The 16px mark for a workspace or organization: its uploaded logo, or its
* initial on a neutral tile. There is no per-entity color — every tile is the
* same gray so an uploaded mark is the only thing that distinguishes one from
* another, exactly as an icon would.
* The 16px mark for a workspace or organization: its uploaded logo, else a
* glyph seeded by the workspace id (so each is recognizable without an
* upload) or the organization's initial on a neutral tile.
*
* Chrome matches the chip family at tile scale: `rounded-sm` is the chip's
* `rounded-lg` scaled to a 16px box, and the letter sits at the smallest type
* token. The fill is `--surface-6`, one step past the chip hover and active
* fills, so the tile still reads as a tile on a hovered or selected row instead
* of dissolving into it. The letter is the icon gray in light mode and steps up
* to the secondary text gray in dark mode, where the icon gray sits too close
* to that fill. Plain `img`/`div`
* rather than the emcn `Avatar`, whose Radix root renders a `<span>` — and globals
* fade every `span` in the collapsed rail to `opacity: 0`, which would blank the
* mark exactly where it is the only thing left to see.
* of dissolving into it. The letter and the unfilled glyph are the icon gray in
* light mode and step up to the secondary text gray in dark mode, where the icon
* gray sits too close to the tile fill. Plain `img`/`div`/`svg` rather than the emcn
* `Avatar`, whose Radix root renders a `<span>` — and globals fade every `span`
* in the collapsed rail to `opacity: 0`, which would blank the mark exactly
* where it is the only thing left to see.
*/
export function IdentityTile({
initial,
glyphSeed,
logoUrl,
alt = '',
className,
Expand All @@ -55,6 +158,9 @@ export function IdentityTile({
/>
)
}
if (glyphSeed !== undefined) {
return <Glyph seed={glyphSeed} className={className} slot={slot} size={size} />
}
return (
<div
data-slot={slot}
Expand Down
Loading
Loading