Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
14e2d0a
feat: session sharing with user collaboration features
harshil-2096 Oct 23, 2026
69493c9
feat: enhance session sharing capabilities with user management and t…
harshil-2096 Oct 27, 2026
fb3e76f
Merge branch 'main' into feat/hv/session-sharing-collaborators-ui
harshil-2096 Oct 27, 2026
340cecf
refactor: replace string literals with constants for session share pe…
harshil-2096 Oct 27, 2026
c268fd9
Merge branch 'feat/hv/session-sharing-collaborators-ui' of github.com…
harshil-2096 Oct 27, 2026
26290fa
Merge branch 'main' into feat/hv/session-sharing-collaborators-ui
harshil-2096 Oct 27, 2026
0ba5ecd
Merge branch 'main' into feat/hv/session-sharing-collaborators-ui
harshil-2096 Oct 27, 2026
df88baf
Merge branch 'main' into feat/hv/session-sharing-collaborators-ui
harshil-2096 Oct 28, 2026
f0a25db
chore: add session access server for user collaboration
harshil-2096 Oct 28, 2026
5733fad
feat: enhance session sharing capabilities with user-specific access …
harshil-2096 Oct 28, 2026
8d5a626
Merge branch 'main' into feat/hv/session-sharing-collaborators-ui
harshil-2096 Oct 29, 2026
4022705
fix capabalities type error
harshil-2096 Oct 29, 2026
65d2ba7
Omit tenant session grants from the session access collaborator list;…
harshil-2096 Oct 29, 2026
b01dd8e
Enhance session user search and share session dialog functionality
harshil-2096 Oct 29, 2026
c47b001
Implement epoch tracking for collaborator list updates in useShareSes…
harshil-2096 Oct 29, 2026
c025b8a
Merge branch 'main' into feat/hv/session-sharing-collaborators-ui
sajal-truefoundry Oct 29, 2026
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
5 changes: 5 additions & 0 deletions .changeset/omit-tenant-session-access-grants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Omit tenant session grants from the session access collaborator list; tenant-wide sharing stays on `session.shared`.
6 changes: 6 additions & 0 deletions .changeset/session-share-collaborators-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@truefoundry/trueforge-ui': patch
'@truefoundry/trueforge-assistant-ui-runtime': patch
---

Share sessions with specific users when capabilities report `session_sharing.users`: Harness implements the session access port (invite search, grants, revoke); tenant-wide access still uses the session `shared` flag.
6 changes: 6 additions & 0 deletions packages/assistant-ui-runtime/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type {
CreateWebSearchRequest,
CreatedBySubject,
DefinedSkill,
DirectoryUser,
GithubSkill,
ImportGithubSkillRequest,
ListPermissionsRequest,
Expand Down Expand Up @@ -114,8 +115,13 @@ export type {
SearchAgentsParams,
SelectRegistrySkillRequest,
Session,
SessionAccessGrant,
SessionAccessServer,
SessionAccessSubject,
SessionAccessSubjectType,
SessionListEntry,
SessionListMetrics,
SessionSubject,
SkillBase,
SkillCatalogEntry,
SkillCatalogServer,
Expand Down
45 changes: 45 additions & 0 deletions packages/assistant-ui-runtime/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,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<TSpec extends AgentSpec = AgentSpec> {
id: string;
title?: string | null;
Expand All @@ -185,6 +190,8 @@ export interface Session<TSpec extends AgentSpec = AgentSpec> {
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;
Expand Down Expand Up @@ -456,6 +463,8 @@ export interface AgentBuilderCapabilitiesResponse {
skill: { enabled: boolean; reason?: string };
settings?: { enabled: boolean };
webSearch?: { enabled: boolean };
/** Present when the host reports session sharing; `enabled` gates invite-by-user. */
sessionSharing?: { enabled: boolean };
};
}

Expand Down Expand Up @@ -1161,6 +1170,39 @@ export interface PermissionsServer<
listPermissions(req: TRequest): Promise<TResponse>;
}

// ---------------------------------------------------------------------------
// Session access — share a session with specific users
// ---------------------------------------------------------------------------

export type SessionAccessSubjectType = 'user';

export interface SessionAccessSubject {
subjectType: SessionAccessSubjectType;
subjectId: string;
}

export interface SessionAccessGrant extends SessionAccessSubject {
/** Null when the directory can no longer resolve the user. */
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<SessionAccessGrant[]>;
grantAccess(req: { sessionId: string; subjects: SessionAccessSubject[] }): Promise<SessionAccessGrant[]>;
revokeAccess(req: { sessionId: string } & SessionAccessSubject): Promise<void>;
/** Directory search for the invite input. Resolves empty when the host has no user directory. */
searchUsers(req: { query?: string; limit?: number; pageToken?: string }): Promise<ListResult<DirectoryUser>>;
}

// ---------------------------------------------------------------------------
// AgentMetrics — optional aggregate metrics + time-series charts
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1260,6 +1302,7 @@ export type AgentUIServerPort<
TSandboxEnvironments extends SandboxEnvironmentServer = SandboxEnvironmentServer,
TMetrics extends AgentMetricsServer = AgentMetricsServer,
TPermissions extends PermissionsServer = PermissionsServer,
TSessionAccess extends SessionAccessServer = SessionAccessServer,
> = TChat &
TBuilder & {
catalog?: TCatalog;
Expand All @@ -1268,6 +1311,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 }>;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/trueforge-ui/src/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
216 changes: 205 additions & 11 deletions packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
'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 { useShareSessionDialog, type SessionSharePermission } from '../hooks/useShareSessionDialog.js';
import { useSessionUserSearch } from '../hooks/useSessionUserSearch.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';
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';

Expand All @@ -17,11 +25,11 @@ export type ShareSessionDialogProps = {
};

const SHARE_PERMISSIONS: Record<SessionSharePermission, { title: string; description: string }> = {
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,
Expand All @@ -30,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];
}
Expand Down Expand Up @@ -99,9 +107,154 @@ 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 (
<div className="flex h-8 w-full items-center gap-2 text-sm">
<Avatar size="sm">
<AvatarFallback>{initialsOf(label)}</AvatarFallback>
</Avatar>
<span className="text-text-primary min-w-0 flex-1 truncate">{label}</span>
<span className="text-text-secondary shrink-0">{role}</span>
{action == null ? null : (
<button
type="button"
aria-label={action.label}
title={action.label}
className="text-text-secondary hover:text-text-primary shrink-0 cursor-pointer"
onClick={action.onClick}
>
<Icon name="xmark" size="0.875rem" />
</button>
)}
</div>
);
}

function InviteCollaborators({
excludedIds,
onInvite,
}: {
excludedIds: ReadonlySet<string>;
onInvite: (users: DirectoryUser[]) => Promise<boolean>;
}) {
const { query, setQuery, users, loading, hasMore, loadMore } = useSessionUserSearch({ enabled: true });
const [selected, setSelected] = useState<DirectoryUser[]>([]);
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 (
<div className="flex w-full flex-col gap-2">
<div className="flex h-8 w-full items-center gap-2">
<input
value={query}
onChange={event => 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]')}
/>
<Button.Primary
type="button"
size="large"
className="h-8 shrink-0 rounded px-2.5 text-xs"
disabled={selected.length === 0 || inviting}
onClick={() => {
void submit();
}}
>
<Icon name="users" size="0.875rem" />
Invite
</Button.Primary>
</div>
{selected.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selected.map(user => (
<button
key={user.id}
type="button"
aria-label={`Remove ${user.displayName} from invite`}
className="bg-secondary-bg text-text-primary flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs"
onClick={() => setSelected(current => current.filter(item => item.id !== user.id))}
>
{user.displayName}
<Icon name="xmark" size="0.625rem" />
</button>
))}
</div>
) : null}
{query.trim().length > 0 && (candidates.length > 0 || hasMore) ? (
<div role="listbox" aria-label="Matching users" className="flex max-h-40 w-full flex-col overflow-y-auto">
{candidates.map(user => (
<button
key={user.id}
type="button"
role="option"
aria-selected={false}
className={auiSelectOptionClass()}
onClick={() => setSelected(current => [...current, user])}
>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="font-semibold">{user.displayName}</span>
{user.email == null ? null : <span className="text-text-secondary truncate">{user.email}</span>}
</span>
</button>
))}
{hasMore ? (
<button
type="button"
className="text-text-secondary hover:text-text-primary cursor-pointer px-2 py-1.5 text-left text-sm"
disabled={loading}
onClick={loadMore}
>
{loading ? 'Loading…' : 'Load more'}
</button>
) : null}
</div>
) : null}
</div>
);
}

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 (
Expand All @@ -124,12 +277,53 @@ export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogPro
<AccessSelector
permission={permission}
tenantId={tenantId}
disabled={!canManage || loading}
disabled={!canChangePermission || loading}
onChange={next => {
track(AnalyticsEvents.Session.SHARE_PERMISSION_CHANGED, { session_id: sessionId, permission: next });
void changePermission(next);
}}
/>
{permission === 'tenant' ? (
{canInvite ? (
<InviteCollaborators
excludedIds={new Set([...collaborators.map(grant => 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 ? (
<div className="flex w-full flex-col gap-1">
{collaborators.map(grant => {
const label = grant.displayName ?? grant.email ?? grant.subjectId;
return (
<PersonRow
key={grant.subjectId}
label={label}
role="can access"
{...(canInvite
? {
action: {
label: `Remove ${label}`,
onClick: () => {
track(AnalyticsEvents.Session.SHARE_COLLABORATOR_REMOVED, { session_id: sessionId });
void removeCollaborator(grant.subjectId);
},
},
}
: {})}
/>
);
})}
{owner == null ? null : <PersonRow label={owner.displayName} role="owner" />}
</div>
) : null}
{permission === SESSION_SHARE_TENANT || collaborators.length > 0 ? (
<>
<div role="separator" className="bg-border h-px w-full" />
<div className="flex w-full items-center gap-2">
Expand Down
Loading
Loading