Skip to content

feat(web): Ask share links#888

Merged
brendan-kellam merged 25 commits intomainfrom
bkellam/ask-share-links-SOU-65
Feb 18, 2026
Merged

feat(web): Ask share links#888
brendan-kellam merged 25 commits intomainfrom
bkellam/ask-share-links-SOU-65

Conversation

@brendan-kellam
Copy link
Contributor

@brendan-kellam brendan-kellam commented Feb 13, 2026

Overview

From cursor:

Note

Enables sharing chats beyond simple public/private links by introducing per-user access grants and a new chat-sharing entitlement, plus related UI to manage visibility and invite/remove org members from access.

Reworks chat ownership/editability: removes Chat.isReadonly, tracks anonymous chat ownership via a cookie-backed anonymousCreatorId, claims anonymous chats on sign-in, and tightens write operations (sending messages, renaming, updating messages) to owner-only while allowing shared users to view private chats and leave feedback.

Adds chat duplication (including from read-only/shared views) and consolidates chat action UI (rename/duplicate/delete with loading states). Also adds public-chat metadata/OG image generation for better link previews, and improves auth UX by preserving callbackUrl across login/signup and optionally offering "Continue as guest."

Fixes #661
Fixes #479

PostHog Events

Event Name Description
wa_chat_share_dialog_opened User opens the share dialog for a chat
wa_chat_visibility_changed Chat visibility is changed between PUBLIC and PRIVATE
wa_chat_link_copied User copies the share link to clipboard
wa_chat_users_invited Users are invited to access a private chat
wa_chat_user_removed A user is removed from chat access
wa_shared_chat_viewed Someone views a shared chat (tracks viewer type and access method)
wa_chat_sign_in_banner_displayed Sign-in prompt banner is shown to an anonymous chat owner
wa_chat_sign_in_banner_dismissed User dismisses the sign-in prompt banner
wa_chat_sign_in_banner_clicked User clicks "Sign in" on the banner
wa_anonymous_chats_claimed Anonymous chats are claimed when user signs in
wa_chat_duplicated User duplicates a chat
wa_chat_renamed User renames a chat
wa_chat_deleted User deletes a chat

UI screenshots:

Share Dialog - Main View

  • Private visibility selected (with shared users)
image
  • Public visibility selected
image
  • Empty state (no shared users, just owner)
image

Visibility Dropdown

  • Dropdown expanded showing options
image

Invite Users View

  • Search with user selected (showing inline badge)
image
  • Search results list (no selection yet)
image
  • Empty search state (no results found)
image

Remove User

  • Tooltip showing "Remove user"
image

Enterprise Feature Disabled

  • Disabled search button with info message (when isChatSharingEnabledInCurrentPlan is false)

Unauthenticated User

  • Share dialog as unauthenticated user (disabled visibility dropdown with "Sign in" link)
image

Chat Actions Menu

  • Dropdown showing "Duplicate" option
image

Duplicating Chats

  • Duplicate banner when chat is not the current user's
image
  • Duplicate rename dialog
image

Anonymous access

  • Sign in to save chat history prompt
image

Open graph images

  • Public chat
image

Summary by CodeRabbit

  • New Features

    • Chat sharing with invite/search, visibility controls, and share settings UI
    • Chat duplication workflow (create copies) with dialogs and quick actions
    • Anonymous sessions: create, persist, and claim anonymous chats; "Continue as guest"
    • Open Graph image & metadata generation for shared chats
    • Sign-in prompt encouraging users to save chat history
  • Bug Fixes

    • Improved avatar fallback behavior
  • Refactors

    • Permissions changed from read-only flag to ownership-based access
    • Database updated to track anonymous creators and shared access

Note

High Risk
Touches core chat access control and persistence (schema changes, ownership/visibility checks, new sharing API), so mistakes could leak private chats or block legitimate access.

Overview
Adds a new chat sharing model: chats can be public via link or private but shared with specific org users (Enterprise-gated via new chat-sharing entitlement), including a new member-search API and UI to invite/remove users and change visibility.

Reworks chat permissions from Chat.isReadonly to ownership-based access control, adds anonymous chat ownership via a cookie-backed anonymousCreatorId, and claims anonymous chats on sign-in; write operations (sending messages, renaming, updating messages) become owner-only while shared users can view private chats and submit feedback.

Introduces chat duplication (including from non-owner/read-only views), improves chat action UX with consolidated rename/duplicate/delete dialogs + loading states, and adds Open Graph metadata + dynamic OG image generation for public chat links; auth flows now preserve callbackUrl and can offer “Continue as guest” when anonymous access is enabled.

Written by Cursor Bugbot for commit 8eb70a7. This will update automatically on new commits. Configure here.

@github-actions

This comment has been minimized.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Replaces chat read-only flag with ownership and explicit sharing: adds anonymousCreatorId, ChatAccess table and migrations; introduces ownership checks, claim/duplicate/share/unshare APIs; adds anonymous-id cookie handling; owner-aware UI (sharing, duplication, OG image) and related client/server routes/components.

Changes

Cohort / File(s) Summary
Database schema & migrations
packages/db/prisma/schema.prisma, packages/db/prisma/migrations/...
Removed isReadonly from Chat, added anonymousCreatorId, introduced ChatAccess model and migrations to add/drop columns and create ChatAccess table + constraints.
Core chat actions & backend APIs
packages/web/src/features/chat/actions.ts, packages/web/src/app/api/(server)/chat/route.ts, packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts
Added ownership/shared-access checks, claimAnonymousChats, duplicateChat, share/unshare helpers, getSharedWithUsersForChat, updated getChatInfo to return isOwner, and new server searchMembers route for invite search.
Client API & types
packages/web/src/app/api/(client)/client.ts, packages/web/src/auth.ts, packages/web/src/lib/errorCodes.ts, packages/web/src/lib/serviceError.ts
Added searchChatShareableMembers client function, introduced SessionUser type, removed CHAT_IS_READONLY error and chatIsReadonly() helper.
Anonymous session handling
packages/web/src/lib/anonymousId.ts
New helpers to get or create sb.anonymous-id cookie for anonymous ownership tracking.
Page-level & OG image
packages/web/src/app/[domain]/chat/[id]/page.tsx, packages/web/src/app/[domain]/chat/[id]/opengraph-image.tsx, packages/web/src/app/[domain]/components/topBar.tsx
Added generateMetadata, OG image generation endpoint for public chats, and TopBar centerContent/actions props to surface sharing controls.
Sharing UI components
packages/web/src/app/[domain]/chat/components/shareChatPopover/... (ShareChatPopover, ShareSettings, InvitePanel)
New ShareChatPopover, ShareSettings and InvitePanel UI with async handlers to update visibility and invite/remove users, plus toasts and client integration.
Chat thread & panels (ownership UI)
packages/web/src/features/chat/components/chatThread/..., packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx, packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx
Replaced isChatReadonly with isOwner, isAuthenticated, chatName; owner vs non-owner UI branches, SignInPromptBanner, duplication flow, and prop updates across thread/panel components.
Chat actions & dialogs
packages/web/src/app/[domain]/chat/components/* (chatActionsDropdown, chatName, chatSidePanel, rename/delete/duplicate dialogs)
Centralized ChatActionsDropdown; async rename/delete/duplicate dialogs with loading states and updated callback signatures; ChatSidePanel wired to duplicate flow and async returns.
Auth & login UI
packages/web/src/app/login/*, packages/web/src/app/signup/page.tsx, packages/web/src/app/[domain]/components/meControlDropdownMenu.tsx
Login/Signup fetch anonymous-access status and pass isAnonymousAccessEnabled to LoginForm; show "Continue as guest" when enabled; avatar fallback adjusted.
Entitlements & config
packages/shared/src/entitlements.ts
Added chat-sharing entitlement and updated entitlementsByPlan mappings.
Developer docs & tools
CLAUDE.md, packages/db/tools/* (scriptRunner, inject-user-data.ts)
Expanded CLAUDE.md developer guidance; added inject-user-data script and exposed it in scriptRunner.
OG / metadata & changelog/docs
CHANGELOG.md, docs/docs/*
Changed Unreleased notes (duplication, OG metadata/image, ownership model), added docs page for chat-sharing.
Misc server/client wiring
packages/web/src/app/(client)/client.ts, packages/db/tools/scripts/inject-user-data.ts
Added client API binding for member search and new DB tooling script export.

Sequence Diagram(s)

sequenceDiagram
    participant Browser as Browser UI
    participant Cookie as Cookie Store
    participant API as Server API
    participant DB as Database

    Browser->>Cookie: read sb.anonymous-id
    alt cookie missing
        Browser->>API: request getOrCreateAnonymousId
        API->>Cookie: set sb.anonymous-id
    end
    Browser->>API: POST /api/chat (create chat, anon or auth)
    API->>DB: INSERT Chat (creatorId or anonymousCreatorId)
    DB-->>API: return chat
    API-->>Browser: created chat
Loading
sequenceDiagram
    participant Owner as Authenticated Owner
    participant UI as Share UI
    participant API as Server API
    participant DB as Database

    Owner->>UI: open ShareChatPopover
    UI->>API: GET /api/ee/chat/{chatId}/searchMembers?query=...
    API->>DB: query members excluding existing ChatAccess + current user
    DB-->>API: return matches
    API-->>UI: display results
    Owner->>UI: invite selected users
    UI->>API: POST shareChatWithUsers
    API->>DB: INSERT ChatAccess rows
    DB-->>API: success
    API-->>UI: success response
    UI->>Owner: show toast, update list
Loading
sequenceDiagram
    participant Anonymous as Anonymous User
    participant Browser as Browser UI
    participant API as Server API
    participant DB as Database

    Anonymous->>Browser: creates chats (sb.anonymous-id)
    Browser->>API: sign in
    API->>DB: authenticate -> userId
    API->>API: claimAnonymousChats (reads sb.anonymous-id)
    API->>DB: UPDATE Chat SET creatorId=userId WHERE anonymousCreatorId=uuid
    DB-->>API: updated rows
    API-->>Browser: sign-in complete
    Browser->>Anonymous: owner controls visible
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

sourcebot-team

Suggested reviewers

  • msukkari
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(web): Ask share links' accurately captures the main feature: enabling shareable links for Ask Sourcebot chat sessions. It is concise and specific.
Linked Issues check ✅ Passed The PR fully implements both linked issues: #661 (anonymous sign-in with chat claiming) and #479 (shareable links/access control via visibility and per-user sharing).
Out of Scope Changes check ✅ Passed All changes align with the PR objectives. Database schema updates, authorization rewiring, UI enhancements, and documentation support the core features of anonymous chat ownership and shareable links.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch bkellam/ask-share-links-SOU-65

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In `@packages/web/src/app/`[domain]/chat/[id]/opengraph-image.tsx:
- Line 121: The current src fallback uses process.env.NEXT_PUBLIC_DOMAIN_URL ??
'http://localhost:3000' which can leak localhost into production OG images;
update the code paths that build the placeholder URL (the JSX using creatorImage
and the similar occurrence around the logo) to avoid defaulting to
'http://localhost:3000' — instead, call generateDefaultImage() when
NEXT_PUBLIC_DOMAIN_URL is undefined (or, if NODE_ENV === 'production',
explicitly throw/log a visible error), so the component uses a safe generated
image rather than a localhost URL.

In `@packages/web/src/app/`[domain]/chat/components/chatName.tsx:
- Around line 50-83: onDeleteChat and onDuplicateChat use different domain
sources which is inconsistent; change onDeleteChat to use params.domain instead
of SINGLE_TENANT_ORG_DOMAIN so both handlers route consistently. Specifically,
in the onDeleteChat callback (where router.push is called) replace the
SINGLE_TENANT_ORG_DOMAIN reference with params.domain, keeping the existing
router.push and router.refresh behavior; ensure params is in the dependency
array if not already.

In `@packages/web/src/app/`[domain]/chat/components/deleteChatDialog.tsx:
- Around line 17-25: The handleDelete callback can leave isLoading true if
onDelete throws; update the handleDelete function to wrap the await onDelete()
call in a try/catch/finally so setIsLoading(false) is always executed (use
finally) and handle errors in catch (e.g., log or show an error state) rather
than letting the exception propagate uncaught; reference the handleDelete
function and the onDelete, setIsLoading, and onOpenChange symbols when making
this change.

In `@packages/web/src/app/`[domain]/chat/components/duplicateChatDialog.tsx:
- Around line 40-48: onSubmit currently calls onDuplicate and sets isLoading
before awaiting, but if onDuplicate rejects the subsequent setIsLoading(false)
and form.reset()/onOpenChange(false) won't run; wrap the await call in a
try/finally so setIsLoading(false) always executes, and optionally add a catch
to handle or report the error (e.g., show a toast) before rethrowing or
returning; update the onSubmit function (referencing onSubmit, formSchema,
setIsLoading, onDuplicate, form.reset, onOpenChange) to use try { const
newChatId = await onDuplicate(data.name); if (newChatId) { form.reset();
onOpenChange(false); } } finally { setIsLoading(false); }.

In `@packages/web/src/app/`[domain]/chat/components/renameChatDialog.tsx:
- Around line 40-49: onSubmit can leave isLoading true if onRename throws; wrap
the await in a try/finally so setIsLoading(false) always runs. Call onRename
inside a try block (capture its boolean result into a local success variable),
call setIsLoading(false) in finally, and only call form.reset() and
onOpenChange(false) when success is true; reference the onSubmit handler,
setIsLoading, onRename, form.reset, and onOpenChange to locate the changes.

In `@packages/web/src/app/`[domain]/chat/components/shareChatPopover.tsx:
- Line 33: The local state currentVisibility initialized with
useState(visibility) in the ShareChatPopover component can go stale when the
visibility prop changes; add a useEffect that watches the visibility prop and
calls setCurrentVisibility(visibility) to keep the local state in sync, ensuring
the popover reflects prop updates (use the existing currentVisibility,
setCurrentVisibility, and visibility identifiers).
- Around line 39-59: The handler handleVisibilityChange can leave isUpdating
true if updateChatVisibility throws; wrap the async call and subsequent logic in
a try/finally so setIsUpdating(false) always runs: in handleVisibilityChange,
setIsUpdating(true) then use try { const response = await
updateChatVisibility(...); /* existing isServiceError/else toast,
setCurrentVisibility, router.refresh */ } finally { setIsUpdating(false); }
while keeping the current references to updateChatVisibility, isServiceError,
setCurrentVisibility, toast, and router.refresh.

In `@packages/web/src/app/login/components/loginForm.tsx`:
- Around line 95-99: The guest "Continue as guest" link uses the user-controlled
callbackUrl directly (in the loginForm component), opening an open-redirect
risk; compute a safeCallbackUrl before rendering (e.g., in the LoginForm
component) by validating callbackUrl is a relative path (starts with "/" but not
"//") or by parsing it and ensuring its origin matches window.location.origin,
and otherwise fallback to "/"; then use that safeCallbackUrl in the Link href
instead of callbackUrl (referencing the existing callbackUrl,
isAnonymousAccessEnabled, and Link usage).

In `@packages/web/src/features/chat/actions.ts`:
- Around line 38-56: isOwnerOfChat currently only checks anonymous ownership
when no authenticated user is present, which misses authenticated users who
still have unclaimed anonymous chats; update isOwnerOfChat (and use
getAnonymousId) to always check the anonymousCreatorId fallback regardless of
whether a User is passed (but keep the existing authenticated check first so
createdById takes precedence), i.e., after verifying chat.createdById ===
user.id, if chat.anonymousCreatorId call getAnonymousId() and compare to
chat.anonymousCreatorId and return true on match; this ensures callers like
updateChatMessages will correctly recognize an owner's unclaimed anonymous chats
without changing the ownership precedence.

In `@packages/web/src/features/chat/components/chatThread/signInPromptBanner.tsx`:
- Around line 53-55: The banner is being hidden on every stream because
isStreaming is combined unconditionally in the render-gate; change that so
streaming only blocks the initial show. Update the conditional in
signInPromptBanner.tsx that currently uses isStreaming to instead gate it with
hasShownOnce (e.g. replace isStreaming with isStreaming && !hasShownOnce or
remove isStreaming from the top-level OR and only check (isStreaming &&
!hasShownOnce)), keeping the other checks (isAuthenticated, isOwner,
isDismissed, hasMessages, hasShownOnce) intact.
🧹 Nitpick comments (10)
packages/web/src/app/[domain]/chat/components/shareChatPopover.tsx (1)

61-66: navigator.clipboard.writeText can reject — consider handling the error.

The Clipboard API may fail due to permissions or insecure contexts. A rejected promise here would be an unhandled rejection.

Proposed fix
     const handleCopyLink = useCallback(() => {
-        navigator.clipboard.writeText(window.location.href);
-        toast({
-            description: "✅ Link copied to clipboard",
-        });
+        navigator.clipboard.writeText(window.location.href).then(() => {
+            toast({
+                description: "✅ Link copied to clipboard",
+            });
+        }).catch(() => {
+            toast({
+                description: "Failed to copy link",
+                variant: "destructive",
+            });
+        });
     }, [toast]);
packages/web/src/app/[domain]/chat/components/duplicateChatDialog.tsx (1)

23-25: formSchema is recreated on every render.

Move it outside the component to avoid re-creating the schema (and a new zodResolver reference) on each render.

Proposed fix
+const formSchema = z.object({
+    name: z.string().min(1),
+});
+
 export const DuplicateChatDialog = ({ isOpen, onOpenChange, onDuplicate, currentName }: DuplicateChatDialogProps) => {
     const [isLoading, setIsLoading] = useState(false);
-
-    const formSchema = z.object({
-        name: z.string().min(1),
-    });
packages/db/prisma/schema.prisma (1)

447-449: No constraint ensuring at least one creator identifier is present.

Both createdById and anonymousCreatorId are nullable. A chat could end up with neither set, making it an unowned orphan. If this is intentional (e.g., during migration), consider adding an application-level invariant or a database check constraint to prevent it long-term.

packages/db/prisma/migrations/20260213030000_add_chat_anonymous_creator_id/migration.sql (1)

1-2: Add an index on anonymousCreatorId to support the claimAnonymousChats flow.

The migration is missing an index on this column. The claimAnonymousChats function (packages/web/src/features/chat/actions.ts:356–360) filters chats by anonymousCreatorId in an updateMany query, which will scan the table without an index. Add:

Suggested index addition
CREATE INDEX "Chat_anonymousCreatorId_idx" ON "Chat"("anonymousCreatorId");

Or, if filtering is typically combined with orgId, consider a composite index:

CREATE INDEX "Chat_orgId_anonymousCreatorId_idx" ON "Chat"("orgId", "anonymousCreatorId");
packages/web/src/app/[domain]/chat/components/chatName.tsx (1)

108-124: Dialogs are rendered even for non-owners.

RenameChatDialog, DeleteChatDialog, and DuplicateChatDialog are always rendered in the DOM regardless of isOwner. They can't be opened by non-owners (since the trigger is gated by isOwner), so this is functionally safe — but it's unnecessary DOM for the non-owner case.

packages/web/src/features/chat/components/chatThread/chatThread.tsx (2)

307-323: Duplicated onDuplicate logic across three components.

This onDuplicate handler (calling duplicateChat, handling errors with toast, navigating to the new chat) is nearly identical to the one in chatName.tsx (lines 68–83) and chatSidePanel.tsx (lines 120–139). Consider extracting a shared useDuplicateChat hook to reduce duplication.


22-31: Duplicate import source: next/navigation is imported on both line 22 and line 31.

Consolidate these into a single import statement.

Proposed fix
-import { useRouter } from 'next/navigation';
+import { useRouter, useParams } from 'next/navigation';
 ...
-import { useParams } from 'next/navigation';
packages/web/src/app/[domain]/chat/[id]/page.tsx (2)

28-80: generateMetadata duplicates the chat DB lookup done by getChatInfo in the page function.

Both generateMetadata (line 38) and getChatInfo (line 95) fetch the same chat record. Since these are raw Prisma calls (not fetch), Next.js request deduplication won't apply. Consider wrapping the chat lookup in React.cache() to deduplicate across generateMetadata and the page function within the same request.

Additionally, the as unknown as SBChatMessage[] cast on line 53 has no runtime validation. If the stored JSON shape ever diverges from SBChatMessage, the .find() and .parts access on lines 54–59 could throw at runtime with an unhelpful error.


86-90: claimAnonymousChats() result is silently discarded.

If claiming fails (e.g., DB error wrapped in a service error), the page proceeds normally. This is likely intentional as a best-effort operation, but a failed claim means the user won't see ownership of their anonymous chats. Consider at minimum logging the failure.

     if (session) {
-        await claimAnonymousChats();
+        const claimResult = await claimAnonymousChats();
+        if (isServiceError(claimResult)) {
+            console.error('Failed to claim anonymous chats:', claimResult.message);
+        }
     }
packages/web/src/features/chat/actions.ts (1)

348-369: claimAnonymousChats does not clear anonymousCreatorId after claiming.

After transferring ownership via createdById, the anonymousCreatorId remains on the chat record. This is functionally harmless today because:

  1. The anonymous ID is a cookie-based UUID (collision-resistant)
  2. isOwnerOfChat checks createdById first for authenticated users

However, if you later add additional anonymous-ownership semantics or auditing, stale anonymousCreatorId values could cause confusion. Consider nulling the field during claim for clean data hygiene.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In `@CLAUDE.md`:
- Around line 25-35: Add a language specifier to the fenced code block showing
file name examples to satisfy markdownlint MD040; locate the triple-backtick
block that contains the "Correct/Incorrect" file name list (the block with
shareChatPopover.tsx, UserAvatar.tsx, etc.) and change the opening fence from
``` to ```text (or ```plaintext) so the block is treated as plain text.

In `@packages/db/tools/scripts/inject-user-data.ts`:
- Line 34: Remove the redundant confirmAction() invocation from the individual
scripts (e.g., inject-user-data.ts, migrate-duplicate-connections.ts,
inject-audit-data.ts); the confirmation is already handled in scriptRunner.ts
before calling selectedScript.run(prisma). Edit inject-user-data.ts to delete
the standalone confirmAction() call so the script relies on scriptRunner.ts’s
confirmation flow (refer to confirmAction() and selectedScript.run(prisma) in
scriptRunner.ts to verify the centralized confirmation behavior).

In `@packages/web/src/app/`[domain]/chat/[id]/page.tsx:
- Around line 86-90: claimAnonymousChats() is being awaited but its result/error
is ignored, so failures can leave chat ownership incorrect before getChatInfo()
runs; update the code around the session check to inspect the return value or
catch errors from claimAnonymousChats() (referencing claimAnonymousChats and
getChatInfo) and handle failures by either logging the error with context via
the existing logger and/or short-circuiting (throw or return an error page) so
getChatInfo() does not proceed on a claim failure; ensure any caught error
includes the original error details in the log and a clear message that claiming
anonymous chats failed.
- Around line 52-64: The code unsafely casts chat.messages to SBChatMessage[]
and accesses properties that can throw at runtime (this runs in
generateMetadata); replace the direct cast and blind property access by
defensively validating chat.messages is an array and each item matches the
minimal shape before use: check Array.isArray(chat.messages), filter/map items
where typeof m === 'object' && m?.role === 'user' && Array.isArray(m.parts',
then find a part where typeof p === 'object' && p.type === 'text' && typeof
p.text === 'string'; use optional chaining and fallbacks to produce description
(keep 'A chat on Sourcebot' or 'Untitled chat' when validation fails). Reference
symbols: chat.messages, SBChatMessage, firstUserMessage, description,
generateMetadata.

In `@packages/web/src/app/`[domain]/chat/components/shareChatPopover/index.tsx:
- Around line 76-96: The onShareChatWithUsers callback closes over
sharedWithUsers causing stale updates; change the
setSharedWithUsers([...sharedWithUsers, ...users]) call to the functional
updater form setSharedWithUsers(prev => [...prev, ...users]) to avoid race
conditions, and update the useCallback dependency array to remove
sharedWithUsers (keeping chatId and toast only) so the hook doesn't depend on
the mutable array reference.
- Around line 58-73: The onUnshareChatWithUser callback captures sharedWithUsers
and can suffer from a stale-closure when removals happen rapidly; update
setSharedWithUsers to use the functional updater form (prev => prev.filter(u =>
u.id !== userId)) so each removal derives from the latest state, and remove
sharedWithUsers from the useCallback dependency array (keep chatId and toast) to
avoid recreating the callback unnecessarily.

In
`@packages/web/src/app/`[domain]/chat/components/shareChatPopover/invitePanel.tsx:
- Around line 183-187: The click handler sets isInviting via setIsInviting(true)
but never resets it if onShareChatWithUsers throws; update the onClick async
function that calls onShareChatWithUsers(selectedUsers) to use a try/finally:
setIsInviting(true) before the try, await onShareChatWithUsers(...) inside the
try, and call setIsInviting(false) in the finally block so isInviting is always
cleared even on errors.

In
`@packages/web/src/app/`[domain]/chat/components/shareChatPopover/shareSettings.tsx:
- Around line 153-159: The onValueChange handler for the Select currently sets
setIsVisibilityUpdating(true) then awaits onVisibilityChange but does not guard
against exceptions, so setIsVisibilityUpdating(false) can be skipped; wrap the
await call in a try/finally (or try/catch/finally) inside the onValueChange
callback in shareSettings.tsx so that setIsVisibilityUpdating(false) always runs
regardless of whether onVisibilityChange(value as ChatVisibility) throws,
preserving proper UI state for the Select component.
- Around line 45-50: handleCopyLink currently calls
navigator.clipboard.writeText without handling its returned promise; update the
handleCopyLink function to be async (or return a promise) and await
navigator.clipboard.writeText(window.location.href) inside a try/catch, first
checking for navigator.clipboard existence, then show the existing success toast
on resolve and a failure toast (including a brief error message) on reject;
ensure you reference handleCopyLink and navigator.clipboard.writeText and keep
the toast dependency usage intact.

In `@packages/web/src/features/chat/actions.ts`:
- Around line 544-551: The prisma.chatAccess.delete call in the action that
removes a user's access can throw Prisma P2025 if the record is already gone;
update the code in the relevant action to either use
prisma.chatAccess.deleteMany({ where: { chatId, userId } }) so deletion is
idempotent (returns count without throwing) or wrap the
prisma.chatAccess.delete(...) call in a try/catch and ignore P2025 errors;
reference the prisma.chatAccess.delete usage in this action to locate and
replace/guard it accordingly.
🧹 Nitpick comments (10)
packages/db/prisma/schema.prisma (1)

467-479: Consider adding an index on userId for ChatAccess.

The unique constraint on (chatId, userId) supports lookups by chatId first. However, queries like "find all chats shared with user X" (e.g., _hasSharedAccess in actions.ts uses chatId_userId which is fine, but a future chat-listing feature would filter by userId alone) would benefit from an explicit @@index([userId]). At scale this could become a sequential scan.

Suggested addition
   @@unique([chatId, userId])
+  @@index([userId])
 }
packages/web/src/app/api/(server)/chat/[chatId]/searchMembers/route.ts (1)

82-105: Prefer select over include to avoid fetching sensitive columns like hashedPassword.

include: { user: true } fetches all User columns (including hashedPassword) into server memory, even though only id, email, name, and image are used in the response mapping. Using select reduces the data transferred from the DB and avoids holding sensitive fields in memory.

Suggested change
         const members = await prisma.userToOrg.findMany({
             where: {
                 orgId: org.id,
                 userId: {
                     notIn: Array.from(excludeUserIds),
                 },
                 user: {
                     OR: [
                         { name: { contains: query, mode: 'insensitive' } },
                         { email: { contains: query, mode: 'insensitive' } },
                     ],
                 },
             },
-            include: {
-                user: true,
+            select: {
+                userId: true,
+                user: {
+                    select: {
+                        id: true,
+                        email: true,
+                        name: true,
+                        image: true,
+                    },
+                },
             },
         });
packages/web/src/app/[domain]/chat/components/shareChatPopover/invitePanel.tsx (1)

49-52: Forward AbortSignal from react-query's queryFn context to enable request cancellation.

@tanstack/react-query passes a signal via the queryFn context, which can cancel in-flight requests when the query key changes (e.g., during rapid typing). Currently the signal parameter accepted by searchChatShareableMembers is unused.

Suggested fix
 const { data: searchResults, isPending, isError } = useQuery<SearchChatShareableMembersResponse>({
     queryKey: ['search-chat-shareable-members', chatId, debouncedSearchQuery],
-    queryFn: () => unwrapServiceError(searchChatShareableMembers({ chatId, query: debouncedSearchQuery}))
+    queryFn: ({ signal }) => unwrapServiceError(searchChatShareableMembers({ chatId, query: debouncedSearchQuery}, signal))
 })
packages/web/src/features/chat/actions.ts (2)

250-266: Inconsistent authorization: uses createdById directly instead of _isOwnerOfChat.

updateChatVisibility checks chat.createdById !== user.id while updateChatMessages, updateChatName, etc., use _isOwnerOfChat. Since this function uses withAuthV2 (authenticated only), the result is the same today, but the inconsistency makes it fragile if _isOwnerOfChat logic evolves (e.g., to support co-owners or delegated admin). The same applies to deleteChat (line 348).

Suggested fix
-        // Only the creator can change visibility
-        if (chat.createdById !== user.id) {
+        const isOwner = await _isOwnerOfChat(chat, user);
+        if (!isOwner) {
             return notFound();
         }

370-391: claimAnonymousChats doesn't clear anonymousCreatorId after claiming.

After transferring ownership by setting createdById, the anonymousCreatorId remains. While the current _isOwnerOfChat logic guards against misuse (the !user check), clearing it would be cleaner and more defensive against future changes.

Suggested addition
         const result = await prisma.chat.updateMany({
             where: {
                 orgId: org.id,
                 anonymousCreatorId: anonymousId,
                 createdById: null,
             },
             data: {
                 createdById: user.id,
+                anonymousCreatorId: null,
             },
         });
CLAUDE.md (1)

68-85: apiHandler wrapper used without introduction.

The apiHandler function appears in the API route examples (Lines 68, 99, 171) but is never described — no import path, purpose, or explanation of what it provides (e.g., error boundary, logging). Consider adding a brief note or import statement so developers know where it comes from.

packages/web/src/app/[domain]/chat/[id]/page.tsx (1)

92-96: Independent data fetches could be parallelized.

getConfiguredLanguageModelsInfo, getRepos, getSearchContexts, and getChatInfo (Lines 92-95) are independent of each other (only claimAnonymousChats needed to precede getChatInfo). Running them with Promise.all would reduce waterfall latency.

Proposed refactor
-    const languageModels = await getConfiguredLanguageModelsInfo();
-    const repos = await getRepos();
-    const searchContexts = await getSearchContexts(params.domain);
-    const chatInfo = await getChatInfo({ chatId: params.id });
-    const chatHistory = session ? await getUserChatHistory() : [];
+    const [languageModels, repos, searchContexts, chatInfo, chatHistory] = await Promise.all([
+        getConfiguredLanguageModelsInfo(),
+        getRepos(),
+        getSearchContexts(params.domain),
+        getChatInfo({ chatId: params.id }),
+        session ? getUserChatHistory() : Promise.resolve([]),
+    ]);
packages/web/src/app/[domain]/chat/components/shareChatPopover/shareSettings.tsx (2)

52-60: getInitials is duplicated in invitePanel.tsx.

The exact same helper exists in invitePanel.tsx (visible in the relevant code snippets). Extract it to a shared utility to keep things DRY.


186-194: Placeholder href="#" on the info link.

The @todo: link to docs comment (Line 187) leaves a dead link in the shipped UI. Clicking it scrolls to the top of the page, which is confusing. Consider hiding the link entirely until the docs URL is available, or using href with a real URL.

Do you want me to open an issue to track adding the documentation link?

packages/web/src/app/[domain]/chat/components/shareChatPopover/index.tsx (1)

98-106: Hardcoded 100ms timeout for view reset is fragile.

The delay relies on the popover's close animation completing within 100ms. If the animation duration changes or varies across devices, the view could reset visibly before or after the animation ends. Consider using the popover's onCloseAutoFocus or an onAnimationEnd callback instead, or at minimum make the delay a named constant.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@packages/web/src/app/`[domain]/chat/components/shareChatPopover/shareSettings.tsx:
- Around line 138-146: The removal click handler can leave user.id in
removingUserIds if onRemoveSharedWithUser throws; wrap the await call in a
try/finally so setRemovingUserIds always removes user.id afterwards. Concretely,
inside the onClick handler that references setRemovingUserIds and
onRemoveSharedWithUser (and user.id), add try { await
onRemoveSharedWithUser(user.id); } finally { setRemovingUserIds(prev => { const
next = new Set(prev); next.delete(user.id); return next; }); } so the
spinner/button state is cleared regardless of errors.

In `@packages/web/src/app/api/`(server)/ee/chat/[chatId]/searchMembers/route.ts:
- Around line 36-50: The two guard branches return StatusCodes.FORBIDDEN while
using ErrorCode.NOT_FOUND, which is inconsistent; pick one approach and make
codes match: either (A) keep StatusCodes.FORBIDDEN and replace
ErrorCode.NOT_FOUND with a matching forbidden error code (e.g.,
ErrorCode.FORBIDDEN) in the serviceErrorResponse calls, or (B) if the intent is
to hide the endpoint, change StatusCodes.FORBIDDEN to StatusCodes.NOT_FOUND and
keep ErrorCode.NOT_FOUND. Update both branches that reference
env.EXPERIMENT_ASK_GH_ENABLED and hasEntitlement('chat-sharing') so
serviceErrorResponse uses consistent status and error code pairs (adjust
StatusCodes.* and ErrorCode.* accordingly).
🧹 Nitpick comments (3)
packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts (1)

104-120: Unbounded query results for large organizations.

The comment on Line 26 acknowledges this is non-paginated, but findMany with no take limit on a contains search (especially with an empty default query on Line 13) will return every org member minus exclusions. For organizations with thousands of members, this could be slow and transfer a large payload.

Consider adding a reasonable take limit (e.g., 25–50) to cap results.

Proposed fix
         const members = await prisma.userToOrg.findMany({
             where: {
                 orgId: org.id,
                 userId: {
                     notIn: Array.from(excludeUserIds),
                 },
                 user: {
                     OR: [
                         { name: { contains: query, mode: 'insensitive' } },
                         { email: { contains: query, mode: 'insensitive' } },
                     ],
                 },
             },
             include: {
                 user: true,
             },
+            take: 50,
         });
packages/web/src/app/[domain]/chat/components/shareChatPopover/ee/invitePanel.tsx (2)

124-133: Side effect inside a state updater function.

setSearchQuery('') on Line 130 is called inside the setSelectedUsers updater callback. State updater functions should be pure — calling another state setter inside one is an anti-pattern in React that can lead to unpredictable render batching behavior.

Proposed fix — move the side effect outside the updater
 onClick={() => {
-    setSelectedUsers(prev => {
-        const isSelected = prev.some(u => u.id === user.id);
-        if (isSelected) {
-            return prev.filter(u => u.id !== user.id);
-        } else {
-            setSearchQuery('');
-            return [...prev, user];
-        }
-    });
+    const isSelected = selectedUsers.some(u => u.id === user.id);
+    if (isSelected) {
+        setSelectedUsers(prev => prev.filter(u => u.id !== user.id));
+    } else {
+        setSelectedUsers(prev => [...prev, user]);
+        setSearchQuery('');
+    }
 }}

36-44: Duplicated getInitials helper across components.

This exact function is duplicated in shareSettings.tsx (Lines 57–65). Consider extracting it into a shared utility.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

@brendan-kellam brendan-kellam merged commit 85e2107 into main Feb 18, 2026
11 checks passed
@brendan-kellam brendan-kellam deleted the bkellam/ask-share-links-SOU-65 branch February 18, 2026 01:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FR] Allow anonymous user to sign in to save their chat [FR] Share links for Ask Sourcebot chat sessions

1 participant

Comments