Merge pull request #697 from CREDO23/implement-surfsense-docs-mentions

[Feat] Capture Google profile data, add user profile settings & document mentions picker improvements
This commit is contained in:
Rohan Verma 2026-01-14 13:19:10 -08:00 committed by GitHub
commit f3f52170a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1054 additions and 613 deletions

View file

@ -0,0 +1,72 @@
"""Add display_name and avatar_url columns to user table
This migration adds:
- display_name column for user's full name from OAuth
- avatar_url column for user's profile picture URL from OAuth
Revision ID: 62
Revises: 61
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "62"
down_revision: str | None = "61"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add display_name and avatar_url columns to user table."""
# Add display_name column (nullable for existing users)
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'user' AND column_name = 'display_name'
) THEN
ALTER TABLE "user"
ADD COLUMN display_name VARCHAR;
END IF;
END$$;
"""
)
# Add avatar_url column (nullable for existing users)
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'user' AND column_name = 'avatar_url'
) THEN
ALTER TABLE "user"
ADD COLUMN avatar_url VARCHAR;
END IF;
END$$;
"""
)
def downgrade() -> None:
"""Remove display_name and avatar_url columns from user table."""
op.execute(
"""
ALTER TABLE "user"
DROP COLUMN IF EXISTS avatar_url;
"""
)
op.execute(
"""
ALTER TABLE "user"
DROP COLUMN IF EXISTS display_name;
"""
)

View file

@ -0,0 +1,47 @@
"""Add author_id column to new_chat_messages table
Revision ID: 63
Revises: 62
"""
from collections.abc import Sequence
from alembic import op
revision: str = "63"
down_revision: str | None = "62"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add author_id column to new_chat_messages table."""
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'new_chat_messages' AND column_name = 'author_id'
) THEN
ALTER TABLE new_chat_messages
ADD COLUMN author_id UUID REFERENCES "user"(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_new_chat_messages_author_id
ON new_chat_messages(author_id);
END IF;
END$$;
"""
)
def downgrade() -> None:
"""Remove author_id column from new_chat_messages table."""
op.execute(
"""
DROP INDEX IF EXISTS ix_new_chat_messages_author_id;
ALTER TABLE new_chat_messages
DROP COLUMN IF EXISTS author_id;
"""
)

View file

@ -413,8 +413,17 @@ class NewChatMessage(BaseModel, TimestampMixin):
index=True, index=True,
) )
# Relationship # Track who sent this message (for shared chats)
author_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Relationships
thread = relationship("NewChatThread", back_populates="messages") thread = relationship("NewChatThread", back_populates="messages")
author = relationship("User")
class Document(BaseModel, TimestampMixin): class Document(BaseModel, TimestampMixin):
@ -876,6 +885,10 @@ if config.AUTH_TYPE == "GOOGLE":
) )
pages_used = Column(Integer, nullable=False, default=0, server_default="0") pages_used = Column(Integer, nullable=False, default=0, server_default="0")
# User profile from OAuth
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
else: else:
class User(SQLAlchemyBaseUserTableUUID, Base): class User(SQLAlchemyBaseUserTableUUID, Base):
@ -909,6 +922,10 @@ else:
) )
pages_used = Column(Integer, nullable=False, default=0, server_default="0") pages_used = Column(Integer, nullable=False, default=0, server_default="0")
# User profile (can be set manually for non-OAuth users)
display_name = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
engine = create_async_engine(DATABASE_URL) engine = create_async_engine(DATABASE_URL)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False) async_session_maker = async_sessionmaker(engine, expire_on_commit=False)

View file

@ -411,11 +411,9 @@ async def get_thread_messages(
Requires CHATS_READ permission. Requires CHATS_READ permission.
""" """
try: try:
# Get thread with messages # Get thread first
result = await session.execute( result = await session.execute(
select(NewChatThread) select(NewChatThread).filter(NewChatThread.id == thread_id)
.options(selectinload(NewChatThread.messages))
.filter(NewChatThread.id == thread_id)
) )
thread = result.scalars().first() thread = result.scalars().first()
@ -434,6 +432,15 @@ async def get_thread_messages(
# Check thread-level access based on visibility # Check thread-level access based on visibility
await check_thread_access(session, thread, user) await check_thread_access(session, thread, user)
# Get messages with their authors loaded
messages_result = await session.execute(
select(NewChatMessage)
.options(selectinload(NewChatMessage.author))
.filter(NewChatMessage.thread_id == thread_id)
.order_by(NewChatMessage.created_at)
)
db_messages = messages_result.scalars().all()
# Return messages in the format expected by assistant-ui # Return messages in the format expected by assistant-ui
messages = [ messages = [
NewChatMessageRead( NewChatMessageRead(
@ -442,8 +449,11 @@ async def get_thread_messages(
role=msg.role, role=msg.role,
content=msg.content, content=msg.content,
created_at=msg.created_at, created_at=msg.created_at,
author_id=msg.author_id,
author_display_name=msg.author.display_name if msg.author else None,
author_avatar_url=msg.author.avatar_url if msg.author else None,
) )
for msg in thread.messages for msg in db_messages
] ]
return ThreadHistoryLoadResponse(messages=messages) return ThreadHistoryLoadResponse(messages=messages)
@ -782,6 +792,7 @@ async def append_message(
thread_id=thread_id, thread_id=thread_id,
role=message_role, role=message_role,
content=message.content, content=message.content,
author_id=user.id,
) )
session.add(db_message) session.add(db_message)

View file

@ -38,6 +38,9 @@ class NewChatMessageRead(NewChatMessageBase, IDModel, TimestampModel):
"""Schema for reading a message.""" """Schema for reading a message."""
thread_id: int thread_id: int
author_id: UUID | None = None
author_display_name: str | None = None
author_avatar_url: str | None = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View file

@ -6,6 +6,8 @@ from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]): class UserRead(schemas.BaseUser[uuid.UUID]):
pages_limit: int pages_limit: int
pages_used: int pages_used: int
display_name: str | None = None
avatar_url: str | None = None
class UserCreate(schemas.BaseUserCreate): class UserCreate(schemas.BaseUserCreate):
@ -13,4 +15,5 @@ class UserCreate(schemas.BaseUserCreate):
class UserUpdate(schemas.BaseUserUpdate): class UserUpdate(schemas.BaseUserUpdate):
pass display_name: str | None = None
avatar_url: str | None = None

View file

@ -1,6 +1,7 @@
import logging import logging
import uuid import uuid
import httpx
from fastapi import Depends, Request, Response from fastapi import Depends, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
@ -46,6 +47,71 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SECRET reset_password_token_secret = SECRET
verification_token_secret = SECRET verification_token_secret = SECRET
async def oauth_callback(
self,
oauth_name: str,
access_token: str,
account_id: str,
account_email: str,
expires_at: int | None = None,
refresh_token: str | None = None,
request: Request | None = None,
*,
associate_by_email: bool = False,
is_verified_by_default: bool = False,
) -> User:
"""
Override OAuth callback to capture Google profile data (name, avatar).
"""
# Call parent implementation to create/get user
user = await super().oauth_callback(
oauth_name,
access_token,
account_id,
account_email,
expires_at,
refresh_token,
request,
associate_by_email=associate_by_email,
is_verified_by_default=is_verified_by_default,
)
# Fetch and store Google profile data if not already set
if oauth_name == "google" and (not user.display_name or not user.avatar_url):
try:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://people.googleapis.com/v1/people/me",
params={"personFields": "names,photos"},
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
profile = response.json()
update_dict = {}
# Extract name from names array
names = profile.get("names", [])
if not user.display_name and names:
display_name = names[0].get("displayName")
if display_name:
update_dict["display_name"] = display_name
# Extract photo URL from photos array
photos = profile.get("photos", [])
if not user.avatar_url and photos:
photo_url = photos[0].get("url")
if photo_url:
update_dict["avatar_url"] = photo_url
if update_dict:
user = await self.user_db.update(user, update_dict)
except Exception as e:
logger.warning(f"Failed to fetch Google profile: {e}")
return user
async def on_after_register(self, user: User, request: Request | None = None): async def on_after_register(self, user: User, request: Request | None = None):
""" """
Called after a user registers. Creates a default search space for the user Called after a user registers. Creates a default search space for the user

View file

@ -79,25 +79,17 @@ export function DocumentsTableShell({
[documents, sortKey, sortDesc] [documents, sortKey, sortDesc]
); );
// Filter out SURFSENSE_DOCS for selection purposes const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id));
const selectableDocs = React.useMemo( const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
() => sorted.filter((d) => d.document_type !== "SURFSENSE_DOCS"),
[sorted]
);
const allSelectedOnPage =
selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id));
const someSelectedOnPage =
selectableDocs.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
const toggleAll = (checked: boolean) => { const toggleAll = (checked: boolean) => {
const next = new Set(selectedIds); const next = new Set(selectedIds);
if (checked) if (checked)
selectableDocs.forEach((d) => { sorted.forEach((d) => {
next.add(d.id); next.add(d.id);
}); });
else else
selectableDocs.forEach((d) => { sorted.forEach((d) => {
next.delete(d.id); next.delete(d.id);
}); });
setSelectedIds(next); setSelectedIds(next);
@ -238,10 +230,9 @@ export function DocumentsTableShell({
const icon = getDocumentTypeIcon(doc.document_type); const icon = getDocumentTypeIcon(doc.document_type);
const title = doc.title; const title = doc.title;
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
const isSurfsenseDoc = doc.document_type === "SURFSENSE_DOCS";
return ( return (
<motion.tr <motion.tr
key={`${doc.document_type}-${doc.id}`} key={doc.id}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ animate={{
opacity: 1, opacity: 1,
@ -258,9 +249,8 @@ export function DocumentsTableShell({
> >
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">
<Checkbox <Checkbox
checked={selectedIds.has(doc.id) && !isSurfsenseDoc} checked={selectedIds.has(doc.id)}
onCheckedChange={(v) => !isSurfsenseDoc && toggleOne(doc.id, !!v)} onCheckedChange={(v) => toggleOne(doc.id, !!v)}
disabled={isSurfsenseDoc}
aria-label="Select row" aria-label="Select row"
/> />
</TableCell> </TableCell>

View file

@ -20,7 +20,7 @@ import { DocumentsFilters } from "./components/DocumentsFilters";
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
import { PaginationControls } from "./components/PaginationControls"; import { PaginationControls } from "./components/PaginationControls";
import { ProcessingIndicator } from "./components/ProcessingIndicator"; import { ProcessingIndicator } from "./components/ProcessingIndicator";
import type { ColumnVisibility, Document } from "./components/types"; import type { ColumnVisibility } from "./components/types";
function useDebounced<T>(value: T, delay = 250) { function useDebounced<T>(value: T, delay = 250) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
@ -60,39 +60,30 @@ export default function DocumentsTable() {
const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom); const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
// Filter out SURFSENSE_DOCS from active types for regular documents API // Build query parameters for fetching documents
const regularDocumentTypes = useMemo(
() => activeTypes.filter((t) => t !== "SURFSENSE_DOCS"),
[activeTypes]
);
// Check if only SURFSENSE_DOCS is selected (skip regular docs query)
const onlySurfsenseDocsSelected = activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS";
// Build query parameters for fetching documents (excluding SURFSENSE_DOCS type)
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: pageSize,
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), ...(activeTypes.length > 0 && { document_types: activeTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes] [searchSpaceId, pageIndex, pageSize, activeTypes]
); );
// Build search query parameters (excluding SURFSENSE_DOCS type) // Build search query parameters
const searchQueryParams = useMemo( const searchQueryParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: pageSize,
title: debouncedSearch.trim(), title: debouncedSearch.trim(),
...(regularDocumentTypes.length > 0 && { document_types: regularDocumentTypes }), ...(activeTypes.length > 0 && { document_types: activeTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, regularDocumentTypes, debouncedSearch] [searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch]
); );
// Use query for fetching documents (disabled when only SURFSENSE_DOCS is selected) // Use query for fetching documents
const { const {
data: documentsResponse, data: documentsResponse,
isLoading: isDocumentsLoading, isLoading: isDocumentsLoading,
@ -102,10 +93,10 @@ export default function DocumentsTable() {
queryKey: cacheKeys.documents.globalQueryParams(queryParams), queryKey: cacheKeys.documents.globalQueryParams(queryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams }), queryFn: () => documentsApiService.getDocuments({ queryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !debouncedSearch.trim() && !onlySurfsenseDocsSelected, enabled: !!searchSpaceId && !debouncedSearch.trim(),
}); });
// Use query for searching documents (disabled when only SURFSENSE_DOCS is selected) // Use query for searching documents
const { const {
data: searchResponse, data: searchResponse,
isLoading: isSearchLoading, isLoading: isSearchLoading,
@ -115,114 +106,20 @@ export default function DocumentsTable() {
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !!debouncedSearch.trim() && !onlySurfsenseDocsSelected, enabled: !!searchSpaceId && !!debouncedSearch.trim(),
}); });
// Determine if we should show SurfSense docs (when no type filter or SURFSENSE_DOCS is selected)
const showSurfsenseDocs =
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
// Use query for fetching SurfSense docs
const {
data: surfsenseDocsResponse,
isLoading: isSurfsenseDocsLoading,
refetch: refetchSurfsenseDocs,
} = useQuery({
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
queryFn: () =>
documentsApiService.getSurfsenseDocs({
queryParams: {
page: pageIndex,
page_size: pageSize,
title: debouncedSearch.trim() || undefined,
},
}),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: showSurfsenseDocs,
});
// Transform SurfSense docs to match the Document type
const surfsenseDocsAsDocuments: Document[] = useMemo(() => {
if (!surfsenseDocsResponse?.items) return [];
return surfsenseDocsResponse.items.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: "SURFSENSE_DOCS",
document_metadata: { source: doc.source },
content: doc.content,
created_at: doc.created_at || doc.updated_at || new Date().toISOString(),
search_space_id: -1, // Special value for global docs
}));
}, [surfsenseDocsResponse]);
// Merge type counts with SURFSENSE_DOCS count
const typeCounts = useMemo(() => {
const counts = { ...(rawTypeCounts || {}) };
if (surfsenseDocsResponse?.total) {
counts.SURFSENSE_DOCS = surfsenseDocsResponse.total;
}
return counts;
}, [rawTypeCounts, surfsenseDocsResponse?.total]);
// Extract documents and total based on search state // Extract documents and total based on search state
const regularDocuments = debouncedSearch.trim() const documents = debouncedSearch.trim()
? searchResponse?.items || [] ? searchResponse?.items || []
: documentsResponse?.items || []; : documentsResponse?.items || [];
const regularTotal = debouncedSearch.trim() const total = debouncedSearch.trim() ? searchResponse?.total || 0 : documentsResponse?.total || 0;
? searchResponse?.total || 0
: documentsResponse?.total || 0;
// Merge regular documents with SurfSense docs const loading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
const documents = useMemo(() => { const error = debouncedSearch.trim() ? searchError : documentsError;
// If filtering by type and not including SURFSENSE_DOCS, only show regular docs
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) {
return regularDocuments;
}
// If filtering only by SURFSENSE_DOCS, only show surfsense docs
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
return surfsenseDocsAsDocuments;
}
// Otherwise, merge both (surfsense docs first)
return [...surfsenseDocsAsDocuments, ...regularDocuments];
}, [regularDocuments, surfsenseDocsAsDocuments, activeTypes]);
const total = useMemo(() => { // Display results directly
if (activeTypes.length > 0 && !activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum)) { const displayDocs = documents;
return regularTotal;
}
if (activeTypes.length === 1 && activeTypes[0] === "SURFSENSE_DOCS") {
return surfsenseDocsResponse?.total || 0;
}
return regularTotal + (surfsenseDocsResponse?.total || 0);
}, [regularTotal, surfsenseDocsResponse?.total, activeTypes]);
const loading = useMemo(() => {
// If only SURFSENSE_DOCS selected, only check surfsense loading
if (onlySurfsenseDocsSelected) {
return isSurfsenseDocsLoading;
}
// Otherwise check both regular docs and surfsense docs loading
const regularLoading = debouncedSearch.trim() ? isSearchLoading : isDocumentsLoading;
return regularLoading || (showSurfsenseDocs && isSurfsenseDocsLoading);
}, [
onlySurfsenseDocsSelected,
isSurfsenseDocsLoading,
debouncedSearch,
isSearchLoading,
isDocumentsLoading,
showSurfsenseDocs,
]);
const error = useMemo(() => {
// If only SURFSENSE_DOCS selected, no regular docs errors
if (onlySurfsenseDocsSelected) {
return null;
}
return debouncedSearch.trim() ? searchError : documentsError;
}, [onlySurfsenseDocsSelected, debouncedSearch, searchError, documentsError]);
// Display server-filtered results directly
const displayDocs = documents || [];
const displayTotal = total; const displayTotal = total;
const pageStart = pageIndex * pageSize; const pageStart = pageIndex * pageSize;
const pageEnd = Math.min(pageStart + pageSize, displayTotal); const pageEnd = Math.min(pageStart + pageSize, displayTotal);
@ -242,33 +139,16 @@ export default function DocumentsTable() {
if (isRefreshing) return; if (isRefreshing) return;
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const refetchPromises: Promise<unknown>[] = [];
// Only refetch regular documents if not in "only surfsense docs" mode
if (!onlySurfsenseDocsSelected) {
if (debouncedSearch.trim()) { if (debouncedSearch.trim()) {
refetchPromises.push(refetchSearch()); await refetchSearch();
} else { } else {
refetchPromises.push(refetchDocuments()); await refetchDocuments();
} }
}
if (showSurfsenseDocs) {
refetchPromises.push(refetchSurfsenseDocs());
}
await Promise.all(refetchPromises);
toast.success(t("refresh_success") || "Documents refreshed"); toast.success(t("refresh_success") || "Documents refreshed");
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
}, [ }, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
debouncedSearch,
refetchSearch,
refetchDocuments,
refetchSurfsenseDocs,
showSurfsenseDocs,
onlySurfsenseDocsSelected,
t,
isRefreshing,
]);
// Set up smart polling for active tasks - only polls when tasks are in progress // Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, { const { summary } = useLogsSummary(searchSpaceId, 24, {
@ -385,7 +265,7 @@ export default function DocumentsTable() {
<ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} /> <ProcessingIndicator documentProcessorTasksCount={documentProcessorTasksCount} />
<DocumentsFilters <DocumentsFilters
typeCounts={typeCounts ?? {}} typeCounts={rawTypeCounts ?? {}}
selectedIds={selectedIds} selectedIds={selectedIds}
onSearch={setSearch} onSearch={setSearch}
searchValue={search} searchValue={search}

View file

@ -23,6 +23,7 @@ import {
// extractWriteTodosFromContent, // extractWriteTodosFromContent,
hydratePlanStateAtom, hydratePlanStateAtom,
} from "@/atoms/chat/plan-state.atom"; } from "@/atoms/chat/plan-state.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header"; import { ChatHeader } from "@/components/new-chat/chat-header";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
@ -185,12 +186,25 @@ function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
} }
} }
// Build metadata.custom for author display in shared chats
const metadata = msg.author_id
? {
custom: {
author: {
displayName: msg.author_display_name ?? null,
avatarUrl: msg.author_avatar_url ?? null,
},
},
}
: undefined;
return { return {
id: `msg-${msg.id}`, id: `msg-${msg.id}`,
role: msg.role, role: msg.role,
content, content,
createdAt: new Date(msg.created_at), createdAt: new Date(msg.created_at),
attachments, attachments,
metadata,
}; };
} }
@ -238,6 +252,9 @@ export default function NewChatPage() {
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const hydratePlanState = useSetAtom(hydratePlanStateAtom); const hydratePlanState = useSetAtom(hydratePlanStateAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
// Create the attachment adapter for file processing // Create the attachment adapter for file processing
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []); const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
@ -306,12 +323,6 @@ export default function NewChatPage() {
if (steps.length > 0) { if (steps.length > 0) {
restoredThinkingSteps.set(`msg-${msg.id}`, steps); restoredThinkingSteps.set(`msg-${msg.id}`, steps);
} }
// Hydrate write_todos plan state from persisted tool calls
// Disabled for now
// const writeTodosCalls = extractWriteTodosFromContent(msg.content);
// for (const todoData of writeTodosCalls) {
// hydratePlanState(todoData);
// }
} }
if (msg.role === "user") { if (msg.role === "user") {
const docs = extractMentionedDocuments(msg.content); const docs = extractMentionedDocuments(msg.content);
@ -448,13 +459,27 @@ export default function NewChatPage() {
// Add user message to state // Add user message to state
const userMsgId = `msg-user-${Date.now()}`; const userMsgId = `msg-user-${Date.now()}`;
// Include author metadata for shared chats
const authorMetadata =
currentThread?.visibility === "SEARCH_SPACE" && currentUser
? {
custom: {
author: {
displayName: currentUser.display_name ?? null,
avatarUrl: currentUser.avatar_url ?? null,
},
},
}
: undefined;
const userMessage: ThreadMessageLike = { const userMessage: ThreadMessageLike = {
id: userMsgId, id: userMsgId,
role: "user", role: "user",
content: message.content, content: message.content,
createdAt: new Date(), createdAt: new Date(),
// Include attachments so they can be displayed
attachments: message.attachments || [], attachments: message.attachments || [],
metadata: authorMetadata,
}; };
setMessages((prev) => [...prev, userMessage]); setMessages((prev) => [...prev, userMessage]);
@ -884,6 +909,8 @@ export default function NewChatPage() {
setMentionedDocuments, setMentionedDocuments,
setMessageDocumentsMap, setMessageDocumentsMap,
queryClient, queryClient,
currentThread,
currentUser,
] ]
); );

View file

@ -0,0 +1,123 @@
"use client";
import { Check, Copy, Key, Menu, Shield } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
interface ApiKeyContentProps {
onMenuClick: () => void;
}
export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="api-key-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("api_key_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -0,0 +1,181 @@
"use client";
import { useAtomValue } from "jotai";
import { Loader2, Menu, User } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface ProfileContentProps {
onMenuClick: () => void;
}
function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) {
const [hasError, setHasError] = useState(false);
useEffect(() => {
setHasError(false);
}, [url]);
if (url && !hasError) {
return (
<img
src={url}
alt="Avatar"
className="h-16 w-16 rounded-xl object-cover"
onError={() => setHasError(true)}
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted text-xl font-semibold text-muted-foreground">
{fallback}
</div>
);
}
export function ProfileContent({ onMenuClick }: ProfileContentProps) {
const t = useTranslations("userSettings");
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
useEffect(() => {
if (user) {
setDisplayName(user.display_name || "");
}
}, [user]);
const getInitials = (email: string) => {
const name = email.split("@")[0];
return name.slice(0, 2).toUpperCase();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateUser({
display_name: displayName || null,
});
toast.success(t("profile_saved"));
} catch {
toast.error(t("profile_save_error"));
}
};
const hasChanges = displayName !== (user?.display_name || "");
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="profile-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<User className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("profile_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("profile_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="profile-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>
<AvatarDisplay
url={user?.avatar_url || undefined}
fallback={getInitials(user?.email || "")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
<Input
id="display-name"
type="text"
placeholder={user?.email?.split("@")[0]}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("profile_display_name_hint")}
</p>
</div>
<div className="space-y-2">
<Label>{t("profile_email")}</Label>
<Input type="email" value={user?.email || ""} disabled />
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

View file

@ -0,0 +1,155 @@
"use client";
import { ArrowLeft, ChevronRight, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface SettingsNavItem {
id: string;
label: string;
description: string;
icon: LucideIcon;
}
interface UserSettingsSidebarProps {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
navItems: SettingsNavItem[];
}
export function UserSettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
navItems,
}: UserSettingsSidebarProps) {
const t = useTranslations("userSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose();
};
return (
<>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
<aside
className={cn(
"fixed left-0 top-0 z-50 md:relative md:z-auto",
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
{/* Header with title */}
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onBackToApp}
className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="userSettingsActiveIndicator"
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{item.label}
</p>
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "translate-x-0 text-primary opacity-100"
: "-translate-x-1 text-muted-foreground/40 opacity-0"
)}
/>
</motion.button>
);
})}
</nav>
</aside>
</>
);
}

View file

@ -1,286 +1,27 @@
"use client"; "use client";
import { import { Key, User } from "lucide-react";
ArrowLeft, import { motion } from "motion/react";
Check,
ChevronRight,
Copy,
Key,
type LucideIcon,
Menu,
Shield,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { ApiKeyContent } from "./components/ApiKeyContent";
import { Button } from "@/components/ui/button"; import { ProfileContent } from "./components/ProfileContent";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { UserSettingsSidebar, type SettingsNavItem } from "./components/UserSettingsSidebar";
import { useApiKey } from "@/hooks/use-api-key";
import { cn } from "@/lib/utils";
interface SettingsNavItem {
id: string;
label: string;
description: string;
icon: LucideIcon;
}
function UserSettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
navItems,
}: {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
navItems: SettingsNavItem[];
}) {
const t = useTranslations("userSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose();
};
return (
<>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
<aside
className={cn(
"fixed left-0 top-0 z-50 md:relative md:z-auto",
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
{/* Header with title */}
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onBackToApp}
className="group h-11 justify-start gap-3 px-3 hover:bg-muted"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
{/* Settings Title */}
<div className="px-3">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="userSettingsActiveIndicator"
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{item.label}
</p>
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "translate-x-0 text-primary opacity-100"
: "-translate-x-1 text-muted-foreground/40 opacity-0"
)}
/>
</motion.button>
);
})}
</nav>
</aside>
</>
);
}
function ApiKeyContent({ onMenuClick }: { onMenuClick: () => void }) {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="api-key-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("api_key_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}
export default function UserSettingsPage() { export default function UserSettingsPage() {
const t = useTranslations("userSettings"); const t = useTranslations("userSettings");
const router = useRouter(); const router = useRouter();
const [activeSection, setActiveSection] = useState("api-key"); const [activeSection, setActiveSection] = useState("profile");
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const navItems: SettingsNavItem[] = [ const navItems: SettingsNavItem[] = [
{
id: "profile",
label: t("profile_nav_label"),
description: t("profile_nav_description"),
icon: User,
},
{ {
id: "api-key", id: "api-key",
label: t("api_key_nav_label"), label: t("api_key_nav_label"),
@ -310,6 +51,9 @@ export default function UserSettingsPage() {
onClose={() => setIsSidebarOpen(false)} onClose={() => setIsSidebarOpen(false)}
navItems={navItems} navItems={navItems}
/> />
{activeSection === "profile" && (
<ProfileContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
{activeSection === "api-key" && ( {activeSection === "api-key" && (
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} /> <ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
)} )}

View file

@ -0,0 +1,19 @@
import { atomWithMutation, queryClientAtom } from "jotai-tanstack-query";
import type { UpdateUserRequest } from "@/contracts/types/user.types";
import { userApiService } from "@/lib/apis/user-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const updateUserMutationAtom = atomWithMutation((get) => {
const queryClient = get(queryClientAtom);
return {
mutationKey: cacheKeys.user.current(),
mutationFn: async (request: UpdateUserRequest) => {
return userApiService.updateMe(request);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cacheKeys.user.current() });
},
};
});

View file

@ -119,10 +119,7 @@ const DocumentUploadPopupContent: FC<{
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="px-6 sm:px-12 pb-5 sm:pb-16"> <div className="px-6 sm:px-12 pb-5 sm:pb-16">
<DocumentUploadTab <DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
searchSpaceId={searchSpaceId}
onSuccess={handleSuccess}
/>
</div> </div>
</div> </div>
{/* Bottom fade shadow */} {/* Bottom fade shadow */}

View file

@ -19,9 +19,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
FileText,
Loader2, Loader2,
PencilIcon,
RefreshCwIcon, RefreshCwIcon,
SquareIcon, SquareIcon,
} from "lucide-react"; } from "lucide-react";
@ -31,7 +29,6 @@ import { createPortal } from "react-dom";
import { import {
mentionedDocumentIdsAtom, mentionedDocumentIdsAtom,
mentionedDocumentsAtom, mentionedDocumentsAtom,
messageDocumentsMapAtom,
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { import {
globalNewLLMConfigsAtom, globalNewLLMConfigsAtom,
@ -42,8 +39,8 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { import {
ComposerAddAttachment, ComposerAddAttachment,
ComposerAttachments, ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment"; } from "@/components/assistant-ui/attachment";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { import {
InlineMentionEditor, InlineMentionEditor,
@ -639,69 +636,6 @@ const AssistantActionBar: FC = () => {
); );
}; };
const UserMessage: FC = () => {
const messageId = useAssistantState(({ message }) => message?.id);
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const hasAttachments = useAssistantState(
({ message }) => message?.attachments && message.attachments.length > 0
);
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
data-role="user"
>
<div className="aui-user-message-content-wrapper col-start-2 min-w-0">
{/* Display attachments and mentioned documents */}
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
{/* Attachments (images show as thumbnails, documents as chips) */}
<UserMessageAttachments />
{/* Mentioned documents as chips */}
{mentionedDocs?.map((doc) => (
<span
key={`${doc.document_type}:${doc.id}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
title={doc.title}
>
<FileText className="size-3" />
<span className="max-w-[150px] truncate">{doc.title}</span>
</span>
))}
</div>
)}
{/* Message bubble with action bar positioned relative to it */}
<div className="relative">
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<MessagePrimitive.Parts />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
<UserActionBar />
</div>
</div>
</div>
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
</MessagePrimitive.Root>
);
};
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
};
const EditComposer: FC = () => { const EditComposer: FC = () => {
return ( return (

View file

@ -1,16 +1,54 @@
import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { FileText, PencilIcon } from "lucide-react"; import { FileText, PencilIcon } from "lucide-react";
import type { FC } from "react"; import { type FC, useState } from "react";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { UserMessageAttachments } from "@/components/assistant-ui/attachment"; import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
interface AuthorMetadata {
displayName: string | null;
avatarUrl: string | null;
}
const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
const [hasError, setHasError] = useState(false);
const initials = displayName
? displayName
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
: "U";
if (avatarUrl && !hasError) {
return (
<img
src={avatarUrl}
alt={displayName || "User"}
className="size-8 rounded-full object-cover"
referrerPolicy="no-referrer"
onError={() => setHasError(true)}
/>
);
}
return (
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{initials}
</div>
);
};
export const UserMessage: FC = () => { export const UserMessage: FC = () => {
const messageId = useAssistantState(({ message }) => message?.id); const messageId = useAssistantState(({ message }) => message?.id);
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom); const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined; const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const metadata = useAssistantState(({ message }) => message?.metadata);
const author = metadata?.custom?.author as AuthorMetadata | undefined;
const hasAttachments = useAssistantState( const hasAttachments = useAssistantState(
({ message }) => message?.attachments && message.attachments.length > 0 ({ message }) => message?.attachments && message.attachments.length > 0
); );
@ -20,7 +58,8 @@ export const UserMessage: FC = () => {
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2" className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
data-role="user" data-role="user"
> >
<div className="aui-user-message-content-wrapper col-start-2 min-w-0"> <div className="aui-user-message-content-wrapper col-start-2 min-w-0 flex items-end gap-2">
<div className="flex-1 min-w-0">
{/* Display attachments and mentioned documents */} {/* Display attachments and mentioned documents */}
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && ( {(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end"> <div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
@ -49,6 +88,13 @@ export const UserMessage: FC = () => {
</div> </div>
</div> </div>
</div> </div>
{/* User avatar - only shown in shared chats */}
{author && (
<div className="shrink-0">
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
</div>
)}
</div>
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" /> <BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
</MessagePrimitive.Root> </MessagePrimitive.Root>

View file

@ -354,7 +354,11 @@ export function LayoutDataProvider({
onChatDelete={handleChatDelete} onChatDelete={handleChatDelete}
onViewAllSharedChats={handleViewAllSharedChats} onViewAllSharedChats={handleViewAllSharedChats}
onViewAllPrivateChats={handleViewAllPrivateChats} onViewAllPrivateChats={handleViewAllPrivateChats}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }} user={{
email: user?.email || "",
name: user?.display_name || user?.email?.split("@")[0],
avatarUrl: user?.avatar_url || undefined,
}}
onSettings={handleSettings} onSettings={handleSettings}
onManageMembers={handleManageMembers} onManageMembers={handleManageMembers}
onUserSettings={handleUserSettings} onUserSettings={handleUserSettings}

View file

@ -12,6 +12,7 @@ export interface SearchSpace {
export interface User { export interface User {
email: string; email: string;
name?: string; name?: string;
avatarUrl?: string;
} }
export interface NavItem { export interface NavItem {

View file

@ -61,6 +61,39 @@ function getInitials(email: string): string {
return name.slice(0, 2).toUpperCase(); return name.slice(0, 2).toUpperCase();
} }
/**
* User avatar component - shows image if available, otherwise falls back to initials
*/
function UserAvatar({
avatarUrl,
initials,
bgColor,
}: {
avatarUrl?: string;
initials: string;
bgColor: string;
}) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt="User avatar"
className="h-8 w-8 shrink-0 rounded-lg object-cover"
referrerPolicy="no-referrer"
/>
);
}
return (
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
);
}
export function SidebarUserProfile({ export function SidebarUserProfile({
user, user,
onUserSettings, onUserSettings,
@ -88,12 +121,7 @@ export function SidebarUserProfile({
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)} )}
> >
<div <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
className="flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
<span className="sr-only">{displayName}</span> <span className="sr-only">{displayName}</span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -104,12 +132,7 @@ export function SidebarUserProfile({
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}> <DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{displayName}</p> <p className="truncate text-sm font-medium">{displayName}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p> <p className="truncate text-xs text-muted-foreground">{user.email}</p>
@ -149,13 +172,7 @@ export function SidebarUserProfile({
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)} )}
> >
{/* Avatar */} <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
{/* Name and email */} {/* Name and email */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -171,12 +188,7 @@ export function SidebarUserProfile({
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}> <DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
style={{ backgroundColor: bgColor }}
>
{initials}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{displayName}</p> <p className="truncate text-sm font-medium">{displayName}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p> <p className="truncate text-xs text-muted-foreground">{user.email}</p>

View file

@ -215,6 +215,16 @@ export const DocumentMentionPicker = forwardRef<
isSurfsenseDocsLoading) && isSurfsenseDocsLoading) &&
currentPage === 0; currentPage === 0;
// Split documents into SurfSense docs and user docs for grouped rendering
const surfsenseDocsList = useMemo(
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
[actualDocuments]
);
const userDocsList = useMemo(
() => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"),
[actualDocuments]
);
// Track already selected documents using unique key (document_type:id) to avoid ID collisions // Track already selected documents using unique key (document_type:id) to avoid ID collisions
const selectedKeys = useMemo( const selectedKeys = useMemo(
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)), () => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
@ -324,7 +334,13 @@ export const DocumentMentionPicker = forwardRef<
</div> </div>
) : ( ) : (
<div className="py-1"> <div className="py-1">
{actualDocuments.map((doc) => { {/* SurfSense Documentation Section */}
{surfsenseDocsList.length > 0 && (
<>
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
SurfSense Docs
</div>
{surfsenseDocsList.map((doc) => {
const docKey = `${doc.document_type}:${doc.id}`; const docKey = `${doc.document_type}:${doc.id}`;
const isAlreadySelected = selectedKeys.has(docKey); const isAlreadySelected = selectedKeys.has(docKey);
const selectableIndex = selectableDocuments.findIndex( const selectableIndex = selectableDocuments.findIndex(
@ -354,17 +370,66 @@ export const DocumentMentionPicker = forwardRef<
isHighlighted && "bg-accent" isHighlighted && "bg-accent"
)} )}
> >
{/* Type icon */} <span className="shrink-0 text-muted-foreground text-sm">
<span className="flex-shrink-0 text-muted-foreground text-sm">
{getConnectorIcon(doc.document_type)} {getConnectorIcon(doc.document_type)}
</span> </span>
{/* Title */}
<span className="flex-1 text-sm truncate" title={doc.title}> <span className="flex-1 text-sm truncate" title={doc.title}>
{doc.title} {doc.title}
</span> </span>
</button> </button>
); );
})} })}
</>
)}
{/* User Documents Section */}
{userDocsList.length > 0 && (
<>
<div className="sticky top-0 z-10 px-3 py-2 text-xs font-bold uppercase tracking-wider bg-muted text-foreground/80 border-b border-border">
Your Documents
</div>
{userDocsList.map((doc) => {
const docKey = `${doc.document_type}:${doc.id}`;
const isAlreadySelected = selectedKeys.has(docKey);
const selectableIndex = selectableDocuments.findIndex(
(d) => d.document_type === doc.document_type && d.id === doc.id
);
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
return (
<button
key={docKey}
ref={(el) => {
if (el && selectableIndex >= 0) {
itemRefs.current.set(selectableIndex, el);
}
}}
type="button"
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
}
}}
disabled={isAlreadySelected}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
isHighlighted && "bg-accent"
)}
>
<span className="shrink-0 text-muted-foreground text-sm">
{getConnectorIcon(doc.document_type)}
</span>
<span className="flex-1 text-sm truncate" title={doc.title}>
{doc.title}
</span>
</button>
);
})}
</>
)}
{/* Loading indicator for additional pages */} {/* Loading indicator for additional pages */}
{isLoadingMore && ( {isLoadingMore && (
<div className="flex items-center justify-center py-2"> <div className="flex items-center justify-center py-2">

View file

@ -8,6 +8,8 @@ export const user = z.object({
is_verified: z.boolean(), is_verified: z.boolean(),
pages_limit: z.number(), pages_limit: z.number(),
pages_used: z.number(), pages_used: z.number(),
display_name: z.string().nullish(),
avatar_url: z.string().nullish(),
}); });
/** /**
@ -15,5 +17,20 @@ export const user = z.object({
*/ */
export const getMeResponse = user; export const getMeResponse = user;
/**
* Update current user request
*/
export const updateUserRequest = z.object({
display_name: z.string().nullish(),
avatar_url: z.string().nullish(),
});
/**
* Update current user response
*/
export const updateUserResponse = user;
export type User = z.infer<typeof user>; export type User = z.infer<typeof user>;
export type GetMeResponse = z.infer<typeof getMeResponse>; export type GetMeResponse = z.infer<typeof getMeResponse>;
export type UpdateUserRequest = z.infer<typeof updateUserRequest>;
export type UpdateUserResponse = z.infer<typeof updateUserResponse>;

View file

@ -1,4 +1,8 @@
import { getMeResponse } from "@/contracts/types/user.types"; import {
getMeResponse,
updateUserResponse,
type UpdateUserRequest,
} from "@/contracts/types/user.types";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
class UserApiService { class UserApiService {
@ -8,6 +12,15 @@ class UserApiService {
getMe = async () => { getMe = async () => {
return baseApiService.get(`/users/me`, getMeResponse); return baseApiService.get(`/users/me`, getMeResponse);
}; };
/**
* Update current authenticated user
*/
updateMe = async (request: UpdateUserRequest) => {
return baseApiService.patch(`/users/me`, updateUserResponse, {
body: request,
});
};
} }
export const userApiService = new UserApiService(); export const userApiService = new UserApiService();

View file

@ -31,6 +31,9 @@ export interface MessageRecord {
role: "user" | "assistant" | "system"; role: "user" | "assistant" | "system";
content: unknown; content: unknown;
created_at: string; created_at: string;
author_id?: string | null;
author_display_name?: string | null;
author_avatar_url?: string | null;
} }
export interface ThreadListResponse { export interface ThreadListResponse {

View file

@ -109,6 +109,17 @@
"title": "User Settings", "title": "User Settings",
"description": "Manage your account settings and API access", "description": "Manage your account settings and API access",
"back_to_app": "Back to app", "back_to_app": "Back to app",
"profile_nav_label": "Profile",
"profile_nav_description": "Manage your display name and avatar",
"profile_title": "Profile",
"profile_description": "Update your personal information",
"profile_avatar": "Profile Picture",
"profile_display_name": "Display Name",
"profile_display_name_hint": "This is how your name appears across the app",
"profile_email": "Email",
"profile_save": "Save Changes",
"profile_saved": "Profile updated successfully",
"profile_save_error": "Failed to update profile",
"api_key_nav_label": "API Key", "api_key_nav_label": "API Key",
"api_key_nav_description": "Manage your API access token", "api_key_nav_description": "Manage your API access token",
"api_key_title": "API Key", "api_key_title": "API Key",