From 14e2d0a64efa18498268214b86b2ab3505680312 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Fri, 23 Oct 2026 12:01:22 +0530 Subject: [PATCH 1/9] feat: session sharing with user collaboration features --- .changeset/session-share-collaborators-ui.md | 6 + .../assistant-ui-runtime/src/server/index.ts | 6 + .../assistant-ui-runtime/src/server/types.ts | 45 +++- packages/trueforge-ui/src/analytics/events.ts | 3 + .../src/atoms/ShareSessionDialog.tsx | 202 +++++++++++++++++- .../src/hooks/useCopySharedSessionLink.ts | 7 +- .../src/hooks/useSessionUserSearch.ts | 78 +++++++ .../src/hooks/useShareSessionDialog.ts | 90 +++++++- .../chatServer.ts | 4 +- .../src/server/createTrueForgeServer.ts | 5 + packages/trueforge-ui/src/server/types.ts | 6 + .../test/atoms/ShareSessionDialog.test.tsx | 170 ++++++++++----- .../test/hooks/useSessionUserSearch.test.tsx | 120 +++++++++++ .../test/hooks/useShareSessionDialog.test.tsx | 165 ++++++++++++++ .../harnessServer.test.ts | 3 +- 15 files changed, 831 insertions(+), 79 deletions(-) create mode 100644 .changeset/session-share-collaborators-ui.md create mode 100644 packages/trueforge-ui/src/hooks/useSessionUserSearch.ts create mode 100644 packages/trueforge-ui/test/hooks/useSessionUserSearch.test.tsx create mode 100644 packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx diff --git a/.changeset/session-share-collaborators-ui.md b/.changeset/session-share-collaborators-ui.md new file mode 100644 index 0000000000..bc35463609 --- /dev/null +++ b/.changeset/session-share-collaborators-ui.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-ui': patch +'@truefoundry/trueforge-assistant-ui-runtime': patch +--- + +Share sessions with specific users: invite by search, list and remove collaborators, and manage "everyone" sharing through the session access port. diff --git a/packages/assistant-ui-runtime/src/server/index.ts b/packages/assistant-ui-runtime/src/server/index.ts index b541d80b61..967afacc47 100644 --- a/packages/assistant-ui-runtime/src/server/index.ts +++ b/packages/assistant-ui-runtime/src/server/index.ts @@ -60,6 +60,7 @@ export type { CreateWebSearchRequest, CreatedBySubject, DefinedSkill, + DirectoryUser, GithubSkill, ImportGithubSkillRequest, ListPermissionsRequest, @@ -114,8 +115,13 @@ export type { SearchAgentsParams, SelectRegistrySkillRequest, Session, + SessionAccessGrant, + SessionAccessServer, + SessionAccessSubject, + SessionAccessSubjectType, SessionListEntry, SessionListMetrics, + SessionSubject, SkillBase, SkillCatalogEntry, SkillCatalogServer, diff --git a/packages/assistant-ui-runtime/src/server/types.ts b/packages/assistant-ui-runtime/src/server/types.ts index 333b6d4dee..0ed0388a8d 100644 --- a/packages/assistant-ui-runtime/src/server/types.ts +++ b/packages/assistant-ui-runtime/src/server/types.ts @@ -174,6 +174,11 @@ export interface AgentSpec< // Session — plain DTO, generic in TSpec so host's spec type flows through // --------------------------------------------------------------------------- +export interface SessionSubject { + id: string; + displayName: string; +} + export interface Session { id: string; title?: string | null; @@ -183,6 +188,8 @@ export interface Session { isMutable: boolean; /** When true, any subject in the tenant may read this session and its turns/events by id. */ shared?: boolean; + /** Creator of the session; the only subject allowed to manage its sharing. */ + createdBy?: SessionSubject; /** Rolled-up turns/duration/cost from the session detail API when the host provides it. */ metrics?: SessionListMetrics; createdAt: string; @@ -201,8 +208,6 @@ export interface UpdateSessionRequest { sessionId: string; agentSpec?: TSpec; title?: string; - /** When true, any subject in the tenant may read this session and its turns/events by id. */ - shared?: boolean; } // --------------------------------------------------------------------------- @@ -1153,6 +1158,39 @@ export interface PermissionsServer< listPermissions(req: TRequest): Promise; } +// --------------------------------------------------------------------------- +// Session access — share a session with specific users or the whole tenant +// --------------------------------------------------------------------------- + +export type SessionAccessSubjectType = 'user' | 'tenant'; + +export interface SessionAccessSubject { + subjectType: SessionAccessSubjectType; + subjectId: string; +} + +export interface SessionAccessGrant extends SessionAccessSubject { + /** Null for `tenant` grants and users the directory can no longer resolve. */ + displayName: string | null; + email: string | null; + createdAt: string; +} + +export interface DirectoryUser { + id: string; + displayName: string; + email: string | null; +} + +/** Only the session creator may call these; others are rejected with a permission error. */ +export interface SessionAccessServer { + listAccess(req: { sessionId: string }): Promise; + grantAccess(req: { sessionId: string; subjects: SessionAccessSubject[] }): Promise; + revokeAccess(req: { sessionId: string } & SessionAccessSubject): Promise; + /** Directory search for the invite input. Resolves empty when the host has no user directory. */ + searchUsers(req: { query?: string; limit?: number; pageToken?: string }): Promise>; +} + // --------------------------------------------------------------------------- // AgentMetrics — optional aggregate metrics + time-series charts // --------------------------------------------------------------------------- @@ -1252,6 +1290,7 @@ export type AgentUIServerPort< TSandboxEnvironments extends SandboxEnvironmentServer = SandboxEnvironmentServer, TMetrics extends AgentMetricsServer = AgentMetricsServer, TPermissions extends PermissionsServer = PermissionsServer, + TSessionAccess extends SessionAccessServer = SessionAccessServer, > = TChat & TBuilder & { catalog?: TCatalog; @@ -1260,6 +1299,8 @@ export type AgentUIServerPort< sandboxEnvironments?: TSandboxEnvironments; metrics?: TMetrics; permissions?: TPermissions; + /** Optional session sharing port; omit to hide the invite and collaborator UI. */ + sessionAccess?: TSessionAccess; /** Authenticated caller identity. Used for tenant-scoped share copy. */ getMe?: () => Promise<{ tenantId: string }>; }; diff --git a/packages/trueforge-ui/src/analytics/events.ts b/packages/trueforge-ui/src/analytics/events.ts index 80560f4c4c..ae56fb8d92 100644 --- a/packages/trueforge-ui/src/analytics/events.ts +++ b/packages/trueforge-ui/src/analytics/events.ts @@ -19,6 +19,9 @@ export const AnalyticsEvents = { RENAMED: 'trueforge.agent.session_renamed', SHARE_OPENED: 'trueforge.agent.session_share_opened', SHARE_LINK_COPIED: 'trueforge.agent.session_share_link_copied', + SHARE_PERMISSION_CHANGED: 'trueforge.agent.session_share_permission_changed', + SHARE_COLLABORATORS_INVITED: 'trueforge.agent.session_share_collaborators_invited', + SHARE_COLLABORATOR_REMOVED: 'trueforge.agent.session_share_collaborator_removed', }, Tool: { APPROVAL_RESOLVED: 'trueforge.agent.tool_approval_resolved', diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx index 855d7ba00d..02eb407866 100644 --- a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -1,13 +1,16 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { useTrackAnalytics } from '../analytics/AnalyticsProvider.js'; import { AnalyticsEvents } from '../analytics/events.js'; +import { useSessionUserSearch } from '../hooks/useSessionUserSearch.js'; import { useShareSessionDialog, type SessionSharePermission } from '../hooks/useShareSessionDialog.js'; import { Icon } from '../icons/Icon.js'; +import type { DirectoryUser } from '../server/types.js'; import { auiInputClass } from './lib/inputClasses.js'; import { auiSelectOptionClass, auiSelectTriggerClass } from './lib/selectClasses.js'; +import { Avatar, AvatarFallback } from './primitives/Avatar.js'; import { Button } from './primitives/Button.js'; import { DropdownMenu } from './primitives/DropdownMenu.js'; @@ -99,9 +102,157 @@ function AccessSelector({ ); } +function initialsOf(label: string): string { + return label.trim().slice(0, 2).toUpperCase(); +} + +function PersonRow({ + label, + role, + action, +}: { + label: string; + role: string; + action?: { label: string; onClick: () => void }; +}) { + return ( +
+ + {initialsOf(label)} + + {label} + {role} + {action == null ? null : ( + + )} +
+ ); +} + +function InviteCollaborators({ + excludedIds, + onInvite, +}: { + excludedIds: ReadonlySet; + onInvite: (users: DirectoryUser[]) => Promise; +}) { + const { query, setQuery, users, loading, hasMore, loadMore } = useSessionUserSearch({ enabled: true }); + const [selected, setSelected] = useState([]); + const [inviting, setInviting] = useState(false); + const selectedIds = new Set(selected.map(user => user.id)); + const candidates = users.filter(user => !excludedIds.has(user.id) && !selectedIds.has(user.id)); + + const submit = async () => { + if (selected.length === 0 || inviting) return; + setInviting(true); + const ok = await onInvite(selected); + setInviting(false); + if (ok) { + setSelected([]); + setQuery(''); + } + }; + + return ( +
+
+ setQuery(event.target.value)} + placeholder="Add collaborator username or email" + aria-label="Search users" + className={auiInputClass('h-8 min-w-0 flex-1 px-3.5 text-[0.8125rem]')} + /> + { + void submit(); + }} + > + + Invite + +
+ {selected.length > 0 ? ( +
+ {selected.map(user => ( + + ))} +
+ ) : null} + {query.trim().length > 0 ? ( +
+ {candidates.map(user => ( + + ))} + {!loading && candidates.length === 0 ? ( +

No matching users

+ ) : null} + {hasMore ? ( + + ) : null} +
+ ) : null} +
+ ); +} + export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogProps) { - const { permission, canManage, loading, shareUrl, copied, tenantId, load, changePermission, copySharedSessionLink } = - useShareSessionDialog(sessionId); + const { + permission, + canChangePermission, + canInvite, + loading, + shareUrl, + copied, + tenantId, + owner, + collaborators, + load, + changePermission, + invite, + removeCollaborator, + copySharedSessionLink, + } = useShareSessionDialog(sessionId); const track = useTrackAnalytics(); return ( @@ -124,12 +275,53 @@ export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogPro { + track(AnalyticsEvents.Session.SHARE_PERMISSION_CHANGED, { session_id: sessionId, permission: next }); void changePermission(next); }} /> - {permission === 'tenant' ? ( + {canInvite ? ( + grant.subjectId), ...(owner == null ? [] : [owner.id])])} + onInvite={async users => { + const ok = await invite(users); + if (ok) + track(AnalyticsEvents.Session.SHARE_COLLABORATORS_INVITED, { + session_id: sessionId, + count: users.length, + }); + return ok; + }} + /> + ) : null} + {collaborators.length > 0 || owner != null ? ( +
+ {collaborators.map(grant => { + const label = grant.displayName ?? grant.email ?? grant.subjectId; + return ( + { + track(AnalyticsEvents.Session.SHARE_COLLABORATOR_REMOVED, { session_id: sessionId }); + void removeCollaborator(grant.subjectId); + }, + }, + } + : {})} + /> + ); + })} + {owner == null ? null : } +
+ ) : null} + {permission === 'tenant' || collaborators.length > 0 ? ( <>
diff --git a/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts b/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts index a0adcacf45..5d887eb036 100644 --- a/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts +++ b/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts @@ -7,7 +7,8 @@ import { useOptionalResolvedRoutes } from '../routing/ResolvedRoutesContext.js'; export function useCopySharedSessionLink(sessionId: string | null | undefined): { copied: boolean; - copySharedSessionLink: () => Promise; + /** Resolves true when the link reached the clipboard. */ + copySharedSessionLink: () => Promise; } { const routes = useOptionalResolvedRoutes(); const [copied, setCopied] = useState(false); @@ -19,12 +20,14 @@ export function useCopySharedSessionLink(sessionId: string | null | undefined): }, [copied]); const copySharedSessionLink = useCallback(async () => { - if (sessionId == null) return; + if (sessionId == null) return false; try { await navigator.clipboard.writeText(buildSharedSessionHref({ sessionId, routes })); setCopied(true); + return true; } catch { // Clipboard access depends on the host browser and document permissions. + return false; } }, [routes, sessionId]); diff --git a/packages/trueforge-ui/src/hooks/useSessionUserSearch.ts b/packages/trueforge-ui/src/hooks/useSessionUserSearch.ts new file mode 100644 index 0000000000..f32c08a9f5 --- /dev/null +++ b/packages/trueforge-ui/src/hooks/useSessionUserSearch.ts @@ -0,0 +1,78 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useToasterOptional } from '../containers/ToasterContainer.js'; +import { useOptionalServer } from '../server/ServerContext.js'; +import type { DirectoryUser } from '../server/types.js'; + +const SEARCH_DEBOUNCE_MS = 250; +const SEARCH_PAGE_SIZE = 25; + +/** Debounced directory search for the invite input; an empty query lists nothing. */ +export function useSessionUserSearch({ enabled }: { enabled: boolean }): { + query: string; + setQuery: (query: string) => void; + users: DirectoryUser[]; + loading: boolean; + hasMore: boolean; + loadMore: () => void; +} { + const sessionAccess = useOptionalServer()?.sessionAccess; + const toaster = useToasterOptional(); + const [query, setQuery] = useState(''); + const [users, setUsers] = useState([]); + const [nextPageToken, setNextPageToken] = useState(); + const [loading, setLoading] = useState(false); + // Guards against out-of-order responses when the query changes mid-flight. + const requestIdRef = useRef(0); + + const fetchPage = useCallback( + async ({ term, pageToken }: { term: string; pageToken?: string }) => { + if (sessionAccess == null) return; + const requestId = ++requestIdRef.current; + setLoading(true); + try { + const page = await sessionAccess.searchUsers({ + query: term, + limit: SEARCH_PAGE_SIZE, + ...(pageToken === undefined ? {} : { pageToken }), + }); + if (requestId !== requestIdRef.current) return; + setUsers(previous => { + if (pageToken === undefined) return page.data; + const seen = new Set(previous.map(user => user.id)); + return [...previous, ...page.data.filter(user => !seen.has(user.id))]; + }); + setNextPageToken(page.nextPageToken); + } catch (caught) { + if (requestId === requestIdRef.current) toaster?.showError(caught); + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, + [sessionAccess, toaster], + ); + + useEffect(() => { + const term = query.trim(); + if (!enabled || sessionAccess == null || term.length === 0) { + requestIdRef.current++; + setUsers([]); + setNextPageToken(undefined); + setLoading(false); + return undefined; + } + const timer = window.setTimeout(() => { + void fetchPage({ term }); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [enabled, fetchPage, query, sessionAccess]); + + const loadMore = useCallback(() => { + if (nextPageToken == null || loading) return; + void fetchPage({ term: query.trim(), pageToken: nextPageToken }); + }, [fetchPage, loading, nextPageToken, query]); + + return { query, setQuery, users, loading, hasMore: nextPageToken != null, loadMore }; +} diff --git a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts index 6d2856bbc1..0c7cb42f31 100644 --- a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts +++ b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts @@ -6,6 +6,7 @@ import { useToasterOptional } from '../containers/ToasterContainer.js'; import { buildSharedSessionHref } from '../routing/paths.js'; import { useOptionalResolvedRoutes } from '../routing/ResolvedRoutesContext.js'; import { useOptionalServer } from '../server/ServerContext.js'; +import type { DirectoryUser, SessionAccessGrant, SessionSubject } from '../server/types.js'; import { useCopySharedSessionLink } from './useCopySharedSessionLink.js'; import { useResourcePermissions } from './useResourcePermissions.js'; @@ -14,32 +15,46 @@ export type SessionSharePermission = 'private' | 'tenant'; export function useShareSessionDialog(sessionId: string | null | undefined): { permission: SessionSharePermission; canManage: boolean; + /** Sharing writes need the access port and the caller's tenant id. */ + canChangePermission: boolean; + canInvite: boolean; loading: boolean; shareUrl: string; copied: boolean; tenantId: string | undefined; + owner: SessionSubject | undefined; + collaborators: SessionAccessGrant[]; load: () => Promise; changePermission: (next: SessionSharePermission) => Promise; + invite: (users: DirectoryUser[]) => Promise; + removeCollaborator: (subjectId: string) => Promise; copySharedSessionLink: () => Promise; } { const server = useOptionalServer(); + const sessionAccess = server?.sessionAccess; const toaster = useToasterOptional(); const routes = useOptionalResolvedRoutes(); const resourceIds = sessionId == null || sessionId.length === 0 ? [] : [sessionId]; const { allows } = useResourcePermissions({ resourceType: 'session', resourceIds }); const canManage = allows(sessionId, 'MANAGE'); - const { copied, copySharedSessionLink } = useCopySharedSessionLink(sessionId); + const { copied, copySharedSessionLink: copyLink } = useCopySharedSessionLink(sessionId); const [permission, setPermission] = useState('private'); const [tenantId, setTenantId] = useState(); + const [owner, setOwner] = useState(); + const [collaborators, setCollaborators] = useState([]); const [loading, setLoading] = useState(false); - const shareUrl = sessionId == null || sessionId.length === 0 ? '' : buildSharedSessionHref({ sessionId, routes }); + const hasSession = sessionId != null && sessionId.length > 0; + const shareUrl = hasSession ? buildSharedSessionHref({ sessionId, routes }) : ''; + const canChangePermission = canManage && sessionAccess != null && tenantId != null; + const canInvite = canManage && sessionAccess != null; const load = useCallback(async () => { - if (sessionId == null || sessionId.length === 0 || server == null) return; + if (!hasSession || server == null) return; setLoading(true); try { const session = await server.getSession({ sessionId }); + setOwner(session.createdBy); setPermission(session.shared === true ? 'tenant' : 'private'); if (server.getMe != null) { try { @@ -49,38 +64,97 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { // Keep the generic tenant label when identity is unavailable. } } + // Grants are creator-only; other viewers would get a 403. + if (sessionAccess != null && canManage) { + const grants = await sessionAccess.listAccess({ sessionId }); + setCollaborators(grants.filter(grant => grant.subjectType === 'user')); + setPermission(grants.some(grant => grant.subjectType === 'tenant') ? 'tenant' : 'private'); + } } catch (caught) { toaster?.showError(caught); } finally { setLoading(false); } - }, [server, sessionId, toaster]); + }, [canManage, hasSession, server, sessionAccess, sessionId, toaster]); const changePermission = useCallback( async (next: SessionSharePermission) => { - if (!canManage || next === permission) return; + if (!canChangePermission || next === permission || !hasSession || sessionAccess == null || tenantId == null) { + return; + } const previous = permission; setPermission(next); - if (sessionId == null || sessionId.length === 0 || server == null) return; try { - await server.updateSession({ sessionId, shared: next === 'tenant' }); + const subject = { subjectType: 'tenant', subjectId: tenantId } as const; + if (next === 'tenant') await sessionAccess.grantAccess({ sessionId, subjects: [subject] }); + else await sessionAccess.revokeAccess({ sessionId, ...subject }); } catch (caught) { setPermission(previous); toaster?.showError(caught); } }, - [canManage, permission, server, sessionId, toaster], + [canChangePermission, hasSession, permission, sessionAccess, sessionId, tenantId, toaster], + ); + + const invite = useCallback( + async (users: DirectoryUser[]) => { + if (!canInvite || !hasSession || sessionAccess == null || users.length === 0) return false; + try { + const granted = await sessionAccess.grantAccess({ + sessionId, + subjects: users.map(user => ({ subjectType: 'user', subjectId: user.id })), + }); + setCollaborators(granted.filter(grant => grant.subjectType === 'user')); + return true; + } catch (caught) { + toaster?.showError(caught); + return false; + } + }, + [canInvite, hasSession, sessionAccess, sessionId, toaster], ); + const removeCollaborator = useCallback( + async (subjectId: string) => { + if (!canInvite || !hasSession || sessionAccess == null) return; + const previous = collaborators; + setCollaborators(current => current.filter(grant => grant.subjectId !== subjectId)); + try { + await sessionAccess.revokeAccess({ sessionId, subjectType: 'user', subjectId }); + } catch (caught) { + setCollaborators(previous); + toaster?.showError(caught); + } + }, + [canInvite, collaborators, hasSession, sessionAccess, sessionId, toaster], + ); + + const copySharedSessionLink = useCallback(async () => { + if (!(await copyLink())) return; + toaster?.showSuccess({ + title: 'Link copied to your clipboard', + description: + permission === 'tenant' + ? 'Anyone in your workspace can open this link.' + : 'Only invited collaborators can open this link.', + }); + }, [copyLink, permission, toaster]); + return { permission, canManage, + canChangePermission, + canInvite, loading, shareUrl, copied, tenantId, + owner, + collaborators, load, changePermission, + invite, + removeCollaborator, copySharedSessionLink, }; } diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts index f34a5402ff..da538c2938 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts @@ -90,6 +90,7 @@ function toUiSession(session: TrueForgeApi.Session): HarnessUiSession { isMutable: session.agent.type === 'inline', isCreateAgent: readSessionIsCreateAgent(session.metadata), shared: session.shared, + createdBy: { id: session.createdBySubject.subjectId, displayName: session.createdBySubject.subjectDisplayName }, createdAt: session.createdAt, updatedAt: session.updatedAt, metrics: { @@ -240,12 +241,11 @@ export function createHarnessChatServer( await client.sessions.update(sessionId, { title }); }, - async updateSession({ sessionId, agentSpec, title, shared }) { + async updateSession({ sessionId, agentSpec, title }) { // Named (reference) sessions reject agent updates server-side. const body: TrueForgeApi.UpdateSessionRequest = { ...(agentSpec === undefined ? {} : { agent: { spec: toHarnessAgentSpec(agentSpec) } }), ...(title === undefined ? {} : { title }), - ...(shared === undefined ? {} : { shared }), }; const response = await client.sessions.update(sessionId, body); return toUiSession(response.data); diff --git a/packages/trueforge-ui/src/server/createTrueForgeServer.ts b/packages/trueforge-ui/src/server/createTrueForgeServer.ts index 151993d03c..02f0ac01f0 100644 --- a/packages/trueforge-ui/src/server/createTrueForgeServer.ts +++ b/packages/trueforge-ui/src/server/createTrueForgeServer.ts @@ -17,6 +17,7 @@ import type { SaveAgentResult, ScheduleServer, SearchAgentsParams, + SessionAccessServer, } from './types.js'; export type CreateTrueForgeServerOptions< @@ -57,6 +58,8 @@ export type CreateTrueForgeServerOptions< sandboxEnvironments?: TSandboxEnvironments; /** Per-resource grants. Omit to leave actions enabled. */ permissions?: TPermissions; + /** Session sharing (collaborator invite + list). Optional. */ + sessionAccess?: SessionAccessServer; /** Authenticated caller identity. Used for tenant-scoped share copy. */ getMe?: () => Promise<{ tenantId: string }>; }; @@ -83,6 +86,7 @@ export type TrueForgeServer< schedules?: TSchedules; sandboxEnvironments?: TSandboxEnvironments; permissions?: TPermissions; + sessionAccess?: SessionAccessServer; getMe?: () => Promise<{ tenantId: string }>; }; @@ -177,6 +181,7 @@ export function createTrueForgeServer< ...(opts.schedules != null ? { schedules: opts.schedules } : {}), ...(opts.sandboxEnvironments != null ? { sandboxEnvironments: opts.sandboxEnvironments } : {}), ...(opts.permissions != null ? { permissions: opts.permissions } : {}), + ...(opts.sessionAccess != null ? { sessionAccess: opts.sessionAccess } : {}), ...(opts.getMe != null ? { getMe: opts.getMe } : {}), }; return server; diff --git a/packages/trueforge-ui/src/server/types.ts b/packages/trueforge-ui/src/server/types.ts index e9360a1ddb..4ad691f800 100644 --- a/packages/trueforge-ui/src/server/types.ts +++ b/packages/trueforge-ui/src/server/types.ts @@ -62,6 +62,7 @@ export type { CreateWebSearchProviderRequest, CreatedBySubject, DefinedSkill, + DirectoryUser, GithubSkill, ImportGithubSkillRequest, ListPermissionsRequest, @@ -113,9 +114,14 @@ export type { SearchAgentsParams, SelectRegistrySkillRequest, Session, + SessionAccessGrant, + SessionAccessServer, + SessionAccessSubject, + SessionAccessSubjectType, SessionEventItem, SessionListEntry, SessionListMetrics, + SessionSubject, SkillBase, SkillCatalogEntry, SkillCatalogServer, diff --git a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx index 567d2ed623..13a8a6ad90 100644 --- a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx +++ b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ShareSessionDialog } from '@/atoms/ShareSessionDialog.js'; @@ -7,6 +7,7 @@ import { ToasterProvider } from '@/containers/ToasterContainer.js'; import { resolveRoutesConfig } from '@/routing/paths.js'; import { ResolvedRoutesProvider } from '@/routing/ResolvedRoutesContext.js'; import { ServerProvider } from '@/server/ServerContext.js'; +import type { SessionAccessGrant, SessionAccessSubject } from '@/server/types.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { createMockAgentUIServer } from '../server/mockServer.js'; @@ -30,40 +31,58 @@ afterEach(() => { } }); +type SessionStub = { + id: string; + isMutable: boolean; + shared?: boolean; + createdBy?: { id: string; displayName: string }; + createdAt: string; + updatedAt: string; +}; + +const baseSession: SessionStub = { + id: 'session-1', + isMutable: true, + shared: false, + createdBy: { id: 'owner', displayName: 'Sajal Preet Singh' }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}; + +const aliceGrant: SessionAccessGrant = { + subjectType: 'user', + subjectId: 'u1', + displayName: 'Alice', + email: 'alice@example.com', + createdAt: '2026-01-01T00:00:00Z', +}; + function renderDialog({ - getSession = vi.fn(async () => ({ - id: 'session-1', - isMutable: true, - shared: false, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - })), - updateSession = vi.fn(async (req: { sessionId: string; shared?: boolean }) => ({ - id: req.sessionId, - isMutable: true, - shared: req.shared === true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - })), - getMe = vi.fn(async () => ({ tenantId: 'acme' })), -}: { - getSession?: () => Promise<{ - id: string; - isMutable: boolean; - shared?: boolean; - createdAt: string; - updatedAt: string; - }>; - updateSession?: (req: { sessionId: string; shared?: boolean }) => Promise<{ - id: string; - isMutable: boolean; - shared?: boolean; - createdAt: string; - updatedAt: string; - }>; - getMe?: () => Promise<{ tenantId: string }>; -} = {}) { - const server = createMockAgentUIServer({ getSession, updateSession, getMe }); + session = baseSession, + grants = [], +}: { session?: SessionStub; grants?: SessionAccessGrant[] } = {}) { + const listAccess = vi.fn(async () => grants); + const grantAccess = vi.fn(async (req: { sessionId: string; subjects: SessionAccessSubject[] }) => [ + ...grants, + ...req.subjects.map((subject): SessionAccessGrant => ({ + ...subject, + displayName: subject.subjectType === 'user' ? 'Bob' : null, + email: null, + createdAt: '2026-01-02T00:00:00Z', + })), + ]); + const revokeAccess = vi.fn(async () => undefined); + const searchUsers = vi.fn(async () => ({ + data: [ + { id: 'u1', displayName: 'Alice', email: 'alice@example.com' }, + { id: 'u2', displayName: 'Bob', email: 'bob@example.com' }, + ], + })); + const server = createMockAgentUIServer({ + getSession: vi.fn(async () => session), + getMe: vi.fn(async () => ({ tenantId: 'acme' })), + sessionAccess: { listAccess, grantAccess, revokeAccess, searchUsers }, + }); render( @@ -75,7 +94,7 @@ function renderDialog({ , ); - return { getSession, updateSession, getMe }; + return { listAccess, grantAccess, revokeAccess, searchUsers }; } async function openSharePopover() { @@ -96,13 +115,8 @@ describe('ShareSessionDialog', () => { it('loads tenant sharing and copies the share URL', async () => { renderDialog({ - getSession: vi.fn(async () => ({ - id: 'session-1', - isMutable: true, - shared: true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - })), + session: { ...baseSession, shared: true }, + grants: [{ ...aliceGrant, subjectType: 'tenant', subjectId: 'acme', displayName: null, email: null }], }); await openSharePopover(); @@ -115,19 +129,21 @@ describe('ShareSessionDialog', () => { }); expect(new URL(String(writeText.mock.calls[0]?.[0])).pathname).toBe('/sessions/share/session-1'); expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + expect(await screen.findByText('Link copied to your clipboard')).toBeInTheDocument(); }); - it('shows the share URL after switching to tenant access and hides it again for Only you', async () => { - const { updateSession } = renderDialog(); + it('grants and revokes the tenant subject when switching access', async () => { + const { grantAccess, revokeAccess } = renderDialog(); await openSharePopover(); - expect(screen.queryByLabelText('Share URL')).not.toBeInTheDocument(); - fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); fireEvent.click(await screen.findByRole('option', { name: 'Everyone within acme' })); await waitFor(() => { - expect(updateSession).toHaveBeenCalledWith({ sessionId: 'session-1', shared: true }); + expect(grantAccess).toHaveBeenCalledWith({ + sessionId: 'session-1', + subjects: [{ subjectType: 'tenant', subjectId: 'acme' }], + }); }); expect(screen.getByLabelText('Share URL')).toBeInTheDocument(); @@ -135,13 +151,13 @@ describe('ShareSessionDialog', () => { fireEvent.click(await screen.findByRole('option', { name: 'Only you' })); await waitFor(() => { - expect(updateSession).toHaveBeenCalledWith({ sessionId: 'session-1', shared: false }); + expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 'session-1', subjectType: 'tenant', subjectId: 'acme' }); }); expect(screen.queryByLabelText('Share URL')).not.toBeInTheDocument(); }); - it('closes permission options on outside click without PATCHing', async () => { - const { updateSession } = renderDialog(); + it('closes permission options on outside click without changing access', async () => { + const { grantAccess, revokeAccess } = renderDialog(); await openSharePopover(); fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); @@ -150,29 +166,65 @@ describe('ShareSessionDialog', () => { fireEvent.mouseDown(screen.getByText('Change permissions')); expect(screen.queryByRole('option', { name: 'Everyone within acme' })).not.toBeInTheDocument(); - expect(updateSession).not.toHaveBeenCalled(); + expect(grantAccess).not.toHaveBeenCalled(); + expect(revokeAccess).not.toHaveBeenCalled(); }); - it('closes the permission menu without PATCHing when the current option is clicked', async () => { - const { updateSession } = renderDialog(); + it('closes the permission menu without changing access when the current option is clicked', async () => { + const { grantAccess, revokeAccess } = renderDialog(); await openSharePopover(); fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); fireEvent.click(await screen.findByRole('option', { name: 'Only you' })); expect(screen.queryByRole('option', { name: 'Only you' })).not.toBeInTheDocument(); - expect(updateSession).not.toHaveBeenCalled(); + expect(grantAccess).not.toHaveBeenCalled(); + expect(revokeAccess).not.toHaveBeenCalled(); }); - it('PATCHes shared when the tenant permission is selected', async () => { - const { updateSession } = renderDialog(); + it('lists the owner and collaborators and shows the share URL once someone is invited', async () => { + renderDialog({ grants: [aliceGrant] }); await openSharePopover(); - fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); - fireEvent.click(await screen.findByRole('option', { name: 'Everyone within acme' })); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('can access')).toBeInTheDocument(); + expect(screen.getByText('Sajal Preet Singh')).toBeInTheDocument(); + expect(screen.getByText('owner')).toBeInTheDocument(); + expect(screen.getByLabelText('Share URL')).toBeInTheDocument(); + }); + + it('removes a collaborator', async () => { + const { revokeAccess } = renderDialog({ grants: [aliceGrant] }); + await openSharePopover(); + + fireEvent.click(await screen.findByRole('button', { name: 'Remove Alice' })); + + await waitFor(() => { + expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 'session-1', subjectType: 'user', subjectId: 'u1' }); + }); + expect(screen.queryByText('Alice')).not.toBeInTheDocument(); + }); + + it('searches users, hides people who already have access, and invites the selection', async () => { + const { grantAccess, searchUsers } = renderDialog({ grants: [aliceGrant] }); + await openSharePopover(); + await screen.findByText('Alice'); + + fireEvent.change(screen.getByLabelText('Search users'), { target: { value: 'b' } }); + await waitFor(() => { + expect(searchUsers).toHaveBeenCalledWith({ query: 'b', limit: 25 }); + }); + + const results = await screen.findByRole('listbox', { name: 'Matching users' }); + expect(within(results).queryByText('Alice')).not.toBeInTheDocument(); + fireEvent.click(await within(results).findByRole('option', { name: /Bob/ })); + fireEvent.click(screen.getByRole('button', { name: 'Invite' })); await waitFor(() => { - expect(updateSession).toHaveBeenCalledWith({ sessionId: 'session-1', shared: true }); + expect(grantAccess).toHaveBeenCalledWith({ + sessionId: 'session-1', + subjects: [{ subjectType: 'user', subjectId: 'u2' }], + }); }); }); }); diff --git a/packages/trueforge-ui/test/hooks/useSessionUserSearch.test.tsx b/packages/trueforge-ui/test/hooks/useSessionUserSearch.test.tsx new file mode 100644 index 0000000000..c216c2f33b --- /dev/null +++ b/packages/trueforge-ui/test/hooks/useSessionUserSearch.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectoryUser, ListResult, SessionAccessServer } from '@/server/types.js'; + +type ServerHookValue = { sessionAccess?: Pick }; + +const useOptionalServer = vi.hoisted(() => vi.fn<() => ServerHookValue | null>()); + +vi.mock('@/server/ServerContext.js', () => ({ useOptionalServer })); +// Stable identity, like the real provider; a fresh object per render would retrigger the search effect. +const toaster = vi.hoisted(() => ({ showError: vi.fn(), showSuccess: vi.fn() })); +vi.mock('@/containers/ToasterContainer.js', () => ({ useToasterOptional: () => toaster })); + +import { useSessionUserSearch } from '@/hooks/useSessionUserSearch.js'; + +const alice: DirectoryUser = { id: 'u1', displayName: 'Alice', email: 'alice@example.com' }; +const bob: DirectoryUser = { id: 'u2', displayName: 'Bob', email: null }; + +describe('useSessionUserSearch', () => { + const searchUsers = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + searchUsers.mockReset(); + toaster.showError.mockReset(); + useOptionalServer.mockReturnValue({ sessionAccess: { searchUsers } }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('debounces typing into a single search and skips empty queries', async () => { + searchUsers.mockResolvedValue({ data: [alice] }); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + act(() => result.current.setQuery('al')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(searchUsers).toHaveBeenCalledTimes(1); + expect(searchUsers).toHaveBeenCalledWith({ query: 'al', limit: 25 }); + expect(result.current.users).toEqual([alice]); + + act(() => result.current.setQuery(' ')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchUsers).toHaveBeenCalledTimes(1); + expect(result.current.users).toEqual([]); + }); + + it('appends the next page without duplicating users', async () => { + searchUsers + .mockResolvedValueOnce({ data: [alice], nextPageToken: 'p2' }) + .mockResolvedValueOnce({ data: [alice, bob] } satisfies ListResult); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + result.current.loadMore(); + await Promise.resolve(); + }); + + expect(searchUsers).toHaveBeenLastCalledWith({ query: 'a', limit: 25, pageToken: 'p2' }); + expect(result.current.users).toEqual([alice, bob]); + expect(result.current.hasMore).toBe(false); + }); + + it('ignores a stale response when the query changed mid-flight', async () => { + let resolveFirst: (page: ListResult) => void = () => undefined; + searchUsers + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ data: [bob] }); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + act(() => result.current.setQuery('b')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + await act(async () => { + resolveFirst({ data: [alice] }); + await Promise.resolve(); + }); + + expect(result.current.users).toEqual([bob]); + }); + + it('reports search failures through the toaster', async () => { + const failure = new Error('boom'); + searchUsers.mockRejectedValue(failure); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(toaster.showError).toHaveBeenCalledWith(failure); + }); +}); diff --git a/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx b/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx new file mode 100644 index 0000000000..97ac6d2b93 --- /dev/null +++ b/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Session, SessionAccessGrant, SessionAccessServer } from '@/server/types.js'; + +type ServerHookValue = { + getSession: (req: { sessionId: string }) => Promise; + getMe: () => Promise<{ tenantId: string }>; + sessionAccess?: Pick; +}; + +const useOptionalServer = vi.hoisted(() => vi.fn<() => ServerHookValue | null>()); +const allows = vi.hoisted(() => vi.fn<() => boolean>()); +const showError = vi.hoisted(() => vi.fn()); +const showSuccess = vi.hoisted(() => vi.fn()); +const copyLink = vi.hoisted(() => vi.fn<() => Promise>()); + +vi.mock('@/server/ServerContext.js', () => ({ useOptionalServer })); +vi.mock('@/containers/ToasterContainer.js', () => ({ + useToasterOptional: () => ({ showError, showSuccess }), +})); +vi.mock('@/hooks/useResourcePermissions.js', () => ({ useResourcePermissions: () => ({ allows }) })); +vi.mock('@/routing/ResolvedRoutesContext.js', () => ({ useOptionalResolvedRoutes: () => undefined })); +vi.mock('@/routing/paths.js', () => ({ buildSharedSessionHref: () => 'https://app.test/shared' })); +vi.mock('@/hooks/useCopySharedSessionLink.js', () => ({ + useCopySharedSessionLink: () => ({ copied: false, copySharedSessionLink: copyLink }), +})); + +import { useShareSessionDialog } from '@/hooks/useShareSessionDialog.js'; + +const session: Session = { + id: 's1', + isMutable: false, + shared: false, + createdBy: { id: 'owner', displayName: 'Owner' }, + createdAt: '2026-10-22T00:00:00Z', + updatedAt: '2026-10-22T00:00:00Z', +}; + +function grant(overrides: Partial): SessionAccessGrant { + return { + subjectType: 'user', + subjectId: 'u1', + displayName: 'Alice', + email: 'alice@example.com', + createdAt: '2026-10-22T00:00:00Z', + ...overrides, + }; +} + +describe('useShareSessionDialog', () => { + const listAccess = vi.fn(); + const grantAccess = vi.fn(); + const revokeAccess = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + allows.mockReturnValue(true); + copyLink.mockResolvedValue(true); + listAccess.mockResolvedValue([grant({}), grant({ subjectType: 'tenant', subjectId: 't1', displayName: null })]); + grantAccess.mockResolvedValue([grant({}), grant({ subjectId: 'u2', displayName: 'Bob' })]); + revokeAccess.mockResolvedValue(undefined); + useOptionalServer.mockReturnValue({ + getSession: () => Promise.resolve(session), + getMe: () => Promise.resolve({ tenantId: 't1' }), + sessionAccess: { listAccess, grantAccess, revokeAccess }, + }); + }); + + afterEach(cleanup); + + it('loads owner, user collaborators and the tenant grant', async () => { + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + expect(result.current.owner).toEqual({ id: 'owner', displayName: 'Owner' }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + expect(result.current.permission).toBe('tenant'); + expect(result.current.tenantId).toBe('t1'); + }); + + it('does not list grants for viewers who cannot manage the session', async () => { + allows.mockReturnValue(false); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + expect(listAccess).not.toHaveBeenCalled(); + expect(result.current.canInvite).toBe(false); + expect(result.current.canChangePermission).toBe(false); + }); + + it('shares with the tenant by granting and unshares by revoking the tenant subject', async () => { + listAccess.mockResolvedValue([]); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.changePermission('tenant')); + expect(grantAccess).toHaveBeenCalledWith({ + sessionId: 's1', + subjects: [{ subjectType: 'tenant', subjectId: 't1' }], + }); + expect(result.current.permission).toBe('tenant'); + + await act(() => result.current.changePermission('private')); + expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 's1', subjectType: 'tenant', subjectId: 't1' }); + expect(result.current.permission).toBe('private'); + }); + + it('rolls back the permission and reports the error when the grant fails', async () => { + listAccess.mockResolvedValue([]); + const failure = new Error('forbidden'); + grantAccess.mockRejectedValue(failure); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.changePermission('tenant')); + + expect(result.current.permission).toBe('private'); + expect(showError).toHaveBeenCalledWith(failure); + }); + + it('invites users and replaces the collaborator list with the returned grants', async () => { + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + let ok = false; + await act(async () => { + ok = await result.current.invite([{ id: 'u2', displayName: 'Bob', email: null }]); + }); + + expect(ok).toBe(true); + expect(grantAccess).toHaveBeenCalledWith({ sessionId: 's1', subjects: [{ subjectType: 'user', subjectId: 'u2' }] }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1', 'u2']); + }); + + it('removes a collaborator optimistically and restores it when revoke fails', async () => { + const failure = new Error('nope'); + revokeAccess.mockRejectedValue(failure); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.removeCollaborator('u1')); + + expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 's1', subjectType: 'user', subjectId: 'u1' }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + expect(showError).toHaveBeenCalledWith(failure); + }); + + it('toasts the audience after copying the link, and nothing when the copy fails', async () => { + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.copySharedSessionLink()); + expect(showSuccess).toHaveBeenCalledWith({ + title: 'Link copied to your clipboard', + description: 'Anyone in your workspace can open this link.', + }); + + showSuccess.mockClear(); + copyLink.mockResolvedValue(false); + await act(() => result.current.copySharedSessionLink()); + expect(showSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts index 6bd78b29f1..a5edbd4610 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts @@ -19,7 +19,7 @@ const session = { }, }, title: null, - created_by: 'trueforge-default', + created_by_subject: { subject_id: 'u_owner', subject_type: 'user', subject_display_name: 'Owner' }, created_at: '2026-08-03T00:00:00.000Z', updated_at: '2026-08-03T00:00:00.000Z', metrics: { @@ -319,6 +319,7 @@ describe('createHarnessChatServer', () => { assert.equal(listedAgents, false); assert.equal(found.agentName, 'reviewer'); assert.equal(found.isMutable, false); + assert.deepEqual(found.createdBy, { id: 'u_owner', displayName: 'Owner' }); assert.deepEqual(found.metrics, { totalTurns: 1, totalDurationMs: 12_000, From 69493c935af0ac1389ee16d8199d080c433adbe7 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Tue, 27 Oct 2026 15:29:12 +0530 Subject: [PATCH 2/9] feat: enhance session sharing capabilities with user management and tenant-wide access toggle --- .changeset/session-share-collaborators-ui.md | 2 +- .../assistant-ui-runtime/src/server/types.ts | 8 +++-- .../src/hooks/useShareSessionDialog.ts | 13 +++----- .../chatServer.ts | 3 +- .../test/atoms/ShareSessionDialog.test.tsx | 31 +++++++++---------- .../test/hooks/useShareSessionDialog.test.tsx | 27 +++++++++------- .../harnessServer.test.ts | 14 ++++++++- 7 files changed, 57 insertions(+), 41 deletions(-) diff --git a/.changeset/session-share-collaborators-ui.md b/.changeset/session-share-collaborators-ui.md index bc35463609..3030a91f8c 100644 --- a/.changeset/session-share-collaborators-ui.md +++ b/.changeset/session-share-collaborators-ui.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge-assistant-ui-runtime': patch --- -Share sessions with specific users: invite by search, list and remove collaborators, and manage "everyone" sharing through the session access port. +Share sessions with specific users via the session access port (invite, list, remove), and toggle tenant-wide access with the existing session `shared` flag on updateSession. diff --git a/packages/assistant-ui-runtime/src/server/types.ts b/packages/assistant-ui-runtime/src/server/types.ts index 0ed0388a8d..9651f0a6a3 100644 --- a/packages/assistant-ui-runtime/src/server/types.ts +++ b/packages/assistant-ui-runtime/src/server/types.ts @@ -208,6 +208,8 @@ export interface UpdateSessionRequest { sessionId: string; agentSpec?: TSpec; title?: string; + /** When true, any subject in the tenant may read this session and its turns/events by id. */ + shared?: boolean; } // --------------------------------------------------------------------------- @@ -1159,10 +1161,10 @@ export interface PermissionsServer< } // --------------------------------------------------------------------------- -// Session access — share a session with specific users or the whole tenant +// Session access — share a session with specific users // --------------------------------------------------------------------------- -export type SessionAccessSubjectType = 'user' | 'tenant'; +export type SessionAccessSubjectType = 'user'; export interface SessionAccessSubject { subjectType: SessionAccessSubjectType; @@ -1170,7 +1172,7 @@ export interface SessionAccessSubject { } export interface SessionAccessGrant extends SessionAccessSubject { - /** Null for `tenant` grants and users the directory can no longer resolve. */ + /** Null when the directory can no longer resolve the user. */ displayName: string | null; email: string | null; createdAt: string; diff --git a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts index 0c7cb42f31..c27ca116f4 100644 --- a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts +++ b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts @@ -15,7 +15,7 @@ export type SessionSharePermission = 'private' | 'tenant'; export function useShareSessionDialog(sessionId: string | null | undefined): { permission: SessionSharePermission; canManage: boolean; - /** Sharing writes need the access port and the caller's tenant id. */ + /** Tenant-wide sharing is a session `shared` write; needs MANAGE only. */ canChangePermission: boolean; canInvite: boolean; loading: boolean; @@ -46,7 +46,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { const hasSession = sessionId != null && sessionId.length > 0; const shareUrl = hasSession ? buildSharedSessionHref({ sessionId, routes }) : ''; - const canChangePermission = canManage && sessionAccess != null && tenantId != null; + const canChangePermission = canManage; const canInvite = canManage && sessionAccess != null; const load = useCallback(async () => { @@ -68,7 +68,6 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { if (sessionAccess != null && canManage) { const grants = await sessionAccess.listAccess({ sessionId }); setCollaborators(grants.filter(grant => grant.subjectType === 'user')); - setPermission(grants.some(grant => grant.subjectType === 'tenant') ? 'tenant' : 'private'); } } catch (caught) { toaster?.showError(caught); @@ -79,21 +78,19 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { const changePermission = useCallback( async (next: SessionSharePermission) => { - if (!canChangePermission || next === permission || !hasSession || sessionAccess == null || tenantId == null) { + if (!canChangePermission || next === permission || !hasSession || server == null) { return; } const previous = permission; setPermission(next); try { - const subject = { subjectType: 'tenant', subjectId: tenantId } as const; - if (next === 'tenant') await sessionAccess.grantAccess({ sessionId, subjects: [subject] }); - else await sessionAccess.revokeAccess({ sessionId, ...subject }); + await server.updateSession({ sessionId, shared: next === 'tenant' }); } catch (caught) { setPermission(previous); toaster?.showError(caught); } }, - [canChangePermission, hasSession, permission, sessionAccess, sessionId, tenantId, toaster], + [canChangePermission, hasSession, permission, server, sessionId, toaster], ); const invite = useCallback( diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts index da538c2938..17e46d269f 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts @@ -241,11 +241,12 @@ export function createHarnessChatServer( await client.sessions.update(sessionId, { title }); }, - async updateSession({ sessionId, agentSpec, title }) { + async updateSession({ sessionId, agentSpec, title, shared }) { // Named (reference) sessions reject agent updates server-side. const body: TrueForgeApi.UpdateSessionRequest = { ...(agentSpec === undefined ? {} : { agent: { spec: toHarnessAgentSpec(agentSpec) } }), ...(title === undefined ? {} : { title }), + ...(shared === undefined ? {} : { shared }), }; const response = await client.sessions.update(sessionId, body); return toUiSession(response.data); diff --git a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx index 13a8a6ad90..b7f6d00ad1 100644 --- a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx +++ b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx @@ -66,12 +66,16 @@ function renderDialog({ ...grants, ...req.subjects.map((subject): SessionAccessGrant => ({ ...subject, - displayName: subject.subjectType === 'user' ? 'Bob' : null, + displayName: 'Bob', email: null, createdAt: '2026-01-02T00:00:00Z', })), ]); const revokeAccess = vi.fn(async () => undefined); + const updateSession = vi.fn(async (req: { sessionId: string; shared?: boolean }) => ({ + ...session, + shared: req.shared === true, + })); const searchUsers = vi.fn(async () => ({ data: [ { id: 'u1', displayName: 'Alice', email: 'alice@example.com' }, @@ -81,6 +85,7 @@ function renderDialog({ const server = createMockAgentUIServer({ getSession: vi.fn(async () => session), getMe: vi.fn(async () => ({ tenantId: 'acme' })), + updateSession, sessionAccess: { listAccess, grantAccess, revokeAccess, searchUsers }, }); render( @@ -94,7 +99,7 @@ function renderDialog({ , ); - return { listAccess, grantAccess, revokeAccess, searchUsers }; + return { listAccess, grantAccess, revokeAccess, searchUsers, updateSession }; } async function openSharePopover() { @@ -116,7 +121,6 @@ describe('ShareSessionDialog', () => { it('loads tenant sharing and copies the share URL', async () => { renderDialog({ session: { ...baseSession, shared: true }, - grants: [{ ...aliceGrant, subjectType: 'tenant', subjectId: 'acme', displayName: null, email: null }], }); await openSharePopover(); @@ -132,18 +136,15 @@ describe('ShareSessionDialog', () => { expect(await screen.findByText('Link copied to your clipboard')).toBeInTheDocument(); }); - it('grants and revokes the tenant subject when switching access', async () => { - const { grantAccess, revokeAccess } = renderDialog(); + it('updates session.shared when switching access', async () => { + const { updateSession } = renderDialog(); await openSharePopover(); fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); fireEvent.click(await screen.findByRole('option', { name: 'Everyone within acme' })); await waitFor(() => { - expect(grantAccess).toHaveBeenCalledWith({ - sessionId: 'session-1', - subjects: [{ subjectType: 'tenant', subjectId: 'acme' }], - }); + expect(updateSession).toHaveBeenCalledWith({ sessionId: 'session-1', shared: true }); }); expect(screen.getByLabelText('Share URL')).toBeInTheDocument(); @@ -151,13 +152,13 @@ describe('ShareSessionDialog', () => { fireEvent.click(await screen.findByRole('option', { name: 'Only you' })); await waitFor(() => { - expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 'session-1', subjectType: 'tenant', subjectId: 'acme' }); + expect(updateSession).toHaveBeenCalledWith({ sessionId: 'session-1', shared: false }); }); expect(screen.queryByLabelText('Share URL')).not.toBeInTheDocument(); }); it('closes permission options on outside click without changing access', async () => { - const { grantAccess, revokeAccess } = renderDialog(); + const { updateSession } = renderDialog(); await openSharePopover(); fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); @@ -166,20 +167,18 @@ describe('ShareSessionDialog', () => { fireEvent.mouseDown(screen.getByText('Change permissions')); expect(screen.queryByRole('option', { name: 'Everyone within acme' })).not.toBeInTheDocument(); - expect(grantAccess).not.toHaveBeenCalled(); - expect(revokeAccess).not.toHaveBeenCalled(); + expect(updateSession).not.toHaveBeenCalled(); }); it('closes the permission menu without changing access when the current option is clicked', async () => { - const { grantAccess, revokeAccess } = renderDialog(); + const { updateSession } = renderDialog(); await openSharePopover(); fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); fireEvent.click(await screen.findByRole('option', { name: 'Only you' })); expect(screen.queryByRole('option', { name: 'Only you' })).not.toBeInTheDocument(); - expect(grantAccess).not.toHaveBeenCalled(); - expect(revokeAccess).not.toHaveBeenCalled(); + expect(updateSession).not.toHaveBeenCalled(); }); it('lists the owner and collaborators and shows the share URL once someone is invited', async () => { diff --git a/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx b/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx index 97ac6d2b93..08aeb772c2 100644 --- a/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx +++ b/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx @@ -7,6 +7,7 @@ import type { Session, SessionAccessGrant, SessionAccessServer } from '@/server/ type ServerHookValue = { getSession: (req: { sessionId: string }) => Promise; getMe: () => Promise<{ tenantId: string }>; + updateSession: (req: { sessionId: string; shared?: boolean }) => Promise; sessionAccess?: Pick; }; @@ -53,24 +54,30 @@ describe('useShareSessionDialog', () => { const listAccess = vi.fn(); const grantAccess = vi.fn(); const revokeAccess = vi.fn(); + const updateSession = vi.fn<(req: { sessionId: string; shared?: boolean }) => Promise>(); + const getSession = vi.fn<(req: { sessionId: string }) => Promise>(); beforeEach(() => { vi.clearAllMocks(); allows.mockReturnValue(true); copyLink.mockResolvedValue(true); - listAccess.mockResolvedValue([grant({}), grant({ subjectType: 'tenant', subjectId: 't1', displayName: null })]); + getSession.mockResolvedValue(session); + listAccess.mockResolvedValue([grant({})]); grantAccess.mockResolvedValue([grant({}), grant({ subjectId: 'u2', displayName: 'Bob' })]); revokeAccess.mockResolvedValue(undefined); + updateSession.mockImplementation(async req => ({ ...session, shared: req.shared === true })); useOptionalServer.mockReturnValue({ - getSession: () => Promise.resolve(session), + getSession, getMe: () => Promise.resolve({ tenantId: 't1' }), + updateSession, sessionAccess: { listAccess, grantAccess, revokeAccess }, }); }); afterEach(cleanup); - it('loads owner, user collaborators and the tenant grant', async () => { + it('loads owner, user collaborators and permission from session.shared', async () => { + getSession.mockResolvedValue({ ...session, shared: true }); const { result } = renderHook(() => useShareSessionDialog('s1')); await act(() => result.current.load()); @@ -90,27 +97,24 @@ describe('useShareSessionDialog', () => { expect(result.current.canChangePermission).toBe(false); }); - it('shares with the tenant by granting and unshares by revoking the tenant subject', async () => { + it('shares with the tenant via updateSession shared and unshares the same way', async () => { listAccess.mockResolvedValue([]); const { result } = renderHook(() => useShareSessionDialog('s1')); await act(() => result.current.load()); await act(() => result.current.changePermission('tenant')); - expect(grantAccess).toHaveBeenCalledWith({ - sessionId: 's1', - subjects: [{ subjectType: 'tenant', subjectId: 't1' }], - }); + expect(updateSession).toHaveBeenCalledWith({ sessionId: 's1', shared: true }); expect(result.current.permission).toBe('tenant'); await act(() => result.current.changePermission('private')); - expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 's1', subjectType: 'tenant', subjectId: 't1' }); + expect(updateSession).toHaveBeenCalledWith({ sessionId: 's1', shared: false }); expect(result.current.permission).toBe('private'); }); - it('rolls back the permission and reports the error when the grant fails', async () => { + it('rolls back the permission and reports the error when updateSession fails', async () => { listAccess.mockResolvedValue([]); const failure = new Error('forbidden'); - grantAccess.mockRejectedValue(failure); + updateSession.mockRejectedValue(failure); const { result } = renderHook(() => useShareSessionDialog('s1')); await act(() => result.current.load()); @@ -148,6 +152,7 @@ describe('useShareSessionDialog', () => { }); it('toasts the audience after copying the link, and nothing when the copy fails', async () => { + getSession.mockResolvedValue({ ...session, shared: true }); const { result } = renderHook(() => useShareSessionDialog('s1')); await act(() => result.current.load()); diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts index a5edbd4610..ec2f661a10 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts @@ -54,7 +54,11 @@ const fetchMock: typeof fetch = async (input, init) => { body !== null && typeof body === 'object' && 'title' in body && typeof body.title === 'string' ? body.title : session.title; - return Response.json({ data: { ...session, title } }); + const shared = + body !== null && typeof body === 'object' && 'shared' in body && typeof body.shared === 'boolean' + ? body.shared + : false; + return Response.json({ data: { ...session, title, shared } }); } if (url.endsWith('/api/v1/sessions/ses_1') && method === 'DELETE') { deletedSessions.push('ses_1'); @@ -279,6 +283,14 @@ describe('createHarnessChatServer', () => { assert.equal(updated.title, 'Acme onboarding'); }); + it('PATCHes session shared when updateSession receives shared', async () => { + sessionRequests.length = 0; + const server = createHarnessChatServer({ fetch: fetchMock }); + const updated = await server.updateSession({ sessionId: 'ses_1', shared: true }); + assert.deepEqual(sessionRequests.at(-1), { shared: true }); + assert.equal(updated.shared, true); + }); + it('listSessions forwards an unknown agentId to the API (empty page from the server)', async () => { let listUrl: string | undefined; const fetchNamed: typeof fetch = async input => { From 340cecf794d66e46bafd4fc4451a4f14be47e3ba Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Tue, 27 Oct 2026 15:58:14 +0530 Subject: [PATCH 3/9] refactor: replace string literals with constants for session share permissions in ShareSessionDialog and useShareSessionDialog --- .../src/atoms/ShareSessionDialog.tsx | 19 ++++++++++++------- .../src/hooks/useShareSessionDialog.ts | 15 +++++++++------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx index 02eb407866..3eda505a24 100644 --- a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -5,7 +5,12 @@ import { useState, type ReactNode } from 'react'; import { useTrackAnalytics } from '../analytics/AnalyticsProvider.js'; import { AnalyticsEvents } from '../analytics/events.js'; import { useSessionUserSearch } from '../hooks/useSessionUserSearch.js'; -import { useShareSessionDialog, type SessionSharePermission } from '../hooks/useShareSessionDialog.js'; +import { + SESSION_SHARE_PRIVATE, + SESSION_SHARE_TENANT, + useShareSessionDialog, + type SessionSharePermission, +} from '../hooks/useShareSessionDialog.js'; import { Icon } from '../icons/Icon.js'; import type { DirectoryUser } from '../server/types.js'; import { auiInputClass } from './lib/inputClasses.js'; @@ -20,11 +25,11 @@ export type ShareSessionDialogProps = { }; const SHARE_PERMISSIONS: Record = { - private: { title: 'Only you', description: 'Only you have access' }, - tenant: { title: 'Everyone within this tenant', description: 'Everyone has access' }, + [SESSION_SHARE_PRIVATE]: { title: 'Only you', description: 'Only you have access' }, + [SESSION_SHARE_TENANT]: { title: 'Everyone within this tenant', description: 'Everyone has access' }, }; -const SHARE_PERMISSION_VALUES: readonly SessionSharePermission[] = ['private', 'tenant']; +const SHARE_PERMISSION_VALUES: readonly SessionSharePermission[] = [SESSION_SHARE_PRIVATE, SESSION_SHARE_TENANT]; function permissionCopy({ permission, @@ -33,8 +38,8 @@ function permissionCopy({ permission: SessionSharePermission; tenantId: string | undefined; }): { title: string; description: string } { - if (permission === 'tenant' && tenantId != null && tenantId.length > 0) { - return { title: `Everyone within ${tenantId}`, description: SHARE_PERMISSIONS.tenant.description }; + if (permission === SESSION_SHARE_TENANT && tenantId != null && tenantId.length > 0) { + return { title: `Everyone within ${tenantId}`, description: SHARE_PERMISSIONS[SESSION_SHARE_TENANT].description }; } return SHARE_PERMISSIONS[permission]; } @@ -321,7 +326,7 @@ export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogPro {owner == null ? null : }
) : null} - {permission === 'tenant' || collaborators.length > 0 ? ( + {permission === SESSION_SHARE_TENANT || collaborators.length > 0 ? ( <>
diff --git a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts index c27ca116f4..9ba260c95f 100644 --- a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts +++ b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts @@ -10,7 +10,10 @@ import type { DirectoryUser, SessionAccessGrant, SessionSubject } from '../serve import { useCopySharedSessionLink } from './useCopySharedSessionLink.js'; import { useResourcePermissions } from './useResourcePermissions.js'; -export type SessionSharePermission = 'private' | 'tenant'; +export const SESSION_SHARE_PRIVATE = 'private' as const; +export const SESSION_SHARE_TENANT = 'tenant' as const; + +export type SessionSharePermission = typeof SESSION_SHARE_PRIVATE | typeof SESSION_SHARE_TENANT; export function useShareSessionDialog(sessionId: string | null | undefined): { permission: SessionSharePermission; @@ -38,7 +41,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { const { allows } = useResourcePermissions({ resourceType: 'session', resourceIds }); const canManage = allows(sessionId, 'MANAGE'); const { copied, copySharedSessionLink: copyLink } = useCopySharedSessionLink(sessionId); - const [permission, setPermission] = useState('private'); + const [permission, setPermission] = useState(SESSION_SHARE_PRIVATE); const [tenantId, setTenantId] = useState(); const [owner, setOwner] = useState(); const [collaborators, setCollaborators] = useState([]); @@ -55,7 +58,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { try { const session = await server.getSession({ sessionId }); setOwner(session.createdBy); - setPermission(session.shared === true ? 'tenant' : 'private'); + setPermission(session.shared === true ? SESSION_SHARE_TENANT : SESSION_SHARE_PRIVATE); if (server.getMe != null) { try { const me = await server.getMe(); @@ -64,7 +67,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { // Keep the generic tenant label when identity is unavailable. } } - // Grants are creator-only; other viewers would get a 403. + // listAccess is creator/manager-only on the host; skip for viewers to avoid a 403. if (sessionAccess != null && canManage) { const grants = await sessionAccess.listAccess({ sessionId }); setCollaborators(grants.filter(grant => grant.subjectType === 'user')); @@ -84,7 +87,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { const previous = permission; setPermission(next); try { - await server.updateSession({ sessionId, shared: next === 'tenant' }); + await server.updateSession({ sessionId, shared: next === SESSION_SHARE_TENANT }); } catch (caught) { setPermission(previous); toaster?.showError(caught); @@ -131,7 +134,7 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { toaster?.showSuccess({ title: 'Link copied to your clipboard', description: - permission === 'tenant' + permission === SESSION_SHARE_TENANT ? 'Anyone in your workspace can open this link.' : 'Only invited collaborators can open this link.', }); From f0a25dbabfc550407537ecd11a77df8d5eb65808 Mon Sep 17 00:00:00 2001 From: Harshil Vasoya Date: Wed, 28 Oct 2026 12:28:43 +0530 Subject: [PATCH 4/9] chore: add session access server for user collaboration --- .changeset/session-share-collaborators-ui.md | 2 +- .../src/atoms/ShareSessionDialog.tsx | 5 +- .../trueforge-agent-server-adapter/index.ts | 6 ++ .../sessionAccessServer.ts | 78 +++++++++++++++ .../sessionAccessServer.test.ts | 99 +++++++++++++++++++ 5 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/sessionAccessServer.ts create mode 100644 packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/sessionAccessServer.test.ts diff --git a/.changeset/session-share-collaborators-ui.md b/.changeset/session-share-collaborators-ui.md index 3030a91f8c..35b17c4558 100644 --- a/.changeset/session-share-collaborators-ui.md +++ b/.changeset/session-share-collaborators-ui.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge-assistant-ui-runtime': patch --- -Share sessions with specific users via the session access port (invite, list, remove), and toggle tenant-wide access with the existing session `shared` flag on updateSession. +Share sessions with specific users: the Harness server implements the session access port (invite search, grants, revoke), and tenant-wide access still uses the session `shared` flag. diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx index 3eda505a24..361de04ab6 100644 --- a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -205,7 +205,7 @@ function InviteCollaborators({ ))}
) : null} - {query.trim().length > 0 ? ( + {query.trim().length > 0 && (candidates.length > 0 || hasMore) ? (
{candidates.map(user => ( ))} - {!loading && candidates.length === 0 ? ( -

No matching users

- ) : null} {hasMore ? (