Compare commits

...

30 Commits

Author SHA1 Message Date
Raunak Bhagat
d44d1d92b3 2.9 fixes (#7756) 2026-01-24 17:36:20 -08:00
Raunak Bhagat
4cedcfee59 Fix notifications popover some more 2026-01-24 17:30:45 -08:00
Raunak Bhagat
90a721a76e Fix line-items 2026-01-24 17:30:45 -08:00
Raunak Bhagat
3ccd99e931 Fix notifications 2026-01-24 17:30:45 -08:00
Raunak Bhagat
9076bf603f Fix actions popover 2026-01-24 17:30:45 -08:00
Nikolas Garza
8c6e0a70c3 fix(chat): prevent streaming text from appearing in bursts after citations (#7745) 2026-01-24 16:58:12 -08:00
Yuhong Sun
bebe9555d4 fix: Azure OpenAI Tool Calls (#7727) 2026-01-24 16:55:27 -08:00
Nikolas Garza
c530722c9f fix(tests): use crawler-friendly search query in Exa integration test (#7746) 2026-01-24 16:53:40 -08:00
Jamison Lahman
68380b4ddb chore(fe): align assistant icon with chat bar (#7537) 2026-01-24 16:34:57 -08:00
Jamison Lahman
b3380746ab fix(fe): chat header is sticky and transparent (#7487) 2026-01-24 16:34:57 -08:00
Nikolas Garza
56be114c87 fix(fe): show scroll-down button when user scrolls up during streaming (#7562) 2026-01-24 16:34:57 -08:00
Nikolas Garza
54f467da5c fix: improve scroll behavior (#7364) 2026-01-24 16:34:57 -08:00
Nikolas Garza
8726b112fe fix(slack): Extract person names and filter garbage in query expansion (#7632) 2026-01-23 22:59:23 -08:00
Raunak Bhagat
92181d07b2 fix: Fix scrollability issues for modals (#7718) 2026-01-23 22:05:53 -08:00
Raunak Bhagat
3a73f7fab2 fix: Fix layout issues with AgentEditorPage (#7730) 2026-01-23 20:29:21 -08:00
Raunak Bhagat
7dabaca7cd fix: Add back agent sharing (#7731) 2026-01-23 19:13:36 -08:00
Raunak Bhagat
dec4748825 Close modal on success only 2026-01-23 17:39:52 -08:00
Raunak Bhagat
072836cd86 Cherry-pick agent-deletion 2026-01-23 17:39:52 -08:00
Evan Lohn
2705b5fb0e Revert "fix: modal header in index attempt errors (#7601)"
This reverts commit f945ab6b05.
2026-01-23 15:02:41 -08:00
Evan Lohn
37dcde4226 fix: prevent updates from overwriting perm syncing (#7384) 2026-01-23 14:52:44 -08:00
Evan Lohn
a765b5f622 fix(mcp): per-user auth (#7400) 2026-01-23 14:51:56 -08:00
Evan Lohn
5e093368d1 fix: bedrock non-anthropic prompt caching (#7435) 2026-01-23 14:50:13 -08:00
Evan Lohn
f945ab6b05 fix: modal header in index attempt errors (#7601) 2026-01-23 14:48:29 -08:00
Justin Tahara
11b7a22404 fix(ui): Coda Logo (#7656) 2026-01-23 14:45:29 -08:00
Justin Tahara
8e34f944cc fix(ui): First Connector Result (#7657) 2026-01-23 14:45:18 -08:00
Jamison Lahman
32606dc752 revert: "feat: Enable triple click on content in the chat" (#7393) to release v2.9 (#7710) 2026-01-23 14:21:22 -08:00
Jamison Lahman
1f6c4b40bf fix(fe): inline code text wraps (#7574) to release v2.9 (#7707) 2026-01-23 13:40:28 -08:00
Nikolas Garza
1943f1c745 feat(billing): add annual pricing support to subscription checkout (#7506) 2026-01-23 10:40:16 -08:00
Jamison Lahman
82460729a6 fix(db): ensure migrations are atomic (#7474) to release v2.9 (#7648) 2026-01-21 14:58:04 -08:00
Wenxi
c445e6a8c0 fix: delete old notifications first in migration (#7454) 2026-01-20 08:31:00 -08:00
129 changed files with 3279 additions and 2251 deletions

View File

@@ -225,7 +225,6 @@ def do_run_migrations(
) -> None:
if create_schema:
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"'))
connection.execute(text("COMMIT"))
connection.execute(text(f'SET search_path TO "{schema_name}"'))
@@ -309,6 +308,7 @@ async def run_async_migrations() -> None:
schema_name=schema,
create_schema=create_schema,
)
await connection.commit()
except Exception as e:
logger.error(f"Error migrating schema {schema}: {e}")
if not continue_on_error:
@@ -346,6 +346,7 @@ async def run_async_migrations() -> None:
schema_name=schema,
create_schema=create_schema,
)
await connection.commit()
except Exception as e:
logger.error(f"Error migrating schema {schema}: {e}")
if not continue_on_error:

View File

@@ -85,103 +85,122 @@ class UserRow(NamedTuple):
def upgrade() -> None:
conn = op.get_bind()
# Start transaction
conn.execute(sa.text("BEGIN"))
# Step 1: Create or update the unified assistant (ID 0)
search_assistant = conn.execute(
sa.text("SELECT * FROM persona WHERE id = 0")
).fetchone()
try:
# Step 1: Create or update the unified assistant (ID 0)
search_assistant = conn.execute(
sa.text("SELECT * FROM persona WHERE id = 0")
).fetchone()
if search_assistant:
# Update existing Search assistant to be the unified assistant
conn.execute(
sa.text(
"""
UPDATE persona
SET name = :name,
description = :description,
system_prompt = :system_prompt,
num_chunks = :num_chunks,
is_default_persona = true,
is_visible = true,
deleted = false,
display_priority = :display_priority,
llm_filter_extraction = :llm_filter_extraction,
llm_relevance_filter = :llm_relevance_filter,
recency_bias = :recency_bias,
chunks_above = :chunks_above,
chunks_below = :chunks_below,
datetime_aware = :datetime_aware,
starter_messages = null
WHERE id = 0
"""
),
INSERT_DICT,
)
else:
# Create new unified assistant with ID 0
conn.execute(
sa.text(
"""
INSERT INTO persona (
id, name, description, system_prompt, num_chunks,
is_default_persona, is_visible, deleted, display_priority,
llm_filter_extraction, llm_relevance_filter, recency_bias,
chunks_above, chunks_below, datetime_aware, starter_messages,
builtin_persona
) VALUES (
0, :name, :description, :system_prompt, :num_chunks,
true, true, false, :display_priority, :llm_filter_extraction,
:llm_relevance_filter, :recency_bias, :chunks_above, :chunks_below,
:datetime_aware, null, true
)
"""
),
INSERT_DICT,
)
# Step 2: Mark ALL builtin assistants as deleted (except the unified assistant ID 0)
if search_assistant:
# Update existing Search assistant to be the unified assistant
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = true, is_visible = false, is_default_persona = false
WHERE builtin_persona = true AND id != 0
SET name = :name,
description = :description,
system_prompt = :system_prompt,
num_chunks = :num_chunks,
is_default_persona = true,
is_visible = true,
deleted = false,
display_priority = :display_priority,
llm_filter_extraction = :llm_filter_extraction,
llm_relevance_filter = :llm_relevance_filter,
recency_bias = :recency_bias,
chunks_above = :chunks_above,
chunks_below = :chunks_below,
datetime_aware = :datetime_aware,
starter_messages = null
WHERE id = 0
"""
)
),
INSERT_DICT,
)
else:
# Create new unified assistant with ID 0
conn.execute(
sa.text(
"""
INSERT INTO persona (
id, name, description, system_prompt, num_chunks,
is_default_persona, is_visible, deleted, display_priority,
llm_filter_extraction, llm_relevance_filter, recency_bias,
chunks_above, chunks_below, datetime_aware, starter_messages,
builtin_persona
) VALUES (
0, :name, :description, :system_prompt, :num_chunks,
true, true, false, :display_priority, :llm_filter_extraction,
:llm_relevance_filter, :recency_bias, :chunks_above, :chunks_below,
:datetime_aware, null, true
)
"""
),
INSERT_DICT,
)
# Step 3: Add all built-in tools to the unified assistant
# First, get the tool IDs for SearchTool, ImageGenerationTool, and WebSearchTool
search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'SearchTool'")
).fetchone()
# Step 2: Mark ALL builtin assistants as deleted (except the unified assistant ID 0)
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = true, is_visible = false, is_default_persona = false
WHERE builtin_persona = true AND id != 0
"""
)
)
if not search_tool:
raise ValueError(
"SearchTool not found in database. Ensure tools migration has run first."
)
# Step 3: Add all built-in tools to the unified assistant
# First, get the tool IDs for SearchTool, ImageGenerationTool, and WebSearchTool
search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'SearchTool'")
).fetchone()
image_gen_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'ImageGenerationTool'")
).fetchone()
if not search_tool:
raise ValueError(
"SearchTool not found in database. Ensure tools migration has run first."
)
if not image_gen_tool:
raise ValueError(
"ImageGenerationTool not found in database. Ensure tools migration has run first."
)
image_gen_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'ImageGenerationTool'")
).fetchone()
# WebSearchTool is optional - may not be configured
web_search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'WebSearchTool'")
).fetchone()
if not image_gen_tool:
raise ValueError(
"ImageGenerationTool not found in database. Ensure tools migration has run first."
)
# Clear existing tool associations for persona 0
conn.execute(sa.text("DELETE FROM persona__tool WHERE persona_id = 0"))
# WebSearchTool is optional - may not be configured
web_search_tool = conn.execute(
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'WebSearchTool'")
).fetchone()
# Add tools to the unified assistant
# Clear existing tool associations for persona 0
conn.execute(sa.text("DELETE FROM persona__tool WHERE persona_id = 0"))
# Add tools to the unified assistant
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
"""
),
{"tool_id": search_tool[0]},
)
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
"""
),
{"tool_id": image_gen_tool[0]},
)
if web_search_tool:
conn.execute(
sa.text(
"""
@@ -190,191 +209,148 @@ def upgrade() -> None:
ON CONFLICT DO NOTHING
"""
),
{"tool_id": search_tool[0]},
{"tool_id": web_search_tool[0]},
)
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
# Step 4: Migrate existing chat sessions from all builtin assistants to unified assistant
conn.execute(
sa.text(
"""
),
{"tool_id": image_gen_tool[0]},
UPDATE chat_session
SET persona_id = 0
WHERE persona_id IN (
SELECT id FROM persona WHERE builtin_persona = true AND id != 0
)
"""
)
)
if web_search_tool:
# Step 5: Migrate user preferences - remove references to all builtin assistants
# First, get all builtin assistant IDs (except 0)
builtin_assistants_result = conn.execute(
sa.text(
"""
SELECT id FROM persona
WHERE builtin_persona = true AND id != 0
"""
)
).fetchall()
builtin_assistant_ids = [row[0] for row in builtin_assistants_result]
# Get all users with preferences
users_result = conn.execute(
sa.text(
"""
SELECT id, chosen_assistants, visible_assistants,
hidden_assistants, pinned_assistants
FROM "user"
"""
)
).fetchall()
for user_row in users_result:
user = UserRow(*user_row)
user_id: UUID = user.id
updates: dict[str, Any] = {}
# Remove all builtin assistants from chosen_assistants
if user.chosen_assistants:
new_chosen: list[int] = [
assistant_id
for assistant_id in user.chosen_assistants
if assistant_id not in builtin_assistant_ids
]
if new_chosen != user.chosen_assistants:
updates["chosen_assistants"] = json.dumps(new_chosen)
# Remove all builtin assistants from visible_assistants
if user.visible_assistants:
new_visible: list[int] = [
assistant_id
for assistant_id in user.visible_assistants
if assistant_id not in builtin_assistant_ids
]
if new_visible != user.visible_assistants:
updates["visible_assistants"] = json.dumps(new_visible)
# Add all builtin assistants to hidden_assistants
if user.hidden_assistants:
new_hidden: list[int] = list(user.hidden_assistants)
for old_id in builtin_assistant_ids:
if old_id not in new_hidden:
new_hidden.append(old_id)
if new_hidden != user.hidden_assistants:
updates["hidden_assistants"] = json.dumps(new_hidden)
else:
updates["hidden_assistants"] = json.dumps(builtin_assistant_ids)
# Remove all builtin assistants from pinned_assistants
if user.pinned_assistants:
new_pinned: list[int] = [
assistant_id
for assistant_id in user.pinned_assistants
if assistant_id not in builtin_assistant_ids
]
if new_pinned != user.pinned_assistants:
updates["pinned_assistants"] = json.dumps(new_pinned)
# Apply updates if any
if updates:
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
updates["user_id"] = str(user_id) # Convert UUID to string for SQL
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
"""
),
{"tool_id": web_search_tool[0]},
sa.text(f'UPDATE "user" SET {set_clause} WHERE id = :user_id'),
updates,
)
# Step 4: Migrate existing chat sessions from all builtin assistants to unified assistant
conn.execute(
sa.text(
"""
UPDATE chat_session
SET persona_id = 0
WHERE persona_id IN (
SELECT id FROM persona WHERE builtin_persona = true AND id != 0
)
"""
)
)
# Step 5: Migrate user preferences - remove references to all builtin assistants
# First, get all builtin assistant IDs (except 0)
builtin_assistants_result = conn.execute(
sa.text(
"""
SELECT id FROM persona
WHERE builtin_persona = true AND id != 0
"""
)
).fetchall()
builtin_assistant_ids = [row[0] for row in builtin_assistants_result]
# Get all users with preferences
users_result = conn.execute(
sa.text(
"""
SELECT id, chosen_assistants, visible_assistants,
hidden_assistants, pinned_assistants
FROM "user"
"""
)
).fetchall()
for user_row in users_result:
user = UserRow(*user_row)
user_id: UUID = user.id
updates: dict[str, Any] = {}
# Remove all builtin assistants from chosen_assistants
if user.chosen_assistants:
new_chosen: list[int] = [
assistant_id
for assistant_id in user.chosen_assistants
if assistant_id not in builtin_assistant_ids
]
if new_chosen != user.chosen_assistants:
updates["chosen_assistants"] = json.dumps(new_chosen)
# Remove all builtin assistants from visible_assistants
if user.visible_assistants:
new_visible: list[int] = [
assistant_id
for assistant_id in user.visible_assistants
if assistant_id not in builtin_assistant_ids
]
if new_visible != user.visible_assistants:
updates["visible_assistants"] = json.dumps(new_visible)
# Add all builtin assistants to hidden_assistants
if user.hidden_assistants:
new_hidden: list[int] = list(user.hidden_assistants)
for old_id in builtin_assistant_ids:
if old_id not in new_hidden:
new_hidden.append(old_id)
if new_hidden != user.hidden_assistants:
updates["hidden_assistants"] = json.dumps(new_hidden)
else:
updates["hidden_assistants"] = json.dumps(builtin_assistant_ids)
# Remove all builtin assistants from pinned_assistants
if user.pinned_assistants:
new_pinned: list[int] = [
assistant_id
for assistant_id in user.pinned_assistants
if assistant_id not in builtin_assistant_ids
]
if new_pinned != user.pinned_assistants:
updates["pinned_assistants"] = json.dumps(new_pinned)
# Apply updates if any
if updates:
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
updates["user_id"] = str(user_id) # Convert UUID to string for SQL
conn.execute(
sa.text(f'UPDATE "user" SET {set_clause} WHERE id = :user_id'),
updates,
)
# Commit transaction
conn.execute(sa.text("COMMIT"))
except Exception as e:
# Rollback on error
conn.execute(sa.text("ROLLBACK"))
raise e
def downgrade() -> None:
conn = op.get_bind()
# Start transaction
conn.execute(sa.text("BEGIN"))
try:
# Only restore General (ID -1) and Art (ID -3) assistants
# Step 1: Keep Search assistant (ID 0) as default but restore original state
conn.execute(
sa.text(
"""
UPDATE persona
SET is_default_persona = true,
is_visible = true,
deleted = false
WHERE id = 0
# Only restore General (ID -1) and Art (ID -3) assistants
# Step 1: Keep Search assistant (ID 0) as default but restore original state
conn.execute(
sa.text(
"""
)
UPDATE persona
SET is_default_persona = true,
is_visible = true,
deleted = false
WHERE id = 0
"""
)
)
# Step 2: Restore General assistant (ID -1)
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :general_assistant_id
# Step 2: Restore General assistant (ID -1)
conn.execute(
sa.text(
"""
),
{"general_assistant_id": GENERAL_ASSISTANT_ID},
)
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :general_assistant_id
"""
),
{"general_assistant_id": GENERAL_ASSISTANT_ID},
)
# Step 3: Restore Art assistant (ID -3)
conn.execute(
sa.text(
"""
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :art_assistant_id
# Step 3: Restore Art assistant (ID -3)
conn.execute(
sa.text(
"""
),
{"art_assistant_id": ART_ASSISTANT_ID},
)
UPDATE persona
SET deleted = false,
is_visible = true,
is_default_persona = true
WHERE id = :art_assistant_id
"""
),
{"art_assistant_id": ART_ASSISTANT_ID},
)
# Note: We don't restore the original tool associations, names, or descriptions
# as those would require more complex logic to determine original state.
# We also cannot restore original chat session persona_ids as we don't
# have the original mappings.
# Other builtin assistants remain deleted as per the requirement.
# Commit transaction
conn.execute(sa.text("COMMIT"))
except Exception as e:
# Rollback on error
conn.execute(sa.text("ROLLBACK"))
raise e
# Note: We don't restore the original tool associations, names, or descriptions
# as those would require more complex logic to determine original state.
# We also cannot restore original chat session persona_ids as we don't
# have the original mappings.
# Other builtin assistants remain deleted as per the requirement.

View File

@@ -24,6 +24,9 @@ def upgrade() -> None:
# in unique constraints, but we want NULL == NULL for deduplication).
# The '{}' represents an empty JSONB object as the NULL replacement.
# Clean up legacy notifications first
op.execute("DELETE FROM notification WHERE title = 'New Notification'")
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS ix_notification_user_type_data
@@ -40,9 +43,6 @@ def upgrade() -> None:
"""
)
# Clean up legacy 'reindex' notifications that are no longer needed
op.execute("DELETE FROM notification WHERE title = 'New Notification'")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_notification_user_type_data")

View File

@@ -42,20 +42,13 @@ TOOL_DESCRIPTIONS = {
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("BEGIN"))
try:
for tool_id, description in TOOL_DESCRIPTIONS.items():
conn.execute(
sa.text(
"UPDATE tool SET description = :description WHERE in_code_tool_id = :tool_id"
),
{"description": description, "tool_id": tool_id},
)
conn.execute(sa.text("COMMIT"))
except Exception as e:
conn.execute(sa.text("ROLLBACK"))
raise e
for tool_id, description in TOOL_DESCRIPTIONS.items():
conn.execute(
sa.text(
"UPDATE tool SET description = :description WHERE in_code_tool_id = :tool_id"
),
{"description": description, "tool_id": tool_id},
)
def downgrade() -> None:

View File

@@ -70,80 +70,66 @@ BUILT_IN_TOOLS = [
def upgrade() -> None:
conn = op.get_bind()
# Start transaction
conn.execute(sa.text("BEGIN"))
# Get existing tools to check what already exists
existing_tools = conn.execute(
sa.text("SELECT in_code_tool_id FROM tool WHERE in_code_tool_id IS NOT NULL")
).fetchall()
existing_tool_ids = {row[0] for row in existing_tools}
try:
# Get existing tools to check what already exists
existing_tools = conn.execute(
sa.text(
"SELECT in_code_tool_id FROM tool WHERE in_code_tool_id IS NOT NULL"
# Insert or update built-in tools
for tool in BUILT_IN_TOOLS:
in_code_id = tool["in_code_tool_id"]
# Handle historical rename: InternetSearchTool -> WebSearchTool
if (
in_code_id == "WebSearchTool"
and "WebSearchTool" not in existing_tool_ids
and "InternetSearchTool" in existing_tool_ids
):
# Rename the existing InternetSearchTool row in place and update fields
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description,
in_code_tool_id = :in_code_tool_id
WHERE in_code_tool_id = 'InternetSearchTool'
"""
),
tool,
)
).fetchall()
existing_tool_ids = {row[0] for row in existing_tools}
# Keep the local view of existing ids in sync to avoid duplicate insert
existing_tool_ids.discard("InternetSearchTool")
existing_tool_ids.add("WebSearchTool")
continue
# Insert or update built-in tools
for tool in BUILT_IN_TOOLS:
in_code_id = tool["in_code_tool_id"]
# Handle historical rename: InternetSearchTool -> WebSearchTool
if (
in_code_id == "WebSearchTool"
and "WebSearchTool" not in existing_tool_ids
and "InternetSearchTool" in existing_tool_ids
):
# Rename the existing InternetSearchTool row in place and update fields
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description,
in_code_tool_id = :in_code_tool_id
WHERE in_code_tool_id = 'InternetSearchTool'
"""
),
tool,
)
# Keep the local view of existing ids in sync to avoid duplicate insert
existing_tool_ids.discard("InternetSearchTool")
existing_tool_ids.add("WebSearchTool")
continue
if in_code_id in existing_tool_ids:
# Update existing tool
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description
WHERE in_code_tool_id = :in_code_tool_id
"""
),
tool,
)
else:
# Insert new tool
conn.execute(
sa.text(
"""
INSERT INTO tool (name, display_name, description, in_code_tool_id)
VALUES (:name, :display_name, :description, :in_code_tool_id)
"""
),
tool,
)
# Commit transaction
conn.execute(sa.text("COMMIT"))
except Exception as e:
# Rollback on error
conn.execute(sa.text("ROLLBACK"))
raise e
if in_code_id in existing_tool_ids:
# Update existing tool
conn.execute(
sa.text(
"""
UPDATE tool
SET name = :name,
display_name = :display_name,
description = :description
WHERE in_code_tool_id = :in_code_tool_id
"""
),
tool,
)
else:
# Insert new tool
conn.execute(
sa.text(
"""
INSERT INTO tool (name, display_name, description, in_code_tool_id)
VALUES (:name, :display_name, :description, :in_code_tool_id)
"""
),
tool,
)
def downgrade() -> None:

View File

@@ -109,7 +109,6 @@ CHECK_TTL_MANAGEMENT_TASK_FREQUENCY_IN_HOURS = float(
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
STRIPE_PRICE_ID = os.environ.get("STRIPE_PRICE")
# JWT Public Key URL
JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)

View File

@@ -3,30 +3,42 @@ from uuid import UUID
from sqlalchemy.orm import Session
from onyx.configs.constants import NotificationType
from onyx.db.models import Persona
from onyx.db.models import Persona__User
from onyx.db.models import Persona__UserGroup
from onyx.db.notification import create_notification
from onyx.server.features.persona.models import PersonaSharedNotificationData
def make_persona_private(
def update_persona_access(
persona_id: int,
creator_user_id: UUID | None,
user_ids: list[UUID] | None,
group_ids: list[int] | None,
db_session: Session,
is_public: bool | None = None,
user_ids: list[UUID] | None = None,
group_ids: list[int] | None = None,
) -> None:
"""NOTE(rkuo): This function batches all updates into a single commit. If we don't
dedupe the inputs, the commit will exception."""
"""Updates the access settings for a persona including public status, user shares,
and group shares.
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
).delete(synchronize_session="fetch")
db_session.query(Persona__UserGroup).filter(
Persona__UserGroup.persona_id == persona_id
).delete(synchronize_session="fetch")
NOTE: This function batches all updates. If we don't dedupe the inputs,
the commit will exception.
NOTE: Callers are responsible for committing."""
if is_public is not None:
persona = db_session.query(Persona).filter(Persona.id == persona_id).first()
if persona:
persona.is_public = is_public
# NOTE: For user-ids and group-ids, `None` means "leave unchanged", `[]` means "clear all shares",
# and a non-empty list means "replace with these shares".
if user_ids is not None:
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
).delete(synchronize_session="fetch")
if user_ids:
user_ids_set = set(user_ids)
for user_id in user_ids_set:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_id))
@@ -41,11 +53,13 @@ def make_persona_private(
).model_dump(),
)
if group_ids:
if group_ids is not None:
db_session.query(Persona__UserGroup).filter(
Persona__UserGroup.persona_id == persona_id
).delete(synchronize_session="fetch")
group_ids_set = set(group_ids)
for group_id in group_ids_set:
db_session.add(
Persona__UserGroup(persona_id=persona_id, user_group_id=group_id)
)
db_session.commit()

View File

@@ -1,9 +1,9 @@
from typing import cast
from typing import Literal
import requests
import stripe
from ee.onyx.configs.app_configs import STRIPE_PRICE_ID
from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY
from ee.onyx.server.tenants.access import generate_data_plane_token
from ee.onyx.server.tenants.models import BillingInformation
@@ -16,15 +16,21 @@ stripe.api_key = STRIPE_SECRET_KEY
logger = setup_logger()
def fetch_stripe_checkout_session(tenant_id: str) -> str:
def fetch_stripe_checkout_session(
tenant_id: str,
billing_period: Literal["monthly", "annual"] = "monthly",
) -> str:
token = generate_data_plane_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
url = f"{CONTROL_PLANE_API_BASE_URL}/create-checkout-session"
params = {"tenant_id": tenant_id}
response = requests.post(url, headers=headers, params=params)
payload = {
"tenant_id": tenant_id,
"billing_period": billing_period,
}
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()["sessionId"]
@@ -72,22 +78,24 @@ def fetch_billing_information(
def register_tenant_users(tenant_id: str, number_of_users: int) -> stripe.Subscription:
"""
Send a request to the control service to register the number of users for a tenant.
Update the number of seats for a tenant's subscription.
Preserves the existing price (monthly, annual, or grandfathered).
"""
if not STRIPE_PRICE_ID:
raise Exception("STRIPE_PRICE_ID is not set")
response = fetch_tenant_stripe_information(tenant_id)
stripe_subscription_id = cast(str, response.get("stripe_subscription_id"))
subscription = stripe.Subscription.retrieve(stripe_subscription_id)
subscription_item = subscription["items"]["data"][0]
# Use existing price to preserve the customer's current plan
current_price_id = subscription_item.price.id
updated_subscription = stripe.Subscription.modify(
stripe_subscription_id,
items=[
{
"id": subscription["items"]["data"][0].id,
"price": STRIPE_PRICE_ID,
"id": subscription_item.id,
"price": current_price_id,
"quantity": number_of_users,
}
],

View File

@@ -10,6 +10,7 @@ from ee.onyx.server.tenants.billing import fetch_billing_information
from ee.onyx.server.tenants.billing import fetch_stripe_checkout_session
from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information
from ee.onyx.server.tenants.models import BillingInformation
from ee.onyx.server.tenants.models import CreateSubscriptionSessionRequest
from ee.onyx.server.tenants.models import ProductGatingFullSyncRequest
from ee.onyx.server.tenants.models import ProductGatingRequest
from ee.onyx.server.tenants.models import ProductGatingResponse
@@ -104,15 +105,18 @@ async def create_customer_portal_session(
@router.post("/create-subscription-session")
async def create_subscription_session(
request: CreateSubscriptionSessionRequest | None = None,
_: User = Depends(current_admin_user),
) -> SubscriptionSessionResponse:
try:
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found")
session_id = fetch_stripe_checkout_session(tenant_id)
billing_period = request.billing_period if request else "monthly"
session_id = fetch_stripe_checkout_session(tenant_id, billing_period)
return SubscriptionSessionResponse(sessionId=session_id)
except Exception as e:
logger.exception("Failed to create resubscription session")
logger.exception("Failed to create subscription session")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel
@@ -73,6 +74,12 @@ class SubscriptionSessionResponse(BaseModel):
sessionId: str
class CreateSubscriptionSessionRequest(BaseModel):
"""Request to create a subscription checkout session."""
billing_period: Literal["monthly", "annual"] = "monthly"
class TenantByDomainResponse(BaseModel):
tenant_id: str
number_of_users: int

View File

@@ -566,6 +566,23 @@ def extract_content_words_from_recency_query(
return content_words_filtered[:MAX_CONTENT_WORDS]
def _is_valid_keyword_query(line: str) -> bool:
"""Check if a line looks like a valid keyword query vs explanatory text.
Returns False for lines that appear to be LLM explanations rather than keywords.
"""
# Reject lines that start with parentheses (explanatory notes)
if line.startswith("("):
return False
# Reject lines that are too long (likely sentences, not keywords)
# Keywords should be short - reject if > 50 chars or > 6 words
if len(line) > 50 or len(line.split()) > 6:
return False
return True
def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
"""Use LLM to expand query into multiple search variations.
@@ -586,10 +603,18 @@ def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
response_clean = _parse_llm_code_block_response(response)
# Split into lines and filter out empty lines
rephrased_queries = [
raw_queries = [
line.strip() for line in response_clean.split("\n") if line.strip()
]
# Filter out lines that look like explanatory text rather than keywords
rephrased_queries = [q for q in raw_queries if _is_valid_keyword_query(q)]
# Log if we filtered out garbage
if len(raw_queries) != len(rephrased_queries):
filtered_out = set(raw_queries) - set(rephrased_queries)
logger.warning(f"Filtered out non-keyword LLM responses: {filtered_out}")
# If no queries generated, use empty query
if not rephrased_queries:
logger.debug("No content keywords extracted from query expansion")

View File

@@ -444,6 +444,8 @@ def upsert_documents(
logger.info("No documents to upsert. Skipping.")
return
includes_permissions = any(doc.external_access for doc in seen_documents.values())
insert_stmt = insert(DbDocument).values(
[
model_to_dict(
@@ -479,21 +481,38 @@ def upsert_documents(
]
)
update_set = {
"from_ingestion_api": insert_stmt.excluded.from_ingestion_api,
"boost": insert_stmt.excluded.boost,
"hidden": insert_stmt.excluded.hidden,
"semantic_id": insert_stmt.excluded.semantic_id,
"link": insert_stmt.excluded.link,
"primary_owners": insert_stmt.excluded.primary_owners,
"secondary_owners": insert_stmt.excluded.secondary_owners,
"doc_metadata": insert_stmt.excluded.doc_metadata,
}
if includes_permissions:
# Use COALESCE to preserve existing permissions when new values are NULL.
# This prevents subsequent indexing runs (which don't fetch permissions)
# from overwriting permissions set by permission sync jobs.
update_set.update(
{
"external_user_emails": func.coalesce(
insert_stmt.excluded.external_user_emails,
DbDocument.external_user_emails,
),
"external_user_group_ids": func.coalesce(
insert_stmt.excluded.external_user_group_ids,
DbDocument.external_user_group_ids,
),
"is_public": func.coalesce(
insert_stmt.excluded.is_public,
DbDocument.is_public,
),
}
)
on_conflict_stmt = insert_stmt.on_conflict_do_update(
index_elements=["id"], # Conflict target
set_={
"from_ingestion_api": insert_stmt.excluded.from_ingestion_api,
"boost": insert_stmt.excluded.boost,
"hidden": insert_stmt.excluded.hidden,
"semantic_id": insert_stmt.excluded.semantic_id,
"link": insert_stmt.excluded.link,
"primary_owners": insert_stmt.excluded.primary_owners,
"secondary_owners": insert_stmt.excluded.secondary_owners,
"external_user_emails": insert_stmt.excluded.external_user_emails,
"external_user_group_ids": insert_stmt.excluded.external_user_group_ids,
"is_public": insert_stmt.excluded.is_public,
"doc_metadata": insert_stmt.excluded.doc_metadata,
},
index_elements=["id"], set_=update_set # Conflict target
)
db_session.execute(on_conflict_stmt)
db_session.commit()

View File

@@ -187,13 +187,25 @@ def _get_persona_by_name(
return result
def make_persona_private(
def update_persona_access(
persona_id: int,
creator_user_id: UUID | None,
user_ids: list[UUID] | None,
group_ids: list[int] | None,
db_session: Session,
is_public: bool | None = None,
user_ids: list[UUID] | None = None,
group_ids: list[int] | None = None,
) -> None:
"""Updates the access settings for a persona including public status and user shares.
NOTE: Callers are responsible for committing."""
if is_public is not None:
persona = db_session.query(Persona).filter(Persona.id == persona_id).first()
if persona:
persona.is_public = is_public
# NOTE: For user-ids and group-ids, `None` means "leave unchanged", `[]` means "clear all shares",
# and a non-empty list means "replace with these shares".
if user_ids is not None:
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
@@ -212,11 +224,15 @@ def make_persona_private(
).model_dump(),
)
db_session.commit()
# MIT doesn't support group-based sharing, so we allow clearing (no-op since
# there shouldn't be any) but raise an error if trying to add actual groups.
if group_ids is not None:
db_session.query(Persona__UserGroup).filter(
Persona__UserGroup.persona_id == persona_id
).delete(synchronize_session="fetch")
# May cause error if someone switches down to MIT from EE
if group_ids:
raise NotImplementedError("Onyx MIT does not support private Personas")
if group_ids:
raise NotImplementedError("Onyx MIT does not support group-based sharing")
def create_update_persona(
@@ -282,20 +298,21 @@ def create_update_persona(
llm_filter_extraction=create_persona_request.llm_filter_extraction,
is_default_persona=create_persona_request.is_default_persona,
user_file_ids=converted_user_file_ids,
commit=False,
)
versioned_make_persona_private = fetch_versioned_implementation(
"onyx.db.persona", "make_persona_private"
versioned_update_persona_access = fetch_versioned_implementation(
"onyx.db.persona", "update_persona_access"
)
# Privatize Persona
versioned_make_persona_private(
versioned_update_persona_access(
persona_id=persona.id,
creator_user_id=user.id if user else None,
db_session=db_session,
user_ids=create_persona_request.users,
group_ids=create_persona_request.groups,
db_session=db_session,
)
db_session.commit()
except ValueError as e:
logger.exception("Failed to create persona")
@@ -304,11 +321,13 @@ def create_update_persona(
return FullPersonaSnapshot.from_model(persona)
def update_persona_shared_users(
def update_persona_shared(
persona_id: int,
user_ids: list[UUID],
user: User | None,
db_session: Session,
user_ids: list[UUID] | None = None,
group_ids: list[int] | None = None,
is_public: bool | None = None,
) -> None:
"""Simplified version of `create_update_persona` which only touches the
accessibility rather than any of the logic (e.g. prompt, connected data sources,
@@ -317,22 +336,25 @@ def update_persona_shared_users(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if persona.is_public:
raise HTTPException(status_code=400, detail="Cannot share public persona")
if user and user.role != UserRole.ADMIN and persona.user_id != user.id:
raise HTTPException(
status_code=403, detail="You don't have permission to modify this persona"
)
versioned_make_persona_private = fetch_versioned_implementation(
"onyx.db.persona", "make_persona_private"
versioned_update_persona_access = fetch_versioned_implementation(
"onyx.db.persona", "update_persona_access"
)
# Privatize Persona
versioned_make_persona_private(
versioned_update_persona_access(
persona_id=persona_id,
creator_user_id=user.id if user else None,
user_ids=user_ids,
group_ids=None,
db_session=db_session,
is_public=is_public,
user_ids=user_ids,
group_ids=group_ids,
)
db_session.commit()
def update_persona_public_status(
persona_id: int,

View File

@@ -369,6 +369,8 @@ def _patch_openai_responses_chunk_parser() -> None:
# New output item added
output_item = parsed_chunk.get("item", {})
if output_item.get("type") == "function_call":
# Track that we've received tool calls via streaming
self._has_streamed_tool_calls = True
return GenericStreamingChunk(
text="",
tool_use=ChatCompletionToolCallChunk(
@@ -394,6 +396,8 @@ def _patch_openai_responses_chunk_parser() -> None:
elif event_type == "response.function_call_arguments.delta":
content_part: Optional[str] = parsed_chunk.get("delta", None)
if content_part:
# Track that we've received tool calls via streaming
self._has_streamed_tool_calls = True
return GenericStreamingChunk(
text="",
tool_use=ChatCompletionToolCallChunk(
@@ -491,22 +495,72 @@ def _patch_openai_responses_chunk_parser() -> None:
elif event_type == "response.completed":
# Final event signaling all output items (including parallel tool calls) are done
# Check if we already received tool calls via streaming events
# There is an issue where OpenAI (not via Azure) will give back the tool calls streamed out as tokens
# But on Azure, it's only given out all at once. OpenAI also happens to give back the tool calls in the
# response.completed event so we need to throw it out here or there are duplicate tool calls.
has_streamed_tool_calls = getattr(self, "_has_streamed_tool_calls", False)
response_data = parsed_chunk.get("response", {})
# Determine finish reason based on response content
finish_reason = "stop"
if response_data.get("output"):
for item in response_data["output"]:
if isinstance(item, dict) and item.get("type") == "function_call":
finish_reason = "tool_calls"
break
return GenericStreamingChunk(
text="",
tool_use=None,
is_finished=True,
finish_reason=finish_reason,
usage=None,
output_items = response_data.get("output", [])
# Check if there are function_call items in the output
has_function_calls = any(
isinstance(item, dict) and item.get("type") == "function_call"
for item in output_items
)
if has_function_calls and not has_streamed_tool_calls:
# Azure's Responses API returns all tool calls in response.completed
# without streaming them incrementally. Extract them here.
from litellm.types.utils import (
Delta,
ModelResponseStream,
StreamingChoices,
)
tool_calls = []
for idx, item in enumerate(output_items):
if isinstance(item, dict) and item.get("type") == "function_call":
tool_calls.append(
ChatCompletionToolCallChunk(
id=item.get("call_id"),
index=idx,
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=item.get("name"),
arguments=item.get("arguments", ""),
),
)
)
return ModelResponseStream(
choices=[
StreamingChoices(
index=0,
delta=Delta(tool_calls=tool_calls),
finish_reason="tool_calls",
)
]
)
elif has_function_calls:
# Tool calls were already streamed, just signal completion
return GenericStreamingChunk(
text="",
tool_use=None,
is_finished=True,
finish_reason="tool_calls",
usage=None,
)
else:
return GenericStreamingChunk(
text="",
tool_use=None,
is_finished=True,
finish_reason="stop",
usage=None,
)
else:
pass

View File

@@ -63,7 +63,7 @@ def process_with_prompt_cache(
return suffix, None
# Get provider adapter
provider_adapter = get_provider_adapter(llm_config.model_provider)
provider_adapter = get_provider_adapter(llm_config)
# If provider doesn't support caching, combine and return unchanged
if not provider_adapter.supports_caching():

View File

@@ -1,14 +1,17 @@
"""Factory for creating provider-specific prompt cache adapters."""
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLMConfig
from onyx.llm.prompt_cache.providers.anthropic import AnthropicPromptCacheProvider
from onyx.llm.prompt_cache.providers.base import PromptCacheProvider
from onyx.llm.prompt_cache.providers.noop import NoOpPromptCacheProvider
from onyx.llm.prompt_cache.providers.openai import OpenAIPromptCacheProvider
from onyx.llm.prompt_cache.providers.vertex import VertexAIPromptCacheProvider
ANTHROPIC_BEDROCK_TAG = "anthropic."
def get_provider_adapter(provider: str) -> PromptCacheProvider:
def get_provider_adapter(llm_config: LLMConfig) -> PromptCacheProvider:
"""Get the appropriate prompt cache provider adapter for a given provider.
Args:
@@ -17,11 +20,14 @@ def get_provider_adapter(provider: str) -> PromptCacheProvider:
Returns:
PromptCacheProvider instance for the given provider
"""
if provider == LlmProviderNames.OPENAI:
if llm_config.model_provider == LlmProviderNames.OPENAI:
return OpenAIPromptCacheProvider()
elif provider in [LlmProviderNames.ANTHROPIC, LlmProviderNames.BEDROCK]:
elif llm_config.model_provider == LlmProviderNames.ANTHROPIC or (
llm_config.model_provider == LlmProviderNames.BEDROCK
and ANTHROPIC_BEDROCK_TAG in llm_config.model_name
):
return AnthropicPromptCacheProvider()
elif provider == LlmProviderNames.VERTEX_AI:
elif llm_config.model_provider == LlmProviderNames.VERTEX_AI:
return VertexAIPromptCacheProvider()
else:
# Default to no-op for providers without caching support

View File

@@ -1,30 +1,39 @@
from onyx.configs.app_configs import MAX_SLACK_QUERY_EXPANSIONS
SLACK_QUERY_EXPANSION_PROMPT = f"""
Rewrite the user's query and, if helpful, split it into at most {MAX_SLACK_QUERY_EXPANSIONS} \
keyword-only queries, so that Slack's keyword search yields the best matches.
Rewrite the user's query into at most {MAX_SLACK_QUERY_EXPANSIONS} keyword-only queries for Slack's keyword search.
Keep in mind the Slack's search behavior:
- Pure keyword AND search (no semantics).
- Word order matters.
- More words = fewer matches, so keep each query concise.
- IMPORTANT: Prefer simple 1-2 word queries over longer multi-word queries.
Slack search behavior:
- Pure keyword AND search (no semantics)
- More words = fewer matches, so keep queries concise (1-3 words)
Critical: Extract ONLY keywords that would actually appear in Slack message content.
ALWAYS include:
- Person names (e.g., "Sarah Chen", "Mike Johnson") - people search for messages from/about specific people
- Project/product names, technical terms, proper nouns
- Actual content words: "performance", "bug", "deployment", "API", "error"
DO NOT include:
- Meta-words: "topics", "conversations", "discussed", "summary", "messages", "big", "main", "talking"
- Temporal: "today", "yesterday", "week", "month", "recent", "past", "last"
- Channels/Users: "general", "eng-general", "engineering", "@username"
DO include:
- Actual content: "performance", "bug", "deployment", "API", "database", "error", "feature"
- Meta-words: "topics", "conversations", "discussed", "summary", "messages"
- Temporal: "today", "yesterday", "week", "month", "recent", "last"
- Channel names: "general", "eng-general", "random"
Examples:
Query: "what are the big topics in eng-general this week?"
Output:
Query: "messages with Sarah about the deployment"
Output:
Sarah deployment
Sarah
deployment
Query: "what did Mike say about the budget?"
Output:
Mike budget
Mike
budget
Query: "performance issues in eng-general"
Output:
performance issues
@@ -41,7 +50,7 @@ Now process this query:
{{query}}
Output:
Output (keywords only, one per line, NO explanations or commentary):
"""
SLACK_DATE_EXTRACTION_PROMPT = """

View File

@@ -697,7 +697,7 @@ def save_user_credentials(
# TODO: fix and/or type correctly w/base model
config_data = MCPConnectionData(
headers=auth_template.config.get("headers", {}),
header_substitutions=auth_template.config.get(HEADER_SUBSTITUTIONS, {}),
header_substitutions=request.credentials,
)
for oauth_field_key in MCPOAuthKeys:
field_key: Literal["client_info", "tokens", "metadata"] = (

View File

@@ -34,7 +34,7 @@ from onyx.db.persona import mark_persona_as_not_deleted
from onyx.db.persona import update_persona_is_default
from onyx.db.persona import update_persona_label
from onyx.db.persona import update_persona_public_status
from onyx.db.persona import update_persona_shared_users
from onyx.db.persona import update_persona_shared
from onyx.db.persona import update_persona_visibility
from onyx.db.persona import update_personas_display_priority
from onyx.file_store.file_store import get_default_file_store
@@ -366,7 +366,9 @@ def delete_label(
class PersonaShareRequest(BaseModel):
user_ids: list[UUID]
user_ids: list[UUID] | None = None
group_ids: list[int] | None = None
is_public: bool | None = None
# We notify each user when a user is shared with them
@@ -377,11 +379,13 @@ def share_persona(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
update_persona_shared_users(
update_persona_shared(
persona_id=persona_id,
user_ids=persona_share_request.user_ids,
user=user,
db_session=db_session,
user_ids=persona_share_request.user_ids,
group_ids=persona_share_request.group_ids,
is_public=persona_share_request.is_public,
)

View File

@@ -41,6 +41,12 @@ API_KEY_RECORDS: Dict[str, Dict[str, Any]] = {
},
}
# These are inferrable from the file anyways, no need to obfuscate.
# use them to test your auth with this server
#
# mcp_live-kid_alice_001-S3cr3tAlice
# mcp_live-kid_bob_001-S3cr3tBob
# ---- verifier ---------------------------------------------------------------
class ApiKeyVerifier(TokenVerifier):

View File

@@ -270,7 +270,7 @@ def test_web_search_endpoints_with_exa(
provider_id = _activate_exa_provider(admin_user)
assert isinstance(provider_id, int)
search_request = {"queries": ["latest ai research news"], "max_results": 3}
search_request = {"queries": ["wikipedia python programming"], "max_results": 3}
lite_response = requests.post(
f"{API_SERVER_URL}/web-search/search-lite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -25,7 +25,7 @@ export default function OnyxApiKeyForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content tall>
<Modal.Content width="sm" height="lg">
<Modal.Header
icon={SvgKey}
title={isUpdate ? "Update API Key" : "Create a new API Key"}

View File

@@ -105,7 +105,7 @@ function Main() {
{popup}
<Modal open={!!fullApiKey}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
title="New API Key"
icon={SvgKey}

View File

@@ -10,10 +10,7 @@ import {
} from "@/lib/types";
import BackButton from "@/refresh-components/buttons/BackButton";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import {
FetchAssistantsResponse,
fetchAssistantsSS,
} from "@/lib/assistants/fetchAssistantsSS";
import { FetchAssistantsResponse, fetchAssistantsSS } from "@/lib/agentsSS";
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
async function EditslackChannelConfigPage(props: {

View File

@@ -4,7 +4,7 @@ import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSetSummary, ValidSources } from "@/lib/types";
import BackButton from "@/refresh-components/buttons/BackButton";
import { fetchAssistantsSS } from "@/lib/assistants/fetchAssistantsSS";
import { fetchAssistantsSS } from "@/lib/agentsSS";
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
import { redirect } from "next/navigation";
import { SourceIcon } from "@/components/SourceIcon";

View File

@@ -1,9 +1,10 @@
"use client";
import { useState, ReactNode } from "react";
import useSWR, { useSWRConfig, KeyedMutator } from "swr";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import {
LLMProviderView,
ModelConfiguration,
WellKnownLLMProviderDescriptor,
} from "../../interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
@@ -114,7 +115,7 @@ export function ProviderFormEntrypointWrapper({
{formIsVisible && (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title={`Setup ${providerName}`}
@@ -196,7 +197,7 @@ export function ProviderFormEntrypointWrapper({
{formIsVisible && (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title={`${existingLlmProvider ? "Configure" : "Setup"} ${

View File

@@ -130,7 +130,7 @@ export default function UpgradingPage({
{popup}
{isCancelling && (
<Modal open onOpenChange={() => setIsCancelling(false)}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgX}
title="Cancel Embedding Model Switch"

View File

@@ -81,7 +81,7 @@ export const WebProviderSetupModal = memo(
return (
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Modal.Content mini preventAccidentalClose>
<Modal.Content width="sm" preventAccidentalClose>
<Modal.Header
icon={LogoArrangement}
title={`Set up ${providerLabel}`}

View File

@@ -125,7 +125,7 @@ export default function IndexAttemptErrorsModal({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content large>
<Modal.Content width="lg" height="full">
<Modal.Header
icon={SvgAlertTriangle}
title="Indexing Errors"

View File

@@ -353,7 +353,7 @@ export default function InlineFileManagement({
{/* Confirmation Modal */}
<Modal open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgFolderPlus}
title="Confirm File Changes"

View File

@@ -128,7 +128,7 @@ export default function ReIndexModal({
return (
<Modal open onOpenChange={hide}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgRefreshCw} title="Run Indexing" onClose={hide} />
<Modal.Body>
<Text as="p">

View File

@@ -584,7 +584,7 @@ export default function AddConnector({
open
onOpenChange={() => setCreateCredentialFormToggle(false)}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title={`Create a ${getSourceDisplayName(

View File

@@ -323,7 +323,7 @@ const RerankingDetailsForm = forwardRef<
open
onOpenChange={() => setShowGpuWarningModalModel(null)}
>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="GPU Not Enabled"
@@ -358,7 +358,7 @@ const RerankingDetailsForm = forwardRef<
setShowLiteLLMConfigurationModal(false);
}}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title="API Key Configuration"
@@ -462,7 +462,7 @@ const RerankingDetailsForm = forwardRef<
setIsApiKeyModalOpen(false);
}}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title="API Key Configuration"

View File

@@ -14,7 +14,7 @@ export default function AlreadyPickedModal({
}: AlreadyPickedModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgCheck}
title={`${model.model_name} already chosen`}

View File

@@ -21,7 +21,7 @@ export default function DeleteCredentialsModal({
}: DeleteCredentialsModalProps) {
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgTrash}
title={`Delete ${getFormattedProviderName(

View File

@@ -13,7 +13,7 @@ export default function InstantSwitchConfirmModal({
}: InstantSwitchConfirmModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="Are you sure you want to do an instant switch?"

View File

@@ -20,7 +20,7 @@ export default function ModelSelectionConfirmationModal({
}: ModelSelectionConfirmationModalProps) {
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content tall>
<Modal.Content width="sm" height="lg">
<Modal.Header
icon={SvgServer}
title="Update Embedding Model"

View File

@@ -186,7 +186,7 @@ export default function ProviderCreationModal({
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgSettings}
title={`Configure ${getFormattedProviderName(

View File

@@ -17,7 +17,7 @@ export default function SelectModelModal({
}: SelectModelModalProps) {
return (
<Modal open onOpenChange={onCancel}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgServer}
title={`Select ${model.model_name}`}

View File

@@ -539,7 +539,7 @@ export default function EmbeddingForm() {
)}
{showPoorModel && (
<Modal open onOpenChange={() => setShowPoorModel(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title={`Are you sure you want to select ${selectedProvider.model_name}?`}

View File

@@ -299,7 +299,7 @@ function Main() {
)}
{configureModalShown && (
<Modal open onOpenChange={() => setConfigureModalShown(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title="Configure Knowledge Graph"

View File

@@ -308,7 +308,7 @@ export function SettingsForm() {
)}
{showConfirmModal && (
<Modal open onOpenChange={() => setShowConfirmModal(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title="Enable Anonymous Users"

View File

@@ -63,7 +63,7 @@ export default function CreateRateLimitModal({
return (
<Modal open={isOpen} onOpenChange={() => setIsOpen(false)}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgSettings}
title="Create a Token Rate Limit"

View File

@@ -351,7 +351,7 @@ const AddUserButton = ({
{bulkAddUsersModal && (
<Modal open onOpenChange={() => setBulkAddUsersModal(false)}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgUserPlus}
title="Bulk Add Users"

View File

@@ -0,0 +1,323 @@
"use client";
import { cn, noProp } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { useCallback, useMemo, useState, useEffect } from "react";
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
import IconButton from "@/refresh-components/buttons/IconButton";
import LineItem from "@/refresh-components/buttons/LineItem";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import useChatSessions from "@/hooks/useChatSessions";
import { usePopup } from "@/components/admin/connectors/Popup";
import {
handleMoveOperation,
shouldShowMoveModal,
showErrorNotification,
} from "@/sections/sidebar/sidebarUtils";
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
import { deleteChatSession } from "@/app/chat/services/lib";
import { useRouter } from "next/navigation";
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import { PopoverMenu } from "@/refresh-components/Popover";
import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
import SimplePopover from "@/refresh-components/SimplePopover";
import { useAppSidebarContext } from "@/refresh-components/contexts/AppSidebarContext";
import useScreenSize from "@/hooks/useScreenSize";
import {
SvgFolderIn,
SvgMoreHorizontal,
SvgShare,
SvgSidebar,
SvgTrash,
} from "@opal/icons";
import { useSettingsContext } from "@/components/settings/SettingsProvider";
/**
* Chat Header Component
*
* Renders the header for chat sessions with share, move, and delete actions.
* Designed to be rendered inside ChatScrollContainer with sticky positioning.
*
* Features:
* - Share chat functionality
* - Move chat to project (with confirmation for custom agents)
* - Delete chat with confirmation
* - Mobile-responsive sidebar toggle
* - Custom header content from enterprise settings
*/
export default function ChatHeader() {
const settings = useSettingsContext();
const { isMobile } = useScreenSize();
const { setFolded } = useAppSidebarContext();
const [showShareModal, setShowShareModal] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
useState(false);
const [pendingMoveProjectId, setPendingMoveProjectId] = useState<
number | null
>(null);
const [showMoveOptions, setShowMoveOptions] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverItems, setPopoverItems] = useState<React.ReactNode[]>([]);
const {
projects,
fetchProjects,
refreshCurrentProjectDetails,
currentProjectId,
} = useProjectsContext();
const { currentChatSession, refreshChatSessions, currentChatSessionId } =
useChatSessions();
const { popup, setPopup } = usePopup();
const router = useRouter();
const customHeaderContent =
settings?.enterpriseSettings?.custom_header_content;
const availableProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => project.id !== currentProjectId);
}, [projects, currentProjectId]);
const filteredProjects = useMemo(() => {
if (!searchTerm) return availableProjects;
const term = searchTerm.toLowerCase();
return availableProjects.filter((project) =>
project.name.toLowerCase().includes(term)
);
}, [availableProjects, searchTerm]);
const resetMoveState = useCallback(() => {
setShowMoveOptions(false);
setSearchTerm("");
setPendingMoveProjectId(null);
setShowMoveCustomAgentModal(false);
}, []);
const performMove = useCallback(
async (targetProjectId: number) => {
if (!currentChatSession) return;
try {
await handleMoveOperation(
{
chatSession: currentChatSession,
targetProjectId,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
},
setPopup
);
resetMoveState();
setPopoverOpen(false);
} catch (error) {
console.error("Failed to move chat session:", error);
}
},
[
currentChatSession,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
setPopup,
resetMoveState,
]
);
const handleMoveClick = useCallback(
(projectId: number) => {
if (!currentChatSession) return;
if (shouldShowMoveModal(currentChatSession)) {
setPendingMoveProjectId(projectId);
setShowMoveCustomAgentModal(true);
return;
}
void performMove(projectId);
},
[currentChatSession, performMove]
);
const handleDeleteChat = useCallback(async () => {
if (!currentChatSession) return;
try {
const response = await deleteChatSession(currentChatSession.id);
if (!response.ok) {
throw new Error("Failed to delete chat session");
}
await Promise.all([refreshChatSessions(), fetchProjects()]);
router.replace("/chat");
setDeleteModalOpen(false);
} catch (error) {
console.error("Failed to delete chat:", error);
showErrorNotification(
setPopup,
"Failed to delete chat. Please try again."
);
}
}, [
currentChatSession,
refreshChatSessions,
fetchProjects,
router,
setPopup,
]);
const setDeleteConfirmationModalOpen = useCallback((open: boolean) => {
setDeleteModalOpen(open);
if (open) {
setPopoverOpen(false);
}
}, []);
useEffect(() => {
const items = showMoveOptions
? [
<PopoverSearchInput
key="search"
setShowMoveOptions={setShowMoveOptions}
onSearch={setSearchTerm}
/>,
...filteredProjects.map((project) => (
<LineItem
key={project.id}
icon={SvgFolderIn}
onClick={noProp(() => handleMoveClick(project.id))}
>
{project.name}
</LineItem>
)),
]
: [
<LineItem
key="move"
icon={SvgFolderIn}
onClick={noProp(() => setShowMoveOptions(true))}
>
Move to Project
</LineItem>,
<LineItem
key="delete"
icon={SvgTrash}
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
danger
>
Delete
</LineItem>,
];
setPopoverItems(items);
}, [
showMoveOptions,
filteredProjects,
currentChatSession,
setDeleteConfirmationModalOpen,
handleMoveClick,
]);
// Don't render if no chat session
if (!currentChatSessionId) return null;
return (
<>
{popup}
{showShareModal && currentChatSession && (
<ShareChatSessionModal
chatSession={currentChatSession}
onClose={() => setShowShareModal(false)}
/>
)}
{showMoveCustomAgentModal && (
<MoveCustomAgentChatModal
onCancel={resetMoveState}
onConfirm={async (doNotShowAgain: boolean) => {
if (doNotShowAgain && typeof window !== "undefined") {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
"true"
);
}
if (pendingMoveProjectId != null) {
await performMove(pendingMoveProjectId);
}
}}
/>
)}
{deleteModalOpen && (
<ConfirmationModalLayout
title="Delete Chat"
icon={SvgTrash}
onClose={() => setDeleteModalOpen(false)}
submit={
<Button danger onClick={handleDeleteChat}>
Delete
</Button>
}
>
Are you sure you want to delete this chat? This action cannot be
undone.
</ConfirmationModalLayout>
)}
<div className="w-full flex flex-row justify-center items-center py-3 px-4 h-16 bg-background-tint-01 xl:bg-transparent">
{/* Left - contains the icon-button to fold the AppSidebar on mobile */}
<div className="flex-1">
<IconButton
icon={SvgSidebar}
onClick={() => setFolded(false)}
className={cn(!isMobile && "invisible")}
internal
/>
</div>
{/* Center - contains the custom-header-content */}
<div className="flex-1 flex flex-col items-center overflow-hidden">
<Text
as="p"
text03
mainUiBody
className="text-center break-words w-full"
>
{customHeaderContent}
</Text>
</div>
{/* Right - contains the share and more-options buttons */}
<div className="flex-1 flex flex-row items-center justify-end px-1">
<Button
leftIcon={SvgShare}
transient={showShareModal}
tertiary
onClick={() => setShowShareModal(true)}
>
Share Chat
</Button>
<SimplePopover
trigger={
<IconButton
icon={SvgMoreHorizontal}
className="ml-2"
transient={popoverOpen}
tertiary
/>
}
onOpenChange={(state) => {
setPopoverOpen(state);
if (!state) setShowMoveOptions(false);
}}
side="bottom"
align="end"
>
<PopoverMenu>{popoverItems}</PopoverMenu>
</SimplePopover>
</div>
</div>
</>
);
}

View File

@@ -51,7 +51,10 @@ import {
useDocumentSidebarVisible,
} from "@/app/chat/stores/useChatSessionStore";
import FederatedOAuthModal from "@/components/chat/FederatedOAuthModal";
import ChatUI, { ChatUIHandle } from "@/sections/ChatUI";
import ChatScrollContainer, {
ChatScrollContainerHandle,
} from "@/components/chat/ChatScrollContainer";
import MessageList from "@/components/chat/MessageList";
import WelcomeMessage from "@/app/chat/components/WelcomeMessage";
import ProjectContextPanel from "@/app/chat/components/projects/ProjectContextPanel";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
@@ -66,7 +69,9 @@ import OnboardingFlow from "@/refresh-components/onboarding/OnboardingFlow";
import { OnboardingStep } from "@/refresh-components/onboarding/types";
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
import * as AppLayouts from "@/layouts/app-layouts";
import { SvgFileText } from "@opal/icons";
import { SvgChevronDown, SvgFileText } from "@opal/icons";
import ChatHeader from "@/app/chat/components/ChatHeader";
import IconButton from "@/refresh-components/buttons/IconButton";
import Spacer from "@/refresh-components/Spacer";
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
@@ -267,18 +272,17 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
settings,
});
const chatUiRef = useRef<ChatUIHandle>(null);
const autoScrollEnabled = user?.preferences?.auto_scroll ?? false;
const scrollContainerRef = useRef<ChatScrollContainerHandle>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
// Handle input bar height changes for scroll adjustment
const handleInputHeightChange = useCallback(
(delta: number) => {
if (autoScrollEnabled && delta > 0) {
chatUiRef.current?.scrollBy(delta);
}
},
[autoScrollEnabled]
);
// Reset scroll button when session changes
useEffect(() => {
setShowScrollButton(false);
}, [currentChatSessionId]);
const handleScrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollToBottom();
}, []);
const resetInputBar = useCallback(() => {
chatInputBarRef.current?.reset();
@@ -329,6 +333,15 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
);
const messageHistory = useCurrentMessageHistory();
// Determine anchor: second-to-last message (last user message before current response)
const anchorMessage = messageHistory.at(-2) ?? messageHistory[0];
const anchorNodeId = anchorMessage?.nodeId;
const anchorSelector = anchorNodeId ? `#message-${anchorNodeId}` : undefined;
// Auto-scroll preference from user settings
const autoScrollEnabled = user?.preferences?.auto_scroll !== false;
const isStreaming = currentChatState === "streaming";
const { onSubmit, stopGenerating, handleMessageSpecificFileUpload } =
useChatController({
filterManager,
@@ -580,7 +593,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
open
onOpenChange={() => updateCurrentDocumentSidebarVisible(false)}
>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgFileText}
title="Sources"
@@ -627,7 +640,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
>
{({ getRootProps }) => (
<div
className="h-full w-full flex flex-col items-center outline-none"
className="h-full w-full flex flex-col items-center outline-none relative"
{...getRootProps({ tabIndex: -1 })}
>
{/* ProjectUI */}
@@ -640,19 +653,31 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
)}
{/* ChatUI */}
{!!currentChatSessionId && (
<ChatUI
ref={chatUiRef}
liveAssistant={liveAssistant}
llmManager={llmManager}
deepResearchEnabled={deepResearchEnabled}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={setPresentingDocument}
onSubmit={onSubmit}
onMessageSelection={onMessageSelection}
stopGenerating={stopGenerating}
handleResubmitLastMessage={handleResubmitLastMessage}
/>
{!!currentChatSessionId && liveAssistant && (
<ChatScrollContainer
ref={scrollContainerRef}
sessionId={currentChatSessionId}
anchorSelector={anchorSelector}
autoScroll={autoScrollEnabled}
isStreaming={isStreaming}
onScrollButtonVisibilityChange={setShowScrollButton}
>
<AppLayouts.StickyHeader>
<ChatHeader />
</AppLayouts.StickyHeader>
<MessageList
liveAssistant={liveAssistant}
llmManager={llmManager}
deepResearchEnabled={deepResearchEnabled}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={setPresentingDocument}
onSubmit={onSubmit}
onMessageSelection={onMessageSelection}
stopGenerating={stopGenerating}
onResubmit={handleResubmitLastMessage}
anchorNodeId={anchorNodeId}
/>
</ChatScrollContainer>
)}
{!currentChatSessionId && !currentProjectId && (
@@ -665,58 +690,82 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
</div>
)}
{/* ChatInputBar container */}
<div className="w-[min(50rem,100%)] pointer-events-auto z-sticky flex flex-col px-4 justify-center items-center">
{(showOnboarding ||
(user?.role !== UserRole.ADMIN &&
!user?.personalization?.name)) &&
currentProjectId === null && (
<OnboardingFlow
handleHideOnboarding={hideOnboarding}
handleFinishOnboarding={finishOnboarding}
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
/>
{/* ChatInputBar container - absolutely positioned when in chat, centered when no session */}
<div
className={cn(
"flex justify-center",
currentChatSessionId
? "absolute bottom-0 left-0 right-0 pointer-events-none"
: "w-full"
)}
>
<div
className={cn(
"w-[min(50rem,100%)] z-sticky flex flex-col px-4",
currentChatSessionId && "pointer-events-auto"
)}
>
{/* Scroll to bottom button - positioned above ChatInputBar */}
{showScrollButton && (
<div className="mb-2 self-center">
<IconButton
icon={SvgChevronDown}
onClick={handleScrollToBottom}
aria-label="Scroll to bottom"
/>
</div>
)}
<ChatInputBar
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={toggleDocumentSidebar}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => setSelectedDocuments([])}
retrievalEnabled={retrievalEnabled}
selectedDocuments={selectedDocuments}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) || ""
}
stopGenerating={stopGenerating}
onSubmit={handleChatInputSubmit}
onHeightChange={handleInputHeightChange}
chatState={currentChatState}
currentSessionFileTokenCount={
currentChatSessionId
? currentSessionFileTokenCount
: projectContextTokenCount
}
availableContextTokens={availableContextTokens}
selectedAssistant={selectedAssistant || liveAssistant}
handleFileUpload={handleMessageSpecificFileUpload}
setPresentingDocument={setPresentingDocument}
disabled={
(!llmManager.isLoadingProviders &&
llmManager.hasAnyProvider === false) ||
(!isLoadingOnboarding &&
onboardingState.currentStep !== OnboardingStep.Complete)
}
/>
{(showOnboarding ||
(user?.role !== UserRole.ADMIN &&
!user?.personalization?.name)) &&
currentProjectId === null && (
<OnboardingFlow
handleHideOnboarding={hideOnboarding}
handleFinishOnboarding={finishOnboarding}
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
/>
)}
<Spacer rem={0.5} />
<ChatInputBar
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={toggleDocumentSidebar}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => setSelectedDocuments([])}
retrievalEnabled={retrievalEnabled}
selectedDocuments={selectedDocuments}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) || ""
}
stopGenerating={stopGenerating}
onSubmit={handleChatInputSubmit}
chatState={currentChatState}
currentSessionFileTokenCount={
currentChatSessionId
? currentSessionFileTokenCount
: projectContextTokenCount
}
availableContextTokens={availableContextTokens}
selectedAssistant={selectedAssistant || liveAssistant}
handleFileUpload={handleMessageSpecificFileUpload}
setPresentingDocument={setPresentingDocument}
disabled={
(!llmManager.isLoadingProviders &&
llmManager.hasAnyProvider === false) ||
(!isLoadingOnboarding &&
onboardingState.currentStep !== OnboardingStep.Complete)
}
/>
{!!currentProjectId && <ProjectChatSessionList />}
<Spacer rem={0.5} />
{!!currentProjectId && <ProjectChatSessionList />}
</div>
</div>
{/* SearchUI */}

View File

@@ -73,7 +73,7 @@ export function ChatPopup() {
return (
<Modal open onOpenChange={() => {}}>
<Modal.Content tall>
<Modal.Content width="sm" height="lg">
<Modal.Header
titleClassName="text-text-04"
icon={headerIcon}

View File

@@ -38,6 +38,8 @@ import {
} from "@/app/chat/services/actionUtils";
import { SvgArrowUp, SvgHourglass, SvgPlusCircle, SvgStop } from "@opal/icons";
const LINE_HEIGHT = 24;
const MIN_INPUT_HEIGHT = 44;
const MAX_INPUT_HEIGHT = 200;
export interface SourceChipProps {
@@ -90,7 +92,6 @@ export interface ChatInputBarProps {
initialMessage?: string;
stopGenerating: () => void;
onSubmit: (message: string) => void;
onHeightChange?: (delta: number) => void;
llmManager: LlmManager;
chatState: ChatState;
currentSessionFileTokenCount: number;
@@ -121,7 +122,6 @@ const ChatInputBar = React.memo(
initialMessage = "",
stopGenerating,
onSubmit,
onHeightChange,
chatState,
currentSessionFileTokenCount,
availableContextTokens,
@@ -141,9 +141,6 @@ const ChatInputBar = React.memo(
const [message, setMessage] = useState(initialMessage);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number | null>(null);
const onHeightChangeRef = useRef(onHeightChange);
onHeightChangeRef.current = onHeightChange;
// Expose reset and focus methods to parent via ref
React.useImperativeHandle(ref, () => ({
@@ -198,15 +195,37 @@ const ChatInputBar = React.memo(
const combinedSettings = useContext(SettingsContext);
// Track previous message to detect when lines might decrease
const prevMessageRef = useRef("");
// Auto-resize textarea based on content
useEffect(() => {
const textarea = textAreaRef.current;
if (textarea) {
textarea.style.height = "0px"; // this is necessary in order to "reset" the scrollHeight
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
const prevLineCount = (prevMessageRef.current.match(/\n/g) || [])
.length;
const currLineCount = (message.match(/\n/g) || []).length;
const lineRemoved = currLineCount < prevLineCount;
prevMessageRef.current = message;
if (message.length === 0) {
textarea.style.height = `${MIN_INPUT_HEIGHT}px`;
return;
} else if (lineRemoved) {
const linesRemoved = prevLineCount - currLineCount;
textarea.style.height = `${Math.max(
MIN_INPUT_HEIGHT,
Math.min(
textarea.scrollHeight - LINE_HEIGHT * linesRemoved,
MAX_INPUT_HEIGHT
)
)}px`;
} else {
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}
}
}, [message]);
@@ -216,27 +235,6 @@ const ChatInputBar = React.memo(
}
}, [initialMessage]);
// Detect height changes and notify parent for scroll adjustment
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
if (previousHeightRef.current !== null) {
const delta = newHeight - previousHeightRef.current;
if (delta !== 0) {
onHeightChangeRef.current?.(delta);
}
}
previousHeightRef.current = newHeight;
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const handlePaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items;
if (items) {

View File

@@ -93,7 +93,7 @@ export default function FeedbackModal({
{popup}
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
<Modal.Content mini>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={icon}
title="Provide Additional Feedback"

View File

@@ -643,6 +643,7 @@ export function useChatController({
let toolCall: ToolCallMetadata | null = null;
let files = projectFilesToFileDescriptors(currentMessageFiles);
let packets: Packet[] = [];
let packetsVersion = 0;
let newUserMessageId: number | null = null;
let newAssistantMessageId: number | null = null;
@@ -729,7 +730,6 @@ export function useChatController({
if (!packet) {
continue;
}
console.debug("Packet:", JSON.stringify(packet));
// We've processed initial packets and are starting to stream content.
// Transition from 'loading' to 'streaming'.
@@ -800,8 +800,8 @@ export function useChatController({
updateCanContinue(true, frozenSessionId);
}
} else if (Object.hasOwn(packet, "obj")) {
console.debug("Object packet:", JSON.stringify(packet));
packets.push(packet as Packet);
packetsVersion++;
// Check if the packet contains document information
const packetObj = (packet as Packet).obj;
@@ -859,6 +859,7 @@ export function useChatController({
overridden_model: finalMessage?.overridden_model,
stopReason: stopReason,
packets: packets,
packetsVersion: packetsVersion,
},
],
// Pass the latest map state

View File

@@ -139,6 +139,8 @@ export interface Message {
// new gen
packets: Packet[];
// Version counter for efficient memo comparison (increments with each packet)
packetsVersion?: number;
// cached values for easy access
documents?: OnyxDocument[] | null;

View File

@@ -68,7 +68,7 @@ export const CodeBlock = memo(function CodeBlock({
"bg-background-tint-00",
"rounded",
"text-xs",
"inline-block",
"inline",
"whitespace-pre-wrap",
"break-words",
"py-0.5",

View File

@@ -10,8 +10,7 @@ import IconButton from "@/refresh-components/buttons/IconButton";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Button from "@/refresh-components/buttons/Button";
import { SvgEdit } from "@opal/icons";
import FileDisplay from "@/app/chat/message/FileDisplay";
import { useTripleClickSelect } from "@/hooks/useTripleClickSelect";
import FileDisplay from "./FileDisplay";
interface MessageEditingProps {
content: string;
@@ -140,10 +139,6 @@ const HumanMessage = React.memo(function HumanMessage({
const [isEditing, setIsEditing] = useState(false);
// Ref for the text content element (for triple-click selection)
const textContentRef = useRef<HTMLDivElement>(null);
const handleTripleClick = useTripleClickSelect(textContentRef);
// Use nodeId for switching (finding position in siblings)
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
// indexOf returns -1 if not found, treat that as undefined
@@ -200,34 +195,18 @@ const HumanMessage = React.memo(function HumanMessage({
<>
<div className="md:max-w-[25rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
<div
ref={textContentRef}
className={
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3 cursor-text"
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
onMouseDown={handleTripleClick}
onCopy={(e) => {
e.preventDefault();
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
e.clipboardData.setData("text/plain", content);
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
// Check if selection is within this element using DOM containment
if (
textContentRef.current?.contains(
range.commonAncestorContainer
)
) {
e.clipboardData.setData("text/plain", selectedText);
} else {
e.clipboardData.setData("text/plain", content);
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
}}
>

View File

@@ -62,7 +62,6 @@ import { usePopup } from "@/components/admin/connectors/Popup";
import { useFeedbackController } from "../../hooks/useFeedbackController";
import { SvgThumbsDown, SvgThumbsUp } from "@opal/icons";
import Text from "@/refresh-components/texts/Text";
import { useTripleClickSelect } from "@/hooks/useTripleClickSelect";
// Type for the regeneration factory function passed from ChatUI
export type RegenerationFactory = (regenerationRequest: {
@@ -73,6 +72,8 @@ export type RegenerationFactory = (regenerationRequest: {
export interface AIMessageProps {
rawPackets: Packet[];
// Version counter for efficient memo comparison (avoids array copying)
packetsVersion?: number;
chatState: FullChatState;
nodeId: number;
messageId?: number;
@@ -87,8 +88,6 @@ export interface AIMessageProps {
}
// TODO: Consider more robust comparisons:
// - `rawPackets.length` assumes packets are append-only. Could compare the last
// packet or use a shallow comparison if packets can be modified in place.
// - `chatState.docs`, `chatState.citations`, and `otherMessagesCanSwitchTo` use
// reference equality. Shallow array/object comparison would be more robust if
// these are recreated with the same values.
@@ -97,7 +96,7 @@ function arePropsEqual(prev: AIMessageProps, next: AIMessageProps): boolean {
prev.nodeId === next.nodeId &&
prev.messageId === next.messageId &&
prev.currentFeedback === next.currentFeedback &&
prev.rawPackets.length === next.rawPackets.length &&
prev.packetsVersion === next.packetsVersion &&
prev.chatState.assistant?.id === next.chatState.assistant?.id &&
prev.chatState.docs === next.chatState.docs &&
prev.chatState.citations === next.chatState.citations &&
@@ -126,7 +125,6 @@ const AIMessage = React.memo(function AIMessage({
}: AIMessageProps) {
const markdownRef = useRef<HTMLDivElement>(null);
const finalAnswerRef = useRef<HTMLDivElement>(null);
const handleTripleClick = useTripleClickSelect(markdownRef);
const { popup, setPopup } = usePopup();
const { handleFeedbackChange } = useFeedbackController({ setPopup });
@@ -538,8 +536,7 @@ const AIMessage = React.memo(function AIMessage({
<div className="max-w-message-max break-words pl-4 w-full">
<div
ref={markdownRef}
className="overflow-x-visible max-w-content-max focus:outline-none select-text cursor-text"
onMouseDown={handleTripleClick}
className="overflow-x-visible max-w-content-max focus:outline-none select-text"
onCopy={(e) => {
if (markdownRef.current) {
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);

View File

@@ -29,7 +29,7 @@ import {
useCurrentChatState,
useCurrentMessageHistory,
} from "@/app/chat/stores/useChatSessionStore";
import ChatUI from "@/sections/ChatUI";
import MessageList from "@/components/chat/MessageList";
import useChatSessions from "@/hooks/useChatSessions";
import { cn } from "@/lib/utils";
import Logo from "@/refresh-components/Logo";
@@ -350,17 +350,19 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* Scrollable messages area */}
<div className="nrf-messages-scroll">
<div className="nrf-messages-content">
<ChatUI
liveAssistant={resolvedAssistant}
llmManager={llmManager}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={() => {}}
onSubmit={onSubmit}
onMessageSelection={() => {}}
stopGenerating={stopGenerating}
handleResubmitLastMessage={handleResubmitLastMessage}
deepResearchEnabled={deepResearchEnabled}
/>
{resolvedAssistant && (
<MessageList
liveAssistant={resolvedAssistant}
llmManager={llmManager}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={() => {}}
onSubmit={onSubmit}
onMessageSelection={() => {}}
stopGenerating={stopGenerating}
onResubmit={handleResubmitLastMessage}
deepResearchEnabled={deepResearchEnabled}
/>
)}
</div>
</div>
@@ -461,7 +463,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
/>
<Modal open={showTurnOffModal} onOpenChange={setShowTurnOffModal}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="Turn off Onyx new tab page?"
@@ -483,7 +485,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{!user && authTypeMetadata.authType !== AuthType.DISABLED && (
<Modal open onOpenChange={() => {}}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgUser} title="Welcome to Onyx" />
<Modal.Body>
{authTypeMetadata.authType === AuthType.BASIC ? (

View File

@@ -4,8 +4,7 @@ import {
StreamStopInfo,
} from "@/lib/search/interfaces";
import { handleSSEStream } from "@/lib/search/streamingUtils";
import { ChatState, FeedbackType } from "@/app/chat/interfaces";
import { MutableRefObject, RefObject, useEffect, useRef } from "react";
import { FeedbackType } from "@/app/chat/interfaces";
import {
BackendMessage,
DocumentsResponse,
@@ -457,104 +456,3 @@ export async function uploadFilesForChat(
return [responseJson.files as FileDescriptor[], null];
}
export function useScrollonStream({
chatState,
scrollableDivRef,
scrollDist,
endDivRef,
debounceNumber,
mobile,
enableAutoScroll,
}: {
chatState: ChatState;
scrollableDivRef: RefObject<HTMLDivElement | null>;
scrollDist: MutableRefObject<number>;
endDivRef: RefObject<HTMLDivElement | null>;
debounceNumber: number;
mobile?: boolean;
enableAutoScroll?: boolean;
}) {
const mobileDistance = 900; // distance that should "engage" the scroll
const desktopDistance = 500; // distance that should "engage" the scroll
const distance = mobile ? mobileDistance : desktopDistance;
const preventScrollInterference = useRef<boolean>(false);
const preventScroll = useRef<boolean>(false);
const blockActionRef = useRef<boolean>(false);
const previousScroll = useRef<number>(0);
useEffect(() => {
if (!enableAutoScroll) {
return;
}
if (chatState != "input" && scrollableDivRef && scrollableDivRef.current) {
const newHeight: number = scrollableDivRef.current?.scrollTop!;
const heightDifference = newHeight - previousScroll.current;
previousScroll.current = newHeight;
// Prevent streaming scroll
if (heightDifference < 0 && !preventScroll.current) {
scrollableDivRef.current.style.scrollBehavior = "auto";
scrollableDivRef.current.scrollTop = scrollableDivRef.current.scrollTop;
scrollableDivRef.current.style.scrollBehavior = "smooth";
preventScrollInterference.current = true;
preventScroll.current = true;
setTimeout(() => {
preventScrollInterference.current = false;
}, 2000);
setTimeout(() => {
preventScroll.current = false;
}, 10000);
}
// Ensure can scroll if scroll down
else if (!preventScrollInterference.current) {
preventScroll.current = false;
}
if (
scrollDist.current < distance &&
!blockActionRef.current &&
!blockActionRef.current &&
!preventScroll.current &&
endDivRef &&
endDivRef.current
) {
// catch up if necessary!
const scrollAmount = scrollDist.current + (mobile ? 1000 : 10000);
if (scrollDist.current > 300) {
// if (scrollDist.current > 140) {
endDivRef.current.scrollIntoView();
} else {
blockActionRef.current = true;
scrollableDivRef?.current?.scrollBy({
left: 0,
top: Math.max(0, scrollAmount),
behavior: "smooth",
});
setTimeout(() => {
blockActionRef.current = false;
}, debounceNumber);
}
}
}
});
// scroll on end of stream if within distance
useEffect(() => {
if (scrollableDivRef?.current && chatState == "input" && enableAutoScroll) {
if (scrollDist.current < distance - 50) {
scrollableDivRef?.current?.scrollBy({
left: 0,
top: Math.max(scrollDist.current + 600, 0),
behavior: "smooth",
});
}
}
}, [chatState, distance, scrollDist, scrollableDivRef, enableAutoScroll]);
}

View File

@@ -35,7 +35,7 @@ export default function UserGroupCreationForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgUsers}
title={isUpdate ? "Update a User Group" : "Create a new User Group"}

View File

@@ -33,7 +33,7 @@ export default function AddConnectorForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgPlus}
title="Add New Connector"

View File

@@ -22,7 +22,7 @@ export default function AddMemberForm({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgUserPlus}
title="Add New User"

View File

@@ -4,8 +4,9 @@ import { use } from "react";
import { GroupDisplay } from "./GroupDisplay";
import { useSpecificUserGroup } from "./hook";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus, useUsers } from "@/lib/hooks";
import { useConnectorStatus } from "@/lib/hooks";
import { useRouter } from "next/navigation";
import useUsers from "@/hooks/useUsers";
import BackButton from "@/refresh-components/buttons/BackButton";
import { AdminPageTitle } from "@/components/admin/Title";
import { SvgUsers } from "@opal/icons";

View File

@@ -5,8 +5,9 @@ import UserGroupCreationForm from "./UserGroupCreationForm";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorStatus, useUserGroups, useUsers } from "@/lib/hooks";
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
import { AdminPageTitle } from "@/components/admin/Title";
import useUsers from "@/hooks/useUsers";
import { useUser } from "@/components/user/UserProvider";
import CreateButton from "@/refresh-components/buttons/CreateButton";

View File

@@ -178,7 +178,7 @@ function PreviousQueryHistoryExportsModal({
return (
<Modal open onOpenChange={() => setShowModal(false)}>
<Modal.Content large>
<Modal.Content width="lg" height="full">
<Modal.Header
icon={SvgFileText}
title="Previous Query History Exports"

View File

@@ -30,13 +30,10 @@ export default function SourceTile({
w-40
cursor-pointer
shadow-md
bg-background-tint-00
hover:bg-background-tint-02
relative
${
preSelect
? "bg-background-tint-01 subtle-pulse"
: "bg-background-tint-00"
}
${preSelect ? "subtle-pulse" : ""}
`}
href={navigationUrl as Route}
>

View File

@@ -56,7 +56,7 @@ export default function ResetPasswordModal({
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgKey}
title="Reset Password"

View File

@@ -0,0 +1,420 @@
"use client";
import React, {
ForwardedRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
// Size constants
const DEFAULT_ANCHOR_OFFSET_PX = 16; // 1rem
const DEFAULT_FADE_THRESHOLD_PX = 80; // 5rem
const DEFAULT_BUTTON_THRESHOLD_PX = 32; // 2rem
const SCROLL_DEBOUNCE_MS = 100;
const FADE_OVERLAY_HEIGHT = "h-8"; // 2rem
export interface ScrollState {
isAtBottom: boolean;
hasContentAbove: boolean;
hasContentBelow: boolean;
}
export interface ChatScrollContainerHandle {
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
export interface ChatScrollContainerProps {
children: React.ReactNode;
/**
* CSS selector for the element to anchor at top (e.g., "#message-123")
* When set, positions this element at top with spacer below content
*/
anchorSelector?: string;
/** Enable auto-scroll behavior (follow new content) */
autoScroll?: boolean;
/** Whether content is currently streaming (affects scroll button visibility) */
isStreaming?: boolean;
/** Callback when scroll button visibility should change */
onScrollButtonVisibilityChange?: (visible: boolean) => void;
/** Session ID - resets scroll state when changed */
sessionId?: string;
}
const FadeOverlay = React.memo(
({ show, position }: { show: boolean; position: "top" | "bottom" }) => {
if (!show) return null;
const isTop = position === "top";
return (
<div
aria-hidden="true"
className={`absolute left-0 right-0 ${FADE_OVERLAY_HEIGHT} z-sticky pointer-events-none ${
isTop ? "top-0" : "bottom-0"
}`}
style={{
background: `linear-gradient(${
isTop ? "to bottom" : "to top"
}, var(--background-tint-01) 0%, transparent 100%)`,
}}
/>
);
}
);
FadeOverlay.displayName = "FadeOverlay";
const ChatScrollContainer = React.memo(
React.forwardRef(
(
{
children,
anchorSelector,
autoScroll = true,
isStreaming = false,
onScrollButtonVisibilityChange,
sessionId,
}: ChatScrollContainerProps,
ref: ForwardedRef<ChatScrollContainerHandle>
) => {
const anchorOffsetPx = DEFAULT_ANCHOR_OFFSET_PX;
const fadeThresholdPx = DEFAULT_FADE_THRESHOLD_PX;
const buttonThresholdPx = DEFAULT_BUTTON_THRESHOLD_PX;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const endDivRef = useRef<HTMLDivElement>(null);
const scrolledForSessionRef = useRef<string | null>(null);
const prevAnchorSelectorRef = useRef<string | null>(null);
const [spacerHeight, setSpacerHeight] = useState(0);
const [hasContentAbove, setHasContentAbove] = useState(false);
const [hasContentBelow, setHasContentBelow] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true); // Ref for use in callbacks
const isAutoScrollingRef = useRef(false); // Prevent handleScroll from interfering during auto-scroll
const prevScrollTopRef = useRef(0); // Track scroll position to detect scroll direction
const [isScrollReady, setIsScrollReady] = useState(false);
// Use refs for values that change during streaming to prevent effect re-runs
const onScrollButtonVisibilityChangeRef = useRef(
onScrollButtonVisibilityChange
);
onScrollButtonVisibilityChangeRef.current =
onScrollButtonVisibilityChange;
const autoScrollRef = useRef(autoScroll);
autoScrollRef.current = autoScroll;
const isStreamingRef = useRef(isStreaming);
isStreamingRef.current = isStreaming;
// Calculate spacer height to position anchor at top
const calcSpacerHeight = useCallback(
(anchorElement: HTMLElement): number => {
if (!endDivRef.current || !scrollContainerRef.current) return 0;
const contentEnd = endDivRef.current.offsetTop;
const contentFromAnchor = contentEnd - anchorElement.offsetTop;
return Math.max(
0,
scrollContainerRef.current.clientHeight -
contentFromAnchor -
anchorOffsetPx
);
},
[anchorOffsetPx]
);
// Get current scroll state
const getScrollState = useCallback((): ScrollState => {
const container = scrollContainerRef.current;
if (!container || !endDivRef.current) {
return {
isAtBottom: true,
hasContentAbove: false,
hasContentBelow: false,
};
}
const contentEnd = endDivRef.current.offsetTop;
const viewportBottom = container.scrollTop + container.clientHeight;
const contentBelowViewport = contentEnd - viewportBottom;
return {
isAtBottom: contentBelowViewport <= buttonThresholdPx,
hasContentAbove: container.scrollTop > fadeThresholdPx,
hasContentBelow: contentBelowViewport > fadeThresholdPx,
};
}, [buttonThresholdPx, fadeThresholdPx]);
// Update scroll state and notify parent about button visibility
const updateScrollState = useCallback(() => {
const state = getScrollState();
setIsAtBottom(state.isAtBottom);
isAtBottomRef.current = state.isAtBottom; // Keep ref in sync
setHasContentAbove(state.hasContentAbove);
setHasContentBelow(state.hasContentBelow);
// Show button when user is not at bottom (e.g., scrolled up)
onScrollButtonVisibilityChangeRef.current?.(!state.isAtBottom);
}, [getScrollState]);
// Scroll to bottom of content
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = "smooth") => {
const container = scrollContainerRef.current;
if (!container || !endDivRef.current) return;
// Mark as auto-scrolling to prevent handleScroll interference
isAutoScrollingRef.current = true;
// Use scrollTo instead of scrollIntoView for better cross-browser support
const targetScrollTop =
container.scrollHeight - container.clientHeight;
container.scrollTo({ top: targetScrollTop, behavior });
// Update tracking refs
prevScrollTopRef.current = targetScrollTop;
isAtBottomRef.current = true;
// For smooth scrolling, keep isAutoScrollingRef true longer
if (behavior === "smooth") {
// Clear after animation likely completes (Safari smooth scroll is ~500ms)
setTimeout(() => {
isAutoScrollingRef.current = false;
if (container) {
prevScrollTopRef.current = container.scrollTop;
}
}, 600);
} else {
isAutoScrollingRef.current = false;
}
},
[]
);
// Expose scrollToBottom via ref
useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]);
// Re-evaluate button visibility when at-bottom state changes
useEffect(() => {
onScrollButtonVisibilityChangeRef.current?.(!isAtBottom);
}, [isAtBottom]);
// Handle scroll events (user scrolls)
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
// Skip if this scroll was triggered by auto-scroll
if (isAutoScrollingRef.current) return;
const currentScrollTop = container.scrollTop;
const scrolledUp = currentScrollTop < prevScrollTopRef.current - 5; // 5px threshold to ignore micro-movements
prevScrollTopRef.current = currentScrollTop;
// Only update isAtBottomRef when user explicitly scrolls UP
// This prevents content growth or programmatic scrolls from disabling auto-scroll
if (scrolledUp) {
updateScrollState();
} else {
// Still update fade overlays, but preserve isAtBottomRef
const state = getScrollState();
setHasContentAbove(state.hasContentAbove);
setHasContentBelow(state.hasContentBelow);
// Update button visibility based on actual position
onScrollButtonVisibilityChangeRef.current?.(!state.isAtBottom);
}
// Recalculate spacer for non-auto-scroll mode during user scroll
if (!autoScrollRef.current && anchorSelector && endDivRef.current) {
const anchorElement = container.querySelector(
anchorSelector
) as HTMLElement;
if (anchorElement) {
setSpacerHeight(calcSpacerHeight(anchorElement));
}
}
}, [anchorSelector, calcSpacerHeight, updateScrollState, getScrollState]);
// Watch for content changes (MutationObserver + ResizeObserver)
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
let rafId: number | null = null;
const onContentChange = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = null;
// Capture whether we were at bottom BEFORE content changed
const wasAtBottom = isAtBottomRef.current;
// Update spacer for non-auto-scroll mode
if (!autoScrollRef.current && anchorSelector) {
const anchorElement = container.querySelector(
anchorSelector
) as HTMLElement;
if (anchorElement) {
setSpacerHeight(calcSpacerHeight(anchorElement));
}
}
// Auto-scroll: follow content if we were at bottom
if (autoScrollRef.current && wasAtBottom) {
// scrollToBottom handles isAutoScrollingRef and ref updates
scrollToBottom("instant");
}
updateScrollState();
});
};
// MutationObserver for content changes
const mutationObserver = new MutationObserver(onContentChange);
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
// ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(onContentChange);
resizeObserver.observe(container);
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
if (rafId) cancelAnimationFrame(rafId);
};
}, [anchorSelector, calcSpacerHeight, updateScrollState, scrollToBottom]);
// Handle session changes and anchor changes
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const isNewSession =
scrolledForSessionRef.current !== null &&
scrolledForSessionRef.current !== sessionId;
const isNewAnchor = prevAnchorSelectorRef.current !== anchorSelector;
// Reset on session change
if (isNewSession) {
scrolledForSessionRef.current = null;
setIsScrollReady(false);
prevScrollTopRef.current = 0;
isAtBottomRef.current = true;
}
const shouldScroll =
(scrolledForSessionRef.current !== sessionId || isNewAnchor) &&
anchorSelector;
if (!shouldScroll) {
prevAnchorSelectorRef.current = anchorSelector ?? null;
return;
}
const anchorElement = container.querySelector(
anchorSelector!
) as HTMLElement;
if (!anchorElement || !endDivRef.current) {
setIsScrollReady(true);
scrolledForSessionRef.current = sessionId ?? null;
prevAnchorSelectorRef.current = anchorSelector ?? null;
return;
}
// Calculate spacer
if (!autoScrollRef.current) {
setSpacerHeight(calcSpacerHeight(anchorElement));
} else {
setSpacerHeight(0);
}
// Determine scroll behavior
// New session with existing content = instant, new anchor = smooth
const isLoadingExistingContent =
isNewSession || scrolledForSessionRef.current === null;
const behavior: ScrollBehavior = isLoadingExistingContent
? "instant"
: "smooth";
// Defer scroll to next tick so spacer height takes effect
const timeoutId = setTimeout(() => {
const targetScrollTop = Math.max(
0,
anchorElement.offsetTop - anchorOffsetPx
);
container.scrollTo({ top: targetScrollTop, behavior });
// Update prevScrollTopRef so scroll direction is measured from new position
prevScrollTopRef.current = targetScrollTop;
updateScrollState();
// When autoScroll is on, assume we're "at bottom" after positioning
// so that MutationObserver will continue auto-scrolling
if (autoScrollRef.current) {
isAtBottomRef.current = true;
}
setIsScrollReady(true);
scrolledForSessionRef.current = sessionId ?? null;
prevAnchorSelectorRef.current = anchorSelector ?? null;
}, 0);
return () => clearTimeout(timeoutId);
}, [
sessionId,
anchorSelector,
anchorOffsetPx,
calcSpacerHeight,
updateScrollState,
]);
return (
<div className="flex flex-col flex-1 min-h-0 w-full relative overflow-hidden mb-[7.5rem]">
<FadeOverlay show={hasContentAbove} position="top" />
<FadeOverlay show={hasContentBelow} position="bottom" />
<div
key={sessionId}
ref={scrollContainerRef}
className="flex flex-1 justify-center min-h-0 overflow-y-auto overflow-x-hidden default-scrollbar"
onScroll={handleScroll}
style={{
scrollbarGutter: "stable both-edges",
}}
>
<div
className="w-full flex flex-col items-center"
data-scroll-ready={isScrollReady}
style={{
visibility: isScrollReady ? "visible" : "hidden",
}}
>
{children}
{/* End marker - before spacer so we can measure content end */}
<div ref={endDivRef} />
{/* Spacer to allow scrolling anchor to top */}
{spacerHeight > 0 && (
<div style={{ height: spacerHeight }} aria-hidden="true" />
)}
</div>
</div>
</div>
);
}
)
);
ChatScrollContainer.displayName = "ChatScrollContainer";
export default ChatScrollContainer;

View File

@@ -128,7 +128,7 @@ export default function FederatedOAuthModal() {
return (
<Modal open>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgLink}
title="Connect Your Apps"

View File

@@ -153,7 +153,7 @@ export default function MCPApiKeyModal({
const credsType = isTemplateMode ? "Credentials" : "API Key";
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgKey}
title={isAuthenticated ? `Manage ${credsType}` : `Enter ${credsType}`}

View File

@@ -0,0 +1,229 @@
"use client";
import React, { useCallback, useMemo, useRef } from "react";
import { Message } from "@/app/chat/interfaces";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
import HumanMessage from "@/app/chat/message/HumanMessage";
import { ErrorBanner } from "@/app/chat/message/Resubmit";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import AIMessage from "@/app/chat/message/messageComponents/AIMessage";
import Spacer from "@/refresh-components/Spacer";
import {
useCurrentMessageHistory,
useCurrentMessageTree,
useLoadingError,
useUncaughtError,
} from "@/app/chat/stores/useChatSessionStore";
export interface MessageListProps {
liveAssistant: MinimalPersonaSnapshot;
llmManager: LlmManager;
setPresentingDocument: (doc: MinimalOnyxDocument | null) => void;
onMessageSelection: (nodeId: number) => void;
stopGenerating: () => void;
// Submit handlers
onSubmit: (args: {
message: string;
messageIdToResend?: number;
currentMessageFiles: any[];
deepResearch: boolean;
modelOverride?: LlmDescriptor;
regenerationRequest?: {
messageId: number;
parentMessage: Message;
forceSearch?: boolean;
};
forceSearch?: boolean;
}) => Promise<void>;
deepResearchEnabled: boolean;
currentMessageFiles: any[];
onResubmit: () => void;
/**
* Node ID of the message to use as scroll anchor.
* This message will get a data-anchor attribute for ChatScrollContainer.
*/
anchorNodeId?: number;
}
const MessageList = React.memo(
({
liveAssistant,
llmManager,
setPresentingDocument,
onMessageSelection,
stopGenerating,
onSubmit,
deepResearchEnabled,
currentMessageFiles,
onResubmit,
anchorNodeId,
}: MessageListProps) => {
// Get messages and error state from store
const messages = useCurrentMessageHistory();
const messageTree = useCurrentMessageTree();
const error = useUncaughtError();
const loadError = useLoadingError();
// Stable fallbacks to avoid changing prop identities on each render
const emptyDocs = useMemo<OnyxDocument[]>(() => [], []);
const emptyChildrenIds = useMemo<number[]>(() => [], []);
// Use refs to keep callbacks stable while always using latest values
const onSubmitRef = useRef(onSubmit);
const deepResearchEnabledRef = useRef(deepResearchEnabled);
const currentMessageFilesRef = useRef(currentMessageFiles);
onSubmitRef.current = onSubmit;
deepResearchEnabledRef.current = deepResearchEnabled;
currentMessageFilesRef.current = currentMessageFiles;
const createRegenerator = useCallback(
(regenerationRequest: {
messageId: number;
parentMessage: Message;
forceSearch?: boolean;
}) => {
return async function (modelOverride: LlmDescriptor) {
return await onSubmitRef.current({
message: regenerationRequest.parentMessage.message,
currentMessageFiles: currentMessageFilesRef.current,
deepResearch: deepResearchEnabledRef.current,
modelOverride,
messageIdToResend: regenerationRequest.parentMessage.messageId,
regenerationRequest,
forceSearch: regenerationRequest.forceSearch,
});
};
},
[]
);
const handleEditWithMessageId = useCallback(
(editedContent: string, msgId: number) => {
onSubmitRef.current({
message: editedContent,
messageIdToResend: msgId,
currentMessageFiles: [],
deepResearch: deepResearchEnabledRef.current,
});
},
[]
);
return (
<div className="w-[min(50rem,100%)] px-6">
<Spacer />
{messages.map((message, i) => {
const messageReactComponentKey = `message-${message.nodeId}`;
const parentMessage = message.parentNodeId
? messageTree?.get(message.parentNodeId)
: null;
const isAnchor = message.nodeId === anchorNodeId;
if (message.type === "user") {
const nextMessage =
messages.length > i + 1 ? messages[i + 1] : null;
return (
<div
id={messageReactComponentKey}
key={messageReactComponentKey}
data-anchor={isAnchor ? "true" : undefined}
>
<HumanMessage
disableSwitchingForStreaming={
(nextMessage && nextMessage.is_generating) || false
}
stopGenerating={stopGenerating}
content={message.message}
files={message.files}
messageId={message.messageId}
nodeId={message.nodeId}
onEdit={handleEditWithMessageId}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
/>
</div>
);
} else if (message.type === "assistant") {
if ((error || loadError) && i === messages.length - 1) {
return (
<div key={`error-${message.nodeId}`} className="p-4">
<ErrorBanner
resubmit={onResubmit}
error={error || loadError || ""}
errorCode={message.errorCode || undefined}
isRetryable={message.isRetryable ?? true}
details={message.errorDetails || undefined}
stackTrace={message.stackTrace || undefined}
/>
</div>
);
}
const previousMessage = i !== 0 ? messages[i - 1] : null;
const chatStateData = {
assistant: liveAssistant,
docs: message.documents ?? emptyDocs,
citations: message.citations,
setPresentingDocument,
overriddenModel: llmManager.currentLlm?.modelName,
researchType: message.researchType,
};
return (
<div
id={`message-${message.nodeId}`}
key={messageReactComponentKey}
data-anchor={isAnchor ? "true" : undefined}
>
<AIMessage
rawPackets={message.packets}
packetsVersion={message.packetsVersion}
chatState={chatStateData}
nodeId={message.nodeId}
messageId={message.messageId}
currentFeedback={message.currentFeedback}
llmManager={llmManager}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
onRegenerate={createRegenerator}
parentMessage={previousMessage}
/>
</div>
);
}
return null;
})}
{/* Error banner when last message is user message or error type */}
{(((error !== null || loadError !== null) &&
messages[messages.length - 1]?.type === "user") ||
messages[messages.length - 1]?.type === "error") && (
<div className="p-4">
<ErrorBanner
resubmit={onResubmit}
error={error || loadError || ""}
errorCode={messages[messages.length - 1]?.errorCode || undefined}
isRetryable={messages[messages.length - 1]?.isRetryable ?? true}
details={messages[messages.length - 1]?.errorDetails || undefined}
stackTrace={
messages[messages.length - 1]?.stackTrace || undefined
}
/>
</div>
)}
</div>
);
}
);
MessageList.displayName = "MessageList";
export default MessageList;

View File

@@ -201,7 +201,8 @@ export default function TextView({
}}
>
<Modal.Content
large
width="lg"
height="full"
preventAccidentalClose={false}
onOpenAutoFocus={(e) => e.preventDefault()}
>

View File

@@ -244,7 +244,7 @@ export default function CredentialSection({
{showModifyCredential && (
<Modal open onOpenChange={closeModifyCredential}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgEdit}
title="Update Credentials"
@@ -272,7 +272,7 @@ export default function CredentialSection({
{editingCredential && (
<Modal open onOpenChange={closeEditingCredential}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgEdit}
title="Edit Credential"
@@ -292,7 +292,7 @@ export default function CredentialSection({
{showCreateCredential && (
<Modal open onOpenChange={closeCreateCredential}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgKey}
title={`Create ${getSourceDisplayName(sourceType)} Credential`}

View File

@@ -190,7 +190,7 @@ export default function ModifyCredential({
<>
{confirmDeletionCredential != null && (
<Modal open onOpenChange={() => setConfirmDeletionCredential(null)}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgAlertTriangle}
title="Confirm Deletion"

View File

@@ -185,7 +185,7 @@ export const HealthCheckBanner = () => {
if (showLoggedOutModal) {
return (
<Modal open>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgLogOut} title="You Have Been Logged Out" />
<Modal.Body>
<p className="text-sm">

View File

@@ -871,7 +871,7 @@ export const MicrosoftIconSVG = createLogoIcon(microsoftSVG);
export const MistralIcon = createLogoIcon(mistralSVG);
export const MixedBreadIcon = createLogoIcon(mixedBreadSVG);
export const NomicIcon = createLogoIcon(nomicSVG);
export const CodaIcon = createLogoIcon(codaIcon, { monochromatic: true });
export const CodaIcon = createLogoIcon(codaIcon);
export const NotionIcon = createLogoIcon(notionIcon, { monochromatic: true });
export const OCIStorageIcon = createLogoIcon(OCIStorageSVG);
export const OllamaIcon = createLogoIcon(ollamaIcon);

View File

@@ -31,7 +31,7 @@ export default function AddInstructionModal() {
return (
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgAddLines}
title="Set Project Instructions"

View File

@@ -35,7 +35,7 @@ export default function CreateProjectModal() {
return (
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
<Modal.Content mini>
<Modal.Content width="sm">
<Modal.Header
icon={SvgFolderPlus}
title="Create New Project"

View File

@@ -24,7 +24,7 @@ export default function EditPropertyModal({
}: EditPropertyModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content medium>
<Modal.Content>
<Modal.Header
icon={SvgEdit}
title={`Edit ${propertyTitle}`}

View File

@@ -16,7 +16,7 @@ export default function ExceptionTraceModal({
return (
<Modal open onOpenChange={onOutsideClick}>
<Modal.Content large>
<Modal.Content width="lg" height="full">
<Modal.Header
icon={SvgAlertTriangle}
title="Full Exception Trace"

View File

@@ -19,7 +19,7 @@ export default function GenericConfirmModal({
}: GenericConfirmModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgCheck} title={title} onClose={onClose} />
<Modal.Body>
<Text as="p">{message}</Text>

View File

@@ -11,7 +11,7 @@ export default function NoAssistantModal() {
return (
<Modal open>
<Modal.Content small>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgUser} title="No Assistant Available" />
<Modal.Body>
<Text as="p">

View File

@@ -64,7 +64,7 @@ export default function ProviderModal({
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<Modal.Content tall onKeyDown={handleKeyDown}>
<Modal.Content width="sm" height="lg" onKeyDown={handleKeyDown}>
<Modal.Header
icon={icon}
title={title}

View File

@@ -169,7 +169,8 @@ export default function UserFilesModal({
<Modal open={isOpen} onOpenChange={toggle}>
<Modal.Content
tall
width="sm"
height="lg"
onOpenAutoFocus={(e) => {
e.preventDefault();
searchInputRef.current?.focus();

View File

@@ -7,7 +7,7 @@ import {
FullPersona,
} from "@/app/admin/assistants/interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { pinAgents } from "../lib/assistants/orderAssistants";
import { pinAgents } from "@/lib/agents";
import { useUser } from "@/components/user/UserProvider";
import { useSearchParams } from "next/navigation";
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
@@ -75,7 +75,7 @@ export function useAgents() {
* return <AgentEditor agent={agent} />;
*/
export function useAgent(agentId: number | null) {
const { data, error, mutate } = useSWR<FullPersona>(
const { data, error, isLoading, mutate } = useSWR<FullPersona>(
agentId ? `/api/persona/${agentId}` : null,
errorHandlingFetcher,
{
@@ -86,7 +86,7 @@ export function useAgent(agentId: number | null) {
return {
agent: data ?? null,
isLoading: !error && !data && agentId !== null,
isLoading,
error,
refresh: mutate,
};

View File

@@ -0,0 +1,64 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { UserGroup } from "@/lib/types";
import { useContext } from "react";
import { SettingsContext } from "@/components/settings/SettingsProvider";
/**
* Fetches all user groups in the organization.
*
* Returns group information including group members, curators, and associated resources.
* Use this for displaying group lists in sharing dialogs, admin panels, or permission
* management interfaces.
*
* Note: This hook only returns data if enterprise features are enabled. In non-enterprise
* environments, it returns an empty array.
*
* @returns Object containing:
* - data: Array of UserGroup objects, or undefined while loading
* - isLoading: Boolean indicating if data is being fetched
* - error: Any error that occurred during fetch
* - refreshGroups: Function to manually revalidate the data
*
* @example
* // Fetch groups for sharing dialogs
* const { data: groupsData, isLoading } = useGroups();
* if (isLoading) return <Spinner />;
* return <GroupList groups={groupsData ?? []} />;
*
* @example
* // Fetch groups with manual refresh
* const { data: groupsData, refreshGroups } = useGroups();
* // Later...
* await createNewGroup(...);
* refreshGroups(); // Refresh the group list
*/
export default function useGroups() {
const combinedSettings = useContext(SettingsContext);
const isPaidEnterpriseFeaturesEnabled =
combinedSettings && combinedSettings.enterpriseSettings !== null;
const { data, error, mutate, isLoading } = useSWR<UserGroup[]>(
isPaidEnterpriseFeaturesEnabled ? "/api/manage/admin/user-group" : null,
errorHandlingFetcher
);
// If enterprise features are not enabled, return empty array
if (!isPaidEnterpriseFeaturesEnabled) {
return {
data: [],
isLoading: false,
error: undefined,
refreshGroups: () => {},
};
}
return {
data,
isLoading,
error,
refreshGroups: mutate,
};
}

View File

@@ -1,43 +0,0 @@
"use client";
import { useCallback } from "react";
/**
* Hook that implements standard triple-click text selection behavior:
* - Single click: place cursor (browser default)
* - Double click: select word (browser default)
* - Triple click: select entire content of the target element
*
* Uses onMouseDown with event.detail to detect click count and preventDefault
* on triple-click to avoid the native line selection flashing before our selection.
*
* @param elementRef - Ref to the element whose content should be selected on triple-click
* @returns onMouseDown handler to attach to the element
*/
export function useTripleClickSelect(
elementRef: React.RefObject<HTMLElement | null>
) {
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// event.detail gives the click count (1, 2, 3, etc.)
if (e.detail === 3) {
// Prevent native triple-click (line/paragraph selection)
e.preventDefault();
const element = elementRef.current;
if (!element) return;
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
}
},
[elementRef]
);
return handleMouseDown;
}

52
web/src/hooks/useUsers.ts Normal file
View File

@@ -0,0 +1,52 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { AllUsersResponse } from "@/lib/types";
export interface UseUsersParams {
includeApiKeys: boolean;
}
/**
* Fetches all users in the organization.
*
* Returns user information including accepted users, invited users, and optionally
* API key users. Use this for displaying user lists in sharing dialogs, admin panels,
* or permission management interfaces.
*
* @param params - Configuration object
* @param params.includeApiKeys - Whether to include API key users in the response
*
* @returns Object containing:
* - data: AllUsersResponse containing accepted, invited, and API key users, or undefined while loading
* - isLoading: Boolean indicating if data is being fetched
* - error: Any error that occurred during fetch
* - refreshUsers: Function to manually revalidate the data
*
* @example
* // Fetch users without API keys (for sharing dialogs)
* const { data: usersData, isLoading } = useUsers({ includeApiKeys: false });
* if (isLoading) return <Spinner />;
* return <UserList users={usersData?.accepted ?? []} />;
*
* @example
* // Fetch all users including API keys (for admin panel)
* const { data: usersData, refreshUsers } = useUsers({ includeApiKeys: true });
* // Later...
* await createNewUser(...);
* refreshUsers(); // Refresh the user list
*/
export default function useUsers({ includeApiKeys }: UseUsersParams) {
const { data, error, mutate, isLoading } = useSWR<AllUsersResponse>(
`/api/manage/users?include_api_keys=${includeApiKeys}`,
errorHandlingFetcher
);
return {
data,
isLoading,
error,
refreshUsers: mutate,
};
}

View File

@@ -60,29 +60,19 @@ import React, {
useContext,
useState,
useMemo,
useRef,
useLayoutEffect,
Dispatch,
SetStateAction,
useCallback,
} from "react";
import { cn } from "@/lib/utils";
import type { IconProps } from "@opal/types";
import Truncated from "@/refresh-components/texts/Truncated";
import { WithoutStyles } from "@/types";
import Text from "@/refresh-components/texts/Text";
import { SvgMcp } from "@opal/icons";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section, SectionProps } from "@/layouts/general-layouts";
/**
* Actions Layout Context
*
* Provides folding state management for action cards without prop drilling.
*/
interface ActionsLayoutContextValue {
isFolded: boolean;
setIsFolded: Dispatch<SetStateAction<boolean>>;
}
const ActionsLayoutContext = createContext<
ActionsLayoutContextValue | undefined
>(undefined);
@@ -94,6 +84,7 @@ const ActionsLayoutContext = createContext<
* - Provider: Context provider component to wrap action card
* - isFolded: Current folding state
* - setIsFolded: Function to update folding state
* - hasContent: Whether an ActionsContent is currently mounted (read-only)
*
* @example
* ```tsx
@@ -121,25 +112,56 @@ const ActionsLayoutContext = createContext<
*/
export function useActionsLayout() {
const [isFolded, setIsFolded] = useState(false);
const contextValue = useMemo(() => ({ isFolded, setIsFolded }), [isFolded]);
const [hasContent, setHasContent] = useState(false);
// Wrap children directly, no component creation
// Registration function for ActionsContent to announce its presence
const registerContent = useMemo(
() => () => {
setHasContent(true);
return () => setHasContent(false);
},
[]
);
// Use a ref to hold the context value so Provider can be stable.
// Without this, changing contextValue would create a new Provider function,
// which React treats as a different component type, causing unmount/remount
// of all children (and losing focus on inputs).
const contextValueRef = useRef<ActionsLayoutContextValue>(null!);
contextValueRef.current = {
isFolded,
setIsFolded,
hasContent,
registerContent,
};
// Stable Provider - reads from ref on each render, so the function
// reference never changes but the provided value stays current.
const Provider = useMemo(
() =>
({ children }: { children: React.ReactNode }) => (
<ActionsLayoutContext.Provider value={contextValue}>
<ActionsLayoutContext.Provider value={contextValueRef.current}>
{children}
</ActionsLayoutContext.Provider>
),
[contextValue]
[]
);
return { Provider, isFolded, setIsFolded };
return { Provider, isFolded, setIsFolded, hasContent };
}
/**
* Internal hook to access the ActionsLayout context.
* Actions Layout Context
*
* Provides folding state management for action cards without prop drilling.
* Also tracks whether content is present via self-registration.
*/
interface ActionsLayoutContextValue {
isFolded: boolean;
setIsFolded: Dispatch<SetStateAction<boolean>>;
hasContent: boolean;
registerContent: () => () => void;
}
function useActionsLayoutContext() {
const context = useContext(ActionsLayoutContext);
if (!context) {
@@ -164,9 +186,7 @@ function useActionsLayoutContext() {
* </ActionsLayouts.Root>
* ```
*/
export type ActionsRootProps = SectionProps;
function ActionsRoot(props: ActionsRootProps) {
function ActionsRoot(props: SectionProps) {
return <Section gap={0} padding={0} {...props} />;
}
@@ -204,19 +224,17 @@ function ActionsRoot(props: ActionsRootProps) {
* />
* ```
*/
export type ActionsHeaderProps = WithoutStyles<
{
// Core content
name?: string;
title: string;
description: string;
icon: React.FunctionComponent<IconProps>;
// Custom content
rightChildren?: React.ReactNode;
} & HtmlHTMLAttributes<HTMLDivElement>
>;
export interface ActionsHeaderProps
extends WithoutStyles<HtmlHTMLAttributes<HTMLDivElement>> {
// Core content
name?: string;
title: string;
description: string;
icon: React.FunctionComponent<IconProps>;
// Custom content
rightChildren?: React.ReactNode;
}
function ActionsHeader({
name,
title,
@@ -226,13 +244,16 @@ function ActionsHeader({
...props
}: ActionsHeaderProps) {
const { isFolded } = useActionsLayoutContext();
const { isFolded, hasContent } = useActionsLayoutContext();
// Round all corners if there's no content, or if content exists but is folded
const shouldFullyRound = !hasContent || isFolded;
return (
<div
className={cn(
"flex flex-col border bg-background-neutral-00 w-full gap-2 pt-4 pb-2",
isFolded ? "rounded-16" : "rounded-t-16"
shouldFullyRound ? "rounded-16" : "rounded-t-16"
)}
>
<label
@@ -269,6 +290,13 @@ function ActionsHeader({
* Use this to wrap tools, settings, or other expandable content.
* Features a maximum height with scrollable overflow.
*
* IMPORTANT: Only ONE ActionsContent should be used within a single ActionsRoot.
* This component self-registers with the ActionsLayout context to inform
* ActionsHeader whether content exists (for border-radius styling). Using
* multiple ActionsContent components will cause incorrect unmount behavior -
* when any one unmounts, it will incorrectly signal that no content exists,
* even if other ActionsContent components remain mounted.
*
* @example
* ```tsx
* <ActionsLayouts.Content>
@@ -277,19 +305,22 @@ function ActionsHeader({
* </ActionsLayouts.Content>
* ```
*/
export type ActionsContentProps = WithoutStyles<
React.HTMLAttributes<HTMLDivElement>
>;
function ActionsContent(
props: WithoutStyles<React.HTMLAttributes<HTMLDivElement>>
) {
const { isFolded, registerContent } = useActionsLayoutContext();
function ActionsContent(props: ActionsContentProps) {
const { isFolded } = useActionsLayoutContext();
// Self-register with context to inform Header that content exists
useLayoutEffect(() => {
return registerContent();
}, [registerContent]);
if (isFolded) {
return null;
}
return (
<div className="border-x border-b rounded-b-16 overflow-hidden">
<div className="border-x border-b rounded-b-16 overflow-hidden w-full">
<ShadowDiv
className="flex flex-col gap-2 rounded-b-16 max-h-[20rem] p-2"
{...props}
@@ -358,7 +389,6 @@ export type ActionsToolProps = WithoutStyles<{
disabled?: boolean;
rightChildren?: React.ReactNode;
}>;
function ActionsTool({
name,
title,
@@ -401,34 +431,6 @@ function ActionsTool({
);
}
/**
* Actions No Tools Found Component
*
* A simple empty state component that displays when no tools are found.
* Shows the MCP icon with "No tools found" message.
*
* @example
* ```tsx
* <ActionsLayouts.Content>
* {tools.length === 0 ? (
* <ActionsLayouts.NoToolsFound />
* ) : (
* tools.map(tool => <ActionsLayouts.Tool key={tool.id} {...tool} />)
* )}
* </ActionsLayouts.Content>
* ```
*/
function ActionsNoToolsFound() {
return (
<div className="flex items-center justify-center gap-2 p-4">
<SvgMcp className="stroke-text-04" size={18} />
<Text as="p" text03>
No tools found
</Text>
</div>
);
}
/**
* Actions Tool Skeleton Component
*
@@ -486,6 +488,5 @@ export {
ActionsHeader as Header,
ActionsContent as Content,
ActionsTool as Tool,
ActionsNoToolsFound as NoToolsFound,
ActionsToolSkeleton as ToolSkeleton,
};

View File

@@ -28,39 +28,14 @@
"use client";
import { cn, ensureHrefProtocol, noProp } from "@/lib/utils";
import { cn, ensureHrefProtocol } from "@/lib/utils";
import type { Components } from "react-markdown";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { useCallback, useMemo, useState, useEffect } from "react";
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
import IconButton from "@/refresh-components/buttons/IconButton";
import LineItem from "@/refresh-components/buttons/LineItem";
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
import useChatSessions from "@/hooks/useChatSessions";
import { usePopup } from "@/components/admin/connectors/Popup";
import {
handleMoveOperation,
shouldShowMoveModal,
showErrorNotification,
} from "@/sections/sidebar/sidebarUtils";
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
import { deleteChatSession } from "@/app/chat/services/lib";
import { useRouter } from "next/navigation";
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import { PopoverMenu } from "@/refresh-components/Popover";
import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
import SimplePopover from "@/refresh-components/SimplePopover";
import { useAppSidebarContext } from "@/refresh-components/contexts/AppSidebarContext";
import useScreenSize from "@/hooks/useScreenSize";
import {
SvgFolderIn,
SvgMoreHorizontal,
SvgShare,
SvgSidebar,
SvgTrash,
} from "@opal/icons";
import { SvgSidebar } from "@opal/icons";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { useSettingsContext } from "@/components/settings/SettingsProvider";
@@ -93,278 +68,44 @@ function AppHeader() {
const settings = useSettingsContext();
const { isMobile } = useScreenSize();
const { setFolded } = useAppSidebarContext();
const [showShareModal, setShowShareModal] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [showMoveCustomAgentModal, setShowMoveCustomAgentModal] =
useState(false);
const [pendingMoveProjectId, setPendingMoveProjectId] = useState<
number | null
>(null);
const [showMoveOptions, setShowMoveOptions] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverItems, setPopoverItems] = useState<React.ReactNode[]>([]);
const {
projects,
fetchProjects,
refreshCurrentProjectDetails,
currentProjectId,
} = useProjectsContext();
const { currentChatSession, refreshChatSessions, currentChatSessionId } =
useChatSessions();
const { popup, setPopup } = usePopup();
const router = useRouter();
const { currentChatSessionId } = useChatSessions();
const customHeaderContent =
settings?.enterpriseSettings?.custom_header_content;
const availableProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => project.id !== currentProjectId);
}, [projects, currentProjectId]);
// Don't render when there's a chat session - ChatHeader handles that
if (currentChatSessionId) return null;
const filteredProjects = useMemo(() => {
if (!searchTerm) return availableProjects;
const term = searchTerm.toLowerCase();
return availableProjects.filter((project) =>
project.name.toLowerCase().includes(term)
);
}, [availableProjects, searchTerm]);
const resetMoveState = useCallback(() => {
setShowMoveOptions(false);
setSearchTerm("");
setPendingMoveProjectId(null);
setShowMoveCustomAgentModal(false);
}, []);
const performMove = useCallback(
async (targetProjectId: number) => {
if (!currentChatSession) return;
try {
await handleMoveOperation(
{
chatSession: currentChatSession,
targetProjectId,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
},
setPopup
);
resetMoveState();
setPopoverOpen(false);
} catch (error) {
console.error("Failed to move chat session:", error);
}
},
[
currentChatSession,
refreshChatSessions,
refreshCurrentProjectDetails,
fetchProjects,
currentProjectId,
setPopup,
resetMoveState,
]
);
const handleMoveClick = useCallback(
(projectId: number) => {
if (!currentChatSession) return;
if (shouldShowMoveModal(currentChatSession)) {
setPendingMoveProjectId(projectId);
setShowMoveCustomAgentModal(true);
return;
}
void performMove(projectId);
},
[currentChatSession, performMove]
);
const handleDeleteChat = useCallback(async () => {
if (!currentChatSession) return;
try {
const response = await deleteChatSession(currentChatSession.id);
if (!response.ok) {
throw new Error("Failed to delete chat session");
}
await Promise.all([refreshChatSessions(), fetchProjects()]);
router.replace("/chat");
setDeleteModalOpen(false);
} catch (error) {
console.error("Failed to delete chat:", error);
showErrorNotification(
setPopup,
"Failed to delete chat. Please try again."
);
}
}, [
currentChatSession,
refreshChatSessions,
fetchProjects,
router,
setPopup,
]);
const setDeleteConfirmationModalOpen = useCallback((open: boolean) => {
setDeleteModalOpen(open);
if (open) {
setPopoverOpen(false);
}
}, []);
useEffect(() => {
const items = showMoveOptions
? [
<PopoverSearchInput
key="search"
setShowMoveOptions={setShowMoveOptions}
onSearch={setSearchTerm}
/>,
...filteredProjects.map((project) => (
<LineItem
key={project.id}
icon={SvgFolderIn}
onClick={noProp(() => handleMoveClick(project.id))}
>
{project.name}
</LineItem>
)),
]
: [
<LineItem
key="move"
icon={SvgFolderIn}
onClick={noProp(() => setShowMoveOptions(true))}
>
Move to Project
</LineItem>,
<LineItem
key="delete"
icon={SvgTrash}
onClick={noProp(() => setDeleteConfirmationModalOpen(true))}
danger
>
Delete
</LineItem>,
];
setPopoverItems(items);
}, [
showMoveOptions,
filteredProjects,
currentChatSession,
setDeleteConfirmationModalOpen,
handleMoveClick,
]);
// Only render when on mobile or there's custom header content
if (!isMobile && !customHeaderContent) return null;
return (
<>
{popup}
{showShareModal && currentChatSession && (
<ShareChatSessionModal
chatSession={currentChatSession}
onClose={() => setShowShareModal(false)}
<header className="w-full flex flex-row justify-center items-center py-3 px-4 h-16">
{/* Left - contains the icon-button to fold the AppSidebar on mobile */}
<div className="flex-1">
<IconButton
icon={SvgSidebar}
onClick={() => setFolded(false)}
className={cn(!isMobile && "invisible")}
internal
/>
)}
</div>
{showMoveCustomAgentModal && (
<MoveCustomAgentChatModal
onCancel={resetMoveState}
onConfirm={async (doNotShowAgain: boolean) => {
if (doNotShowAgain && typeof window !== "undefined") {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.HIDE_MOVE_CUSTOM_AGENT_MODAL,
"true"
);
}
if (pendingMoveProjectId != null) {
await performMove(pendingMoveProjectId);
}
}}
/>
)}
{deleteModalOpen && (
<ConfirmationModalLayout
title="Delete Chat"
icon={SvgTrash}
onClose={() => setDeleteModalOpen(false)}
submit={
<Button danger onClick={handleDeleteChat}>
Delete
</Button>
}
{/* Center - contains the custom-header-content */}
<div className="flex-1 flex flex-col items-center overflow-hidden">
<Text
as="p"
text03
mainUiBody
className="text-center break-words w-full"
>
Are you sure you want to delete this chat? This action cannot be
undone.
</ConfirmationModalLayout>
)}
{customHeaderContent}
</Text>
</div>
{(isMobile || customHeaderContent || currentChatSessionId) && (
<header className="w-full flex flex-row justify-center items-center py-3 px-4 h-16">
{/* Left - contains the icon-button to fold the AppSidebar on mobile */}
<div className="flex-1">
<IconButton
icon={SvgSidebar}
onClick={() => setFolded(false)}
className={cn(!isMobile && "invisible")}
internal
/>
</div>
{/* Center - contains the custom-header-content */}
<div className="flex-1 flex flex-col items-center overflow-hidden">
<Text
as="p"
text03
mainUiBody
className="text-center break-words w-full"
>
{customHeaderContent}
</Text>
</div>
{/* Right - contains the share and more-options buttons */}
<div
className={cn(
"flex-1 flex flex-row items-center justify-end px-1",
!currentChatSessionId && "invisible"
)}
>
<Button
leftIcon={SvgShare}
transient={showShareModal}
tertiary
onClick={() => setShowShareModal(true)}
>
Share Chat
</Button>
<SimplePopover
trigger={
<IconButton
icon={SvgMoreHorizontal}
className="ml-2"
transient={popoverOpen}
tertiary
/>
}
onOpenChange={(state) => {
setPopoverOpen(state);
if (!state) setShowMoveOptions(false);
}}
side="bottom"
align="end"
>
<PopoverMenu>{popoverItems}</PopoverMenu>
</SimplePopover>
</div>
</header>
)}
</>
{/* Right - empty placeholder for layout balance */}
<div className="flex-1" />
</header>
);
}
@@ -455,4 +196,33 @@ function AppRoot({ children }: AppRootProps) {
);
}
export { AppRoot as Root };
/**
* Sticky Header Wrapper
*
* A layout component that provides sticky positioning for header content.
* Use this to wrap any header content that should stick to the top of a scroll container.
*
* @example
* ```tsx
* <ChatScrollContainer>
* <AppLayouts.StickyHeader>
* <ChatHeader />
* </AppLayouts.StickyHeader>
* <MessageList />
* </ChatScrollContainer>
* ```
*/
export interface StickyHeaderProps {
children?: React.ReactNode;
className?: string;
}
function StickyHeader({ children, className }: StickyHeaderProps) {
return (
<header className={cn("sticky top-0 z-sticky w-full", className)}>
{children}
</header>
);
}
export { AppRoot as Root, StickyHeader };

View File

@@ -134,10 +134,10 @@ function HorizontalInputLayout({
alignment
)}
>
<div className="w-[70%]">
<div className="min-w-[70%]">
<LabelLayout {...fieldLabelProps} />
</div>
<div className="flex flex-col items-end min-w-[12rem]">{children}</div>
<div className="flex flex-col items-end">{children}</div>
</label>
{name && <ErrorLayout name={name} />}
</div>

168
web/src/lib/agents.ts Normal file
View File

@@ -0,0 +1,168 @@
import {
MinimalPersonaSnapshot,
Persona,
} from "@/app/admin/assistants/interfaces";
import { User } from "./types";
import { checkUserIsNoAuthUser } from "./user";
import { personaComparator } from "@/app/admin/assistants/lib";
/**
* Checks if the given user owns the specified assistant.
*
* @param user - The user to check ownership for, or null if no user is logged in
* @param assistant - The assistant to check ownership of
* @returns true if the user owns the assistant (or no auth is required), false otherwise
*/
export function checkUserOwnsAssistant(
user: User | null,
assistant: MinimalPersonaSnapshot | Persona
) {
return checkUserIdOwnsAssistant(user?.id, assistant);
}
/**
* Checks if the given user ID owns the specified assistant.
*
* Returns true if a valid user ID is provided and any of the following conditions
* are met (and the assistant is not built-in):
* - The user is a no-auth user (authentication is disabled)
* - The user ID matches the assistant owner's ID
*
* Returns false if userId is undefined (e.g., user is loading or unauthenticated)
* to prevent granting ownership access prematurely.
*
* @param userId - The user ID to check ownership for
* @param assistant - The assistant to check ownership of
* @returns true if the user owns the assistant, false otherwise
*/
export function checkUserIdOwnsAssistant(
userId: string | undefined,
assistant: MinimalPersonaSnapshot | Persona
) {
return (
!!userId &&
(checkUserIsNoAuthUser(userId) || assistant.owner?.id === userId) &&
!assistant.builtin_persona
);
}
/**
* Updates the user's pinned assistants with the given ordered list of agent IDs.
*
* @param pinnedAgentIds - Array of agent IDs in the desired pinned order
* @throws Error if the API request fails
*/
export async function pinAgents(pinnedAgentIds: number[]) {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ordered_assistant_ids: pinnedAgentIds,
}),
});
if (!response.ok) {
throw new Error("Failed to update pinned assistants");
}
}
/**
* Filters and sorts assistants based on visibility.
*
* Only returns assistants that are marked as visible, sorted using the persona comparator.
*
* @param assistants - Array of assistants to filter
* @returns Filtered and sorted array of visible assistants
*/
export function filterAssistants(
assistants: MinimalPersonaSnapshot[]
): MinimalPersonaSnapshot[] {
let filteredAssistants = assistants.filter(
(assistant) => assistant.is_visible
);
return filteredAssistants.sort(personaComparator);
}
/**
* Deletes an agent by its ID.
*
* @param agentId - The ID of the agent to delete
* @returns null on success, or an error message string on failure
*/
export async function deleteAgent(agentId: number): Promise<string | null> {
try {
const response = await fetch(`/api/persona/${agentId}`, {
method: "DELETE",
});
if (response.ok) {
return null;
}
const errorMessage = (await response.json()).detail || "Unknown error";
return errorMessage;
} catch (error) {
console.error("deleteAgent: Network error", error);
return "Network error. Please check your connection and try again.";
}
}
/**
* Updates agent sharing settings.
*
* For MIT versions, group_ids should not be sent since group-based sharing
* is an EE-only feature.
*
* @param agentId - The ID of the agent to update
* @param userIds - Array of user IDs to share with
* @param groupIds - Array of group IDs to share with (ignored when isPaidEnterpriseFeaturesEnabled is false)
* @param isPublic - Whether the agent should be public
* @param isPaidEnterpriseFeaturesEnabled - Whether enterprise features are enabled
* @returns null on success, or an error message string on failure
*
* @example
* const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
* const error = await updateAgentSharedStatus(agentId, userIds, groupIds, isPublic, isPaidEnterpriseFeaturesEnabled);
* if (error) console.error(error);
*/
export async function updateAgentSharedStatus(
agentId: number,
userIds: string[],
groupIds: number[],
isPublic: boolean | undefined,
isPaidEnterpriseFeaturesEnabled: boolean
): Promise<null | string> {
// MIT versions should not send group_ids - warn if caller provided non-empty groups
if (!isPaidEnterpriseFeaturesEnabled && groupIds.length > 0) {
console.error(
"updateAgentSharedStatus: groupIds provided but enterprise features are disabled. " +
"Group sharing is an EE-only feature. Discarding groupIds."
);
}
try {
const response = await fetch(`/api/persona/${agentId}/share`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_ids: userIds,
// Only include group_ids for enterprise versions
group_ids: isPaidEnterpriseFeaturesEnabled ? groupIds : undefined,
is_public: isPublic,
}),
});
if (response.ok) {
return null;
}
const errorMessage = (await response.json()).detail || "Unknown error";
return errorMessage;
} catch (error) {
console.error("updateAgentSharedStatus: Network error", error);
return "Network error. Please check your connection and try again.";
}
}

View File

@@ -1,8 +1,9 @@
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { fetchSS } from "../utilsSS";
import { fetchSS } from "./utilsSS";
export type FetchAssistantsResponse = [MinimalPersonaSnapshot[], string | null];
// Fetch assistants server-side
export async function fetchAssistantsSS(): Promise<FetchAssistantsResponse> {
const response = await fetchSS("/persona");
if (response.ok) {

View File

@@ -1,25 +0,0 @@
import {
MinimalPersonaSnapshot,
Persona,
} from "@/app/admin/assistants/interfaces";
import { User } from "../types";
import { checkUserIsNoAuthUser } from "../user";
export function checkUserOwnsAssistant(
user: User | null,
assistant: MinimalPersonaSnapshot | Persona
) {
return checkUserIdOwnsAssistant(user?.id, assistant);
}
export function checkUserIdOwnsAssistant(
userId: string | undefined,
assistant: MinimalPersonaSnapshot | Persona
) {
return (
(!userId ||
checkUserIsNoAuthUser(userId) ||
assistant.owner?.id === userId) &&
!assistant.builtin_persona
);
}

View File

@@ -1,15 +0,0 @@
// Helper to persist pinned agents to the server
export async function pinAgents(pinnedAgentIds: number[]) {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ordered_assistant_ids: pinnedAgentIds,
}),
});
if (!response.ok) {
throw new Error("Failed to update pinned assistants");
}
}

View File

@@ -1,59 +0,0 @@
import { Persona } from "@/app/admin/assistants/interfaces";
interface ShareAssistantRequest {
userIds: string[];
assistantId: number;
}
async function updateAssistantSharedStatus(
request: ShareAssistantRequest
): Promise<null | string> {
const response = await fetch(`/api/persona/${request.assistantId}/share`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_ids: request.userIds,
}),
});
if (response.ok) {
return null;
}
const errorMessage = (await response.json()).detail || "Unknown error";
return errorMessage;
}
export async function addUsersToAssistantSharedList(
existingAssistant: Persona,
newUserIds: string[]
): Promise<null | string> {
// Merge existing user IDs with new user IDs, ensuring no duplicates
const updatedUserIds = Array.from(
new Set([...existingAssistant.users.map((user) => user.id), ...newUserIds])
);
// Update the assistant's shared status with the new user list
return updateAssistantSharedStatus({
userIds: updatedUserIds,
assistantId: existingAssistant.id,
});
}
export async function removeUsersFromAssistantSharedList(
existingAssistant: Persona,
userIdsToRemove: string[]
): Promise<null | string> {
// Filter out the user IDs to be removed from the existing user list
const updatedUserIds = existingAssistant.users
.map((user) => user.id)
.filter((id) => !userIdsToRemove.includes(id));
// Update the assistant's shared status with the new user list
return updateAssistantSharedStatus({
userIds: updatedUserIds,
assistantId: existingAssistant.id,
});
}

View File

@@ -1,95 +0,0 @@
"use client";
export async function updateUserAssistantList(
chosenAssistants: number[]
): Promise<boolean> {
const response = await fetch("/api/user/assistant-list", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ chosen_assistants: chosenAssistants }),
});
return response.ok;
}
export async function updateAssistantVisibility(
assistantId: number,
show: boolean
): Promise<boolean> {
const response = await fetch(
`/api/user/assistant-list/update/${assistantId}?show=${show}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
}
);
return response.ok;
}
export async function removeAssistantFromList(
assistantId: number
): Promise<boolean> {
return updateAssistantVisibility(assistantId, false);
}
export async function addAssistantToList(
assistantId: number
): Promise<boolean> {
return updateAssistantVisibility(assistantId, true);
}
export async function moveAssistantUp(
assistantId: number,
chosenAssistants: number[]
): Promise<boolean> {
const index = chosenAssistants.indexOf(assistantId);
if (index > 0) {
const chosenAssistantPrev = chosenAssistants[index - 1];
const chosenAssistant = chosenAssistants[index];
if (chosenAssistantPrev === undefined || chosenAssistant === undefined) {
return false;
}
chosenAssistants[index - 1] = chosenAssistant;
chosenAssistants[index] = chosenAssistantPrev;
return updateUserAssistantList(chosenAssistants);
}
return false;
}
export async function moveAssistantDown(
assistantId: number,
chosenAssistants: number[]
): Promise<boolean> {
const index = chosenAssistants.indexOf(assistantId);
if (index < chosenAssistants.length - 1) {
const chosenAssistantNext = chosenAssistants[index + 1];
const chosenAssistant = chosenAssistants[index];
if (chosenAssistantNext === undefined || chosenAssistant === undefined) {
return false;
}
chosenAssistants[index + 1] = chosenAssistant;
chosenAssistants[index] = chosenAssistantNext;
return updateUserAssistantList(chosenAssistants);
}
return false;
}
export const reorderPinnedAssistants = async (
assistantIds: number[]
): Promise<boolean> => {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ordered_assistant_ids: assistantIds }),
});
return response.ok;
};

View File

@@ -1,136 +0,0 @@
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { User } from "../types";
import { checkUserIsNoAuthUser } from "../user";
import { personaComparator } from "@/app/admin/assistants/lib";
export function checkUserOwnsAssistant(
user: User | null,
assistant: MinimalPersonaSnapshot
) {
return checkUserIdOwnsAssistant(user?.id, assistant);
}
export function checkUserIdOwnsAssistant(
userId: string | undefined,
assistant: MinimalPersonaSnapshot
) {
return (
(!userId ||
checkUserIsNoAuthUser(userId) ||
assistant.owner?.id === userId) &&
!assistant.builtin_persona
);
}
export function classifyAssistants(
user: User | null,
assistants: MinimalPersonaSnapshot[]
) {
if (!user) {
return {
visibleAssistants: assistants.filter(
(assistant) => assistant.is_default_persona
),
hiddenAssistants: [],
};
}
const visibleAssistants = assistants.filter((assistant) => {
const isVisible = user.preferences?.visible_assistants?.includes(
assistant.id
);
const isNotHidden = !user.preferences?.hidden_assistants?.includes(
assistant.id
);
const isBuiltIn = assistant.builtin_persona;
const isDefault = assistant.is_default_persona;
const isOwnedByUser = checkUserOwnsAssistant(user, assistant);
const isShown =
(isVisible && isNotHidden) ||
(isNotHidden && (isBuiltIn || isDefault || isOwnedByUser));
return isShown;
});
const hiddenAssistants = assistants.filter((assistant) => {
return !visibleAssistants.includes(assistant);
});
return {
visibleAssistants,
hiddenAssistants,
};
}
export function orderAssistantsForUser(
assistants: MinimalPersonaSnapshot[],
user: User | null
) {
let orderedAssistants = [...assistants];
if (user?.preferences?.chosen_assistants) {
const chosenAssistantsSet = new Set(user.preferences.chosen_assistants);
const assistantOrderMap = new Map(
user.preferences.chosen_assistants.map((id: number, index: number) => [
id,
index,
])
);
// Sort chosen assistants based on user preferences
orderedAssistants.sort((a, b) => {
const orderA = assistantOrderMap.get(a.id);
const orderB = assistantOrderMap.get(b.id);
if (orderA !== undefined && orderB !== undefined) {
return orderA - orderB;
} else if (orderA !== undefined) {
return -1;
} else if (orderB !== undefined) {
return 1;
} else {
return 0;
}
});
// Filter out assistants not in the user's chosen list
orderedAssistants = orderedAssistants.filter((assistant) =>
chosenAssistantsSet.has(assistant.id)
);
}
// Sort remaining assistants based on display_priority
const remainingAssistants = assistants.filter(
(assistant) => !orderedAssistants.includes(assistant)
);
remainingAssistants.sort((a, b) => {
const priorityA = a.display_priority ?? Number.MAX_SAFE_INTEGER;
const priorityB = b.display_priority ?? Number.MAX_SAFE_INTEGER;
return priorityA - priorityB;
});
// Combine ordered chosen assistants with remaining assistants
return [...orderedAssistants, ...remainingAssistants];
}
export function getUserCreatedAssistants(
user: User | null,
assistants: MinimalPersonaSnapshot[]
) {
return assistants.filter((assistant) =>
checkUserOwnsAssistant(user, assistant)
);
}
// Filter assistants based on connector status, image compatibility and visibility
export function filterAssistants(
assistants: MinimalPersonaSnapshot[]
): MinimalPersonaSnapshot[] {
let filteredAssistants = assistants.filter(
(assistant) => assistant.is_visible
);
return filteredAssistants.sort(personaComparator);
}

View File

@@ -1,6 +1,6 @@
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { fetchAssistantsSS } from "../assistants/fetchAssistantsSS";
import { filterAssistants } from "../assistants/utils";
import { filterAssistants } from "@/lib/agents";
import { fetchAssistantsSS } from "@/lib/agentsSS";
export async function fetchAssistantData(): Promise<MinimalPersonaSnapshot[]> {
try {

View File

@@ -462,21 +462,6 @@ export function useFilters(): FilterManager {
};
}
interface UseUsersParams {
includeApiKeys: boolean;
}
export const useUsers = ({ includeApiKeys }: UseUsersParams) => {
const url = `/api/manage/users?include_api_keys=${includeApiKeys}`;
const swrResponse = useSWR<AllUsersResponse>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshIndexingStatus: () => mutate(url),
};
};
export interface LlmDescriptor {
name: string;
provider: string;

View File

@@ -10,12 +10,12 @@ import { useAppRouter } from "@/hooks/appNavigation";
import IconButton from "@/refresh-components/buttons/IconButton";
import Truncated from "@/refresh-components/texts/Truncated";
import type { IconProps } from "@opal/types";
import { usePinnedAgents } from "@/hooks/useAgents";
import { usePinnedAgents, useAgent } from "@/hooks/useAgents";
import { cn, noProp } from "@/lib/utils";
import { useRouter } from "next/navigation";
import type { Route } from "next";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { checkUserOwnsAssistant } from "@/lib/assistants/utils";
import { checkUserOwnsAssistant, updateAgentSharedStatus } from "@/lib/agents";
import { useUser } from "@/components/user/UserProvider";
import {
SvgActions,
@@ -24,8 +24,12 @@ import {
SvgEdit,
SvgPin,
SvgPinned,
SvgShare,
SvgUser,
} from "@opal/icons";
import { useCreateModal } from "./contexts/ModalContext";
import ShareAgentModal from "@/sections/modals/ShareAgentModal";
import { usePopup } from "@/components/admin/connectors/Popup";
interface IconLabelProps {
icon: React.FunctionComponent<IconProps>;
children: string;
@@ -58,6 +62,9 @@ export default function AgentCard({ agent }: AgentCardProps) {
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const isOwnedByUser = checkUserOwnsAssistant(user, agent);
const [hovered, setHovered] = React.useState(false);
const shareAgentModal = useCreateModal();
const { agent: fullAgent, refresh: refreshAgent } = useAgent(agent.id);
const { popup, setPopup } = usePopup();
// Start chat and auto-pin unpinned agents to the sidebar
const handleStartChat = useCallback(() => {
@@ -67,96 +74,146 @@ export default function AgentCard({ agent }: AgentCardProps) {
route({ agentId: agent.id });
}, [pinned, togglePinnedAgent, agent, route]);
// Handle sharing agent
const handleShare = useCallback(
async (userIds: string[], groupIds: number[], isPublic: boolean) => {
const error = await updateAgentSharedStatus(
agent.id,
userIds,
groupIds,
isPublic,
isPaidEnterpriseFeaturesEnabled
);
if (error) {
setPopup({
type: "error",
message: `Failed to share agent: ${error}`,
});
} else {
// Revalidate the agent data to reflect the changes
refreshAgent();
shareAgentModal.toggle(false);
}
},
[agent.id, isPaidEnterpriseFeaturesEnabled, refreshAgent, setPopup]
);
return (
<Card
className="group/AgentCard"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div
className="flex flex-col w-full text-left cursor-pointer"
onClick={handleStartChat}
<>
{popup}
<shareAgentModal.Provider>
<ShareAgentModal
agent={agent}
userIds={fullAgent?.users?.map((u) => u.id) ?? []}
groupIds={fullAgent?.groups ?? []}
isPublic={fullAgent?.is_public ?? false}
onShare={handleShare}
/>
</shareAgentModal.Provider>
<Card
className="group/AgentCard"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Main Body */}
<div className="flex flex-col items-center gap-1 p-1">
<div className="flex flex-row items-center w-full gap-1">
<div className="flex flex-row items-center w-full p-1.5 gap-1.5">
<div className="px-0.5">
<AgentAvatar agent={agent} size={18} />
<div
className="flex flex-col w-full text-left cursor-pointer"
onClick={handleStartChat}
>
{/* Main Body */}
<div className="flex flex-col items-center gap-1 p-1">
<div className="flex flex-row items-center w-full gap-1">
<div className="flex flex-row items-center w-full p-1.5 gap-1.5">
<div className="px-0.5">
<AgentAvatar agent={agent} size={18} />
</div>
<Truncated mainContentBody className="flex-1">
{agent.name}
</Truncated>
</div>
<Truncated mainContentBody className="flex-1">
{agent.name}
</Truncated>
</div>
<div className={cn("flex flex-row p-0.5 items-center")}>
{isOwnedByUser && isPaidEnterpriseFeaturesEnabled && (
<div className={cn("flex flex-row p-0.5 items-center")}>
{isOwnedByUser && isPaidEnterpriseFeaturesEnabled && (
<IconButton
icon={SvgBarChart}
tertiary
onClick={noProp(() =>
router.push(`/ee/assistants/stats/${agent.id}` as Route)
)}
tooltip="View Agent Stats"
className="hidden group-hover/AgentCard:flex"
/>
)}
{isOwnedByUser && (
<IconButton
icon={SvgEdit}
tertiary
onClick={noProp(() =>
router.push(`/chat/agents/edit/${agent.id}` as Route)
)}
tooltip="Edit Agent"
className="hidden group-hover/AgentCard:flex"
/>
)}
{isOwnedByUser && (
<IconButton
icon={SvgShare}
tertiary
onClick={noProp(() => shareAgentModal.toggle(true))}
tooltip="Share Agent"
className="hidden group-hover/AgentCard:flex"
/>
)}
<IconButton
icon={SvgBarChart}
icon={pinned ? SvgPinned : SvgPin}
tertiary
onClick={noProp(() =>
router.push(`/ee/assistants/stats/${agent.id}` as Route)
)}
tooltip="View Agent Stats"
className="hidden group-hover/AgentCard:flex"
onClick={noProp(() => togglePinnedAgent(agent, !pinned))}
tooltip={pinned ? "Unpin from Sidebar" : "Pin to Sidebar"}
transient={hovered && pinned}
className={cn(!pinned && "hidden group-hover/AgentCard:flex")}
/>
)}
{isOwnedByUser && (
<IconButton
icon={SvgEdit}
tertiary
onClick={noProp(() =>
router.push(`/chat/agents/edit/${agent.id}` as Route)
)}
tooltip="Edit Agent"
className="hidden group-hover/AgentCard:flex"
/>
)}
<IconButton
icon={pinned ? SvgPinned : SvgPin}
tertiary
onClick={noProp(() => togglePinnedAgent(agent, !pinned))}
tooltip={pinned ? "Unpin from Sidebar" : "Pin to Sidebar"}
transient={hovered && pinned}
className={cn(!pinned && "hidden group-hover/AgentCard:flex")}
/>
</div>
</div>
</div>
<Text
as="p"
secondaryBody
text03
className="pb-1 px-2 w-full line-clamp-2 truncate whitespace-normal h-[2.2rem] break-words"
>
{agent.description}
</Text>
</div>
{/* Footer section - bg-background-tint-01 */}
<div className="bg-background-tint-01 p-1 flex flex-row items-end justify-between">
{/* Left side - creator and actions */}
<div className="flex flex-col gap-1 py-1 px-2">
<IconLabel icon={SvgUser}>{agent.owner?.email || "Onyx"}</IconLabel>
<IconLabel icon={SvgActions}>
{agent.tools.length > 0
? `${agent.tools.length} Action${
agent.tools.length > 1 ? "s" : ""
}`
: "No Actions"}
</IconLabel>
</div>
{/* Right side - Start Chat button */}
<div className="p-0.5">
<Button
tertiary
rightIcon={SvgBubbleText}
onClick={noProp(handleStartChat)}
<Text
as="p"
secondaryBody
text03
className="pb-1 px-2 w-full line-clamp-2 truncate whitespace-normal h-[2.2rem] break-words"
>
Start Chat
</Button>
{agent.description}
</Text>
</div>
{/* Footer section - bg-background-tint-01 */}
<div className="bg-background-tint-01 p-1 flex flex-row items-end justify-between">
{/* Left side - creator and actions */}
<div className="flex flex-col gap-1 py-1 px-2">
<IconLabel icon={SvgUser}>
{agent.owner?.email || "Onyx"}
</IconLabel>
<IconLabel icon={SvgActions}>
{agent.tools.length > 0
? `${agent.tools.length} Action${
agent.tools.length > 1 ? "s" : ""
}`
: "No Actions"}
</IconLabel>
</div>
{/* Right side - Start Chat button */}
<div className="p-0.5">
<Button
tertiary
rightIcon={SvgBubbleText}
onClick={noProp(handleStartChat)}
>
Start Chat
</Button>
</div>
</div>
</div>
</div>
</Card>
</Card>
</>
);
}

View File

@@ -54,13 +54,13 @@ const ModalOverlay = React.forwardRef<
ModalOverlay.displayName = DialogPrimitive.Overlay.displayName;
/**
* Modal Context for managing close button ref, warning state, and size variant
* Modal Context for managing close button ref, warning state, and height variant
*/
interface ModalContextValue {
closeButtonRef: React.RefObject<HTMLDivElement | null>;
hasAttemptedClose: boolean;
setHasAttemptedClose: (value: boolean) => void;
sizeVariant: "large" | "medium" | "small" | "tall" | "mini";
height: keyof typeof heightClasses;
}
const ModalContext = React.createContext<ModalContextValue | null>(null);
@@ -73,16 +73,18 @@ const useModalContext = () => {
return context;
};
/**
* Size class names mapping for modal variants
*/
const sizeClassNames = {
large: ["w-[80dvw]", "h-[80dvh]"],
medium: ["w-[60rem]", "h-fit"],
small: ["w-[32rem]", "max-h-[30rem]"],
tall: ["w-[32rem]", "max-h-[calc(100dvh-4rem)]"],
mini: ["w-[32rem]", "h-fit"],
} as const;
const widthClasses = {
lg: "w-[80dvw]",
md: "w-[60rem]",
sm: "w-[32rem]",
};
const heightClasses = {
fit: "h-fit",
sm: "max-h-[30rem] overflow-y-auto",
lg: "max-h-[calc(100dvh-4rem)] overflow-y-auto",
full: "h-[80dvh] overflow-y-auto",
};
/**
* Modal Content Component
@@ -91,25 +93,21 @@ const sizeClassNames = {
*
* @example
* ```tsx
* // Using size variants
* <Modal.Content large>
* {/* Main modal: w-[80dvw] h-[80dvh] *\/}
* // Using width and height props
* <Modal.Content width="lg" height="full">
* {/* Large modal: w-[80dvw] h-[80dvh] *\/}
* </Modal.Content>
*
* <Modal.Content medium>
* <Modal.Content width="md" height="fit">
* {/* Medium modal: w-[60rem] h-fit *\/}
* </Modal.Content>
*
* <Modal.Content small>
* {/* Small modal: w-[32rem] h-[30rem] *\/}
* <Modal.Content width="sm" height="sm">
* {/* Small modal: w-[32rem] max-h-[30rem] *\/}
* </Modal.Content>
*
* <Modal.Content tall>
* {/* Tall modal: w-[32rem] *\/}
* </Modal.Content>
*
* <Modal.Content mini>
* {/* Mini modal: w-[32rem] h-fit *\/}
* <Modal.Content width="sm" height="lg">
* {/* Tall modal: w-[32rem] max-h-[calc(100dvh-4rem)] *\/}
* </Modal.Content>
*
* // Custom size with className
@@ -123,11 +121,8 @@ interface ModalContentProps
extends WithoutStyles<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
> {
large?: boolean;
medium?: boolean;
small?: boolean;
tall?: boolean;
mini?: boolean;
width?: keyof typeof widthClasses;
height?: keyof typeof heightClasses;
preventAccidentalClose?: boolean;
skipOverlay?: boolean;
}
@@ -138,28 +133,14 @@ const ModalContent = React.forwardRef<
(
{
children,
large,
medium,
small,
tall,
mini,
width = "md",
height = "fit",
preventAccidentalClose = true,
skipOverlay = false,
...props
},
ref
) => {
const variant = large
? "large"
: medium
? "medium"
: small
? "small"
: tall
? "tall"
: mini
? "mini"
: "medium";
const closeButtonRef = React.useRef<HTMLDivElement>(null);
const [hasAttemptedClose, setHasAttemptedClose] = React.useState(false);
const hasUserTypedRef = React.useRef(false);
@@ -275,7 +256,7 @@ const ModalContent = React.forwardRef<
closeButtonRef,
hasAttemptedClose,
setHasAttemptedClose,
sizeVariant: variant,
height,
}}
>
<DialogPrimitive.Portal>
@@ -302,8 +283,9 @@ const ModalContent = React.forwardRef<
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
"data-[state=open]:slide-in-from-top-1/2 data-[state=closed]:slide-out-to-top-1/2",
"duration-200",
// Size variants
sizeClassNames[variant]
// Size classes
widthClasses[width],
heightClasses[height]
)}
onOpenAutoFocus={(e) => {
// Reset typing detection when modal opens
@@ -431,20 +413,12 @@ interface ModalBodyProps extends WithoutStyles<SectionProps> {
}
const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
({ twoTone = true, children, ...props }, ref) => {
const { sizeVariant } = useModalContext();
// Apply overflow-auto for fixed height variants (large, small, tall)
const hasFixedHeight =
sizeVariant === "large" ||
sizeVariant === "small" ||
sizeVariant === "tall";
return (
<div
ref={ref}
className={cn(
twoTone && "bg-background-tint-01",
hasFixedHeight && "overflow-auto min-h-0"
"min-h-0 overflow-y-auto"
)}
>
<Section padding={1} gap={1} alignItems="start" {...props}>
@@ -493,3 +467,31 @@ export default Object.assign(ModalRoot, {
Body: ModalBody,
Footer: ModalFooter,
});
// ============================================================================
// Common Layouts
// ============================================================================
export interface BasicModalFooterProps {
left?: React.ReactNode;
cancel?: React.ReactNode;
submit?: React.ReactNode;
}
export function BasicModalFooter({
left,
cancel,
submit,
}: BasicModalFooterProps) {
return (
<>
{left && <Section alignItems="start">{left}</Section>}
{(cancel || submit) && (
<Section flexDirection="row" justifyContent="end" gap={0.5}>
{cancel}
{submit}
</Section>
)}
</>
);
}

Some files were not shown because too many files have changed in this diff Show More