Compare commits

...

11 Commits

Author SHA1 Message Date
ZhenYi
43e2d26ea2 feat(frontend): channel sidebar toggle, member list default closed, fix accent-fg colors
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
2026-04-20 19:33:14 +08:00
ZhenYi
e43d9fc8bf feat(frontend): add attachment_ids to message creation flow and types 2026-04-20 19:33:09 +08:00
ZhenYi
7736869fc4 feat(frontend): integrate ThemeSwitcher, restore custom palette on page load 2026-04-20 19:33:04 +08:00
ZhenYi
ce29eb3062 feat(frontend): Discord design system tokens and palette variables 2026-04-20 19:32:59 +08:00
ZhenYi
3eeb054452 feat(admin): auto-migrate admin DB tables on health check (audit_log, user, role, permission) 2026-04-20 19:32:38 +08:00
ZhenYi
33a4a5c6c9 feat(service): register project_tools in chat service, add AppStorage::read method 2026-04-20 19:32:29 +08:00
ZhenYi
b23c6a03c3 feat(room): add attachment_ids to messages, pass AppConfig, increase max_tool_depth to 1000 2026-04-20 19:32:22 +08:00
ZhenYi
dee79f3f7f feat(room): add attachment upload/download API and attach files to messages 2026-04-20 19:32:11 +08:00
ZhenYi
a0ab16e6ea feat(agent): pass AppConfig through ToolContext and fix tool call handling 2026-04-20 19:32:03 +08:00
ZhenYi
4e955d9ae3 chore: add mime_guess2, quick-xml serialize feature, and config crate to room lib 2026-04-20 19:31:52 +08:00
ZhenYi
4d5c62e46a feat: add project tools (repos, issues, boards, arxiv, curl, members) and ThemeSwitcher component 2026-04-20 19:31:44 +08:00
41 changed files with 4371 additions and 890 deletions

3
Cargo.lock generated
View File

@ -5861,6 +5861,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
"serde",
]
[[package]]
@ -6405,6 +6406,7 @@ dependencies = [
"anyhow",
"async-openai",
"chrono",
"config",
"dashmap",
"db",
"deadpool-redis",
@ -7156,6 +7158,7 @@ dependencies = [
"http 1.4.0",
"jwt-simple",
"lopdf",
"mime_guess2",
"models",
"moka",
"p256",

View File

@ -147,7 +147,7 @@ calamine = "0.26"
csv = "1.3"
lopdf = "0.34"
pulldown-cmark = "0.12"
quick-xml = "0.37"
quick-xml = { version = "0.37", features = ["serialize"] }
sqlparser = "0.55"
lazy_static = "1.5"
md5 = "0.7"

View File

@ -1,7 +1,138 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({ status: "ok" }, { status: 200 });
let migrationDone = false;
async function ensureTables() {
if (migrationDone) return;
migrationDone = true;
console.log("[Health] Checking database tables...");
// 1. admin_audit_log
await query(`
CREATE TABLE IF NOT EXISTS admin_audit_log (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
username VARCHAR(255) NOT NULL,
action VARCHAR(50) NOT NULL,
resource VARCHAR(255) NOT NULL,
resource_id VARCHAR(255),
request_params JSONB,
ip_address VARCHAR(255),
user_agent TEXT,
result VARCHAR(20) NOT NULL DEFAULT 'success',
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// 2. admin_user
await query(`
CREATE TABLE IF NOT EXISTS admin_user (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// 3. admin_role
await query(`
CREATE TABLE IF NOT EXISTS admin_role (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// 4. admin_permission
await query(`
CREATE TABLE IF NOT EXISTS admin_permission (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
code VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// 5. admin_user_role
await query(`
CREATE TABLE IF NOT EXISTS admin_user_role (
user_id INTEGER NOT NULL REFERENCES admin_user(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
)
`);
// 6. admin_role_permission
await query(`
CREATE TABLE IF NOT EXISTS admin_role_permission (
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
permission_id INTEGER NOT NULL REFERENCES admin_permission(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
)
`);
// 索引
await query(`CREATE INDEX IF NOT EXISTS idx_admin_user_username ON admin_user(username)`);
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_user_id ON admin_audit_log(user_id)`);
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at ON admin_audit_log(created_at DESC)`);
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action)`);
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_resource ON admin_audit_log(resource)`);
// Seed data
await query(`
INSERT INTO admin_permission (name, code, description) VALUES
('用户管理', 'user:read', '查看用户列表'),
('用户创建', 'user:create', '创建管理员用户'),
('用户更新', 'user:update', '更新管理员用户'),
('用户删除', 'user:delete', '删除管理员用户'),
('角色管理', 'role:read', '查看角色'),
('角色创建', 'role:create', '创建角色'),
('角色更新', 'role:update', '更新角色'),
('角色删除', 'role:delete', '删除角色'),
('权限管理', 'permission:read', '查看权限'),
('权限创建', 'permission:create', '创建权限'),
('权限更新', 'permission:update', '更新权限'),
('权限删除', 'permission:delete', '删除权限'),
('日志查看', 'log:read', '查看审计日志'),
('会话管理', 'session:manage', '管理在线用户会话'),
('平台数据', 'platform:read', '查看平台数据'),
('平台管理', 'platform:manage', '管理平台数据')
ON CONFLICT (code) DO NOTHING
`);
await query(`
INSERT INTO admin_role (name, description) VALUES
('超级管理员', '拥有所有权限')
ON CONFLICT (name) DO NOTHING
`);
await query(`
INSERT INTO admin_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM admin_role r
CROSS JOIN admin_permission p
WHERE r.name = '超级管理员'
ON CONFLICT DO NOTHING
`);
console.log("[Health] Database tables ready");
}
export async function GET() {
try {
await ensureTables();
return NextResponse.json({ status: "ok" }, { status: 200 });
} catch (e) {
console.error("[Health] DB check failed:", e);
return NextResponse.json({ status: "error", reason: String(e) }, { status: 503 });
}
}

View File

@ -8,6 +8,7 @@ use models::projects::project;
use models::repos::repo;
use models::rooms::{room, room_message};
use models::users::user;
use config::AppConfig;
use std::collections::HashMap;
use uuid::Uuid;
@ -29,6 +30,7 @@ pub type StreamCallback = Box<
pub struct AiRequest {
pub db: AppDatabase,
pub cache: AppCache,
pub config: AppConfig,
pub model: model::Model,
pub project: project::Model,
pub sender: user::Model,

View File

@ -321,13 +321,6 @@ impl ChatService {
);
if has_tool_calls && tools_enabled {
// Send final text chunk
on_chunk(AiStreamChunk {
content: text_accumulated.clone(),
done: true,
})
.await;
// Build ToolCall list from accumulated chunks
let tool_calls: Vec<_> = tool_call_chunks
.into_iter()
@ -340,12 +333,23 @@ impl ChatService {
.collect();
if !tool_calls.is_empty() {
// Capture thinking text, send it as a completed chunk, then clear for the next turn
let thinking_text = text_accumulated.clone();
if !thinking_text.is_empty() {
on_chunk(AiStreamChunk {
content: thinking_text.clone(),
done: true,
})
.await;
}
text_accumulated.clear();
// Append assistant message with tool calls to history
messages.push(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content: Some(
ChatCompletionRequestAssistantMessageContent::Text(
text_accumulated,
thinking_text,
),
),
name: None,
@ -400,6 +404,7 @@ impl ChatService {
let mut ctx = ToolContext::new(
request.db.clone(),
request.cache.clone(),
request.config.clone(),
request.room.id,
Some(request.sender.uid),
)

View File

@ -7,6 +7,7 @@ use std::sync::Arc;
use db::cache::AppCache;
use db::database::AppDatabase;
use config::AppConfig;
use uuid::Uuid;
use super::registry::ToolRegistry;
@ -21,6 +22,7 @@ pub struct ToolContext {
struct Inner {
pub db: AppDatabase,
pub cache: AppCache,
pub config: AppConfig,
pub room_id: Uuid,
pub sender_id: Option<Uuid>,
pub project_id: Uuid,
@ -32,11 +34,18 @@ struct Inner {
}
impl ToolContext {
pub fn new(db: AppDatabase, cache: AppCache, room_id: Uuid, sender_id: Option<Uuid>) -> Self {
pub fn new(
db: AppDatabase,
cache: AppCache,
config: AppConfig,
room_id: Uuid,
sender_id: Option<Uuid>,
) -> Self {
Self {
inner: Arc::new(Inner {
db,
cache,
config,
room_id,
sender_id,
project_id: Uuid::nil(),
@ -111,6 +120,11 @@ impl ToolContext {
&self.inner.cache
}
/// Application config.
pub fn config(&self) -> &AppConfig {
&self.inner.config
}
/// Room where the original message was sent.
pub fn room_id(&self) -> Uuid {
self.inner.room_id

View File

@ -179,6 +179,10 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) {
.route(
"/rooms/{room_id}/upload",
web::post().to(upload::upload),
)
.route(
"/rooms/{room_id}/attachments/{attachment_id}",
web::get().to(upload::get_attachment),
),
);
}

View File

@ -189,9 +189,18 @@ pub async fn message_search(
.user()
.ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?;
let ctx = WsUserContext::new(user_id);
let req = room::types::RoomMessageSearchRequest {
q: query.q.clone(),
start_time: None,
end_time: None,
sender_id: None,
content_type: None,
limit: query.limit,
offset: query.offset,
};
let resp = service
.room
.message_search(room_id, &query.q, query.limit, query.offset, &ctx)
.room_message_search(room_id, req, &ctx)
.await
.map_err(ApiError::from)?;
Ok(ApiResponse::ok(resp).to_response())

View File

@ -1,13 +1,17 @@
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Result, web};
use actix_web::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use chrono::Utc;
use futures_util::StreamExt;
use models::rooms::room_attachment;
use sea_orm::{ActiveModelTrait, EntityTrait, Set};
use service::AppService;
use session::Session;
use uuid::Uuid;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct UploadResponse {
pub id: String,
pub url: String,
pub file_name: String,
pub file_size: i64,
@ -130,11 +134,104 @@ pub async fn upload(
))
})?;
// Write to room_attachment table (message will be linked when message is created)
let attachment_id = Uuid::now_v7();
let attachment: room_attachment::ActiveModel = room_attachment::ActiveModel {
id: Set(attachment_id),
room: Set(room_id),
message: Set(None),
uploader: Set(user_id),
file_name: Set(file_name.clone()),
file_size: Set(file_size),
content_type: Set(content_type.clone()),
s3_key: Set(key),
created_at: Set(Utc::now()),
};
attachment
.insert(&service.db)
.await
.map_err(|e| {
crate::error::ApiError(service::error::AppError::InternalServerError(
e.to_string(),
))
})?;
// Return the structured attachment URL instead of the /files/... path
// (the /files/... path has no handler on the API server)
let attachment_url = format!("/api/rooms/{}/attachments/{}", room_id, attachment_id);
Ok(crate::ApiResponse::ok(UploadResponse {
url,
id: attachment_id.to_string(),
url: attachment_url,
file_name,
file_size,
content_type,
})
.to_response())
}
#[utoipa::path(
get,
path = "/api/rooms/{room_id}/attachments/{attachment_id}",
params(
("room_id" = Uuid, Path, description = "Room ID"),
("attachment_id" = Uuid, Path, description = "Attachment ID"),
),
responses(
(status = 200, description = "Download file"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Not a room member"),
(status = 404, description = "Not found"),
),
tag = "Room"
)]
pub async fn get_attachment(
service: web::Data<AppService>,
session: Session,
path: web::Path<(Uuid, Uuid)>,
) -> Result<HttpResponse, crate::error::ApiError> {
let user_id = session
.user()
.ok_or_else(|| crate::error::ApiError(service::error::AppError::Unauthorized))?;
let (room_id, attachment_id) = path.into_inner();
service
.room
.require_room_member(room_id, user_id)
.await
.map_err(crate::error::ApiError::from)?;
let attachment = room_attachment::Entity::find_by_id(attachment_id)
.one(&service.db)
.await
.map_err(|e| crate::error::ApiError(service::error::AppError::InternalServerError(e.to_string())))?
.ok_or_else(|| crate::error::ApiError(service::error::AppError::NotFound))?;
// Ensure the attachment belongs to the requested room
if attachment.room != room_id {
return Err(crate::error::ApiError(service::error::AppError::NotFound));
}
let storage = service
.storage
.as_ref()
.ok_or_else(|| crate::error::ApiError(service::error::AppError::InternalServerError("Storage not configured".to_string())))?;
let (data, content_type) = storage
.read(&attachment.s3_key)
.await
.map_err(|e| crate::error::ApiError(service::error::AppError::InternalServerError(e.to_string())))?;
HttpResponse::Ok()
.content_type(content_type)
.insert_header((
CONTENT_TYPE,
content_type,
))
.insert_header((
CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", attachment.file_name),
))
.body(data)
}

View File

@ -222,6 +222,7 @@ impl WsRequestHandler {
content_type: params.content_type.clone(),
thread: params.thread_id,
in_reply_to: params.in_reply_to,
attachment_ids: params.attachment_ids.clone().unwrap_or_default(),
},
&ctx,
)

View File

@ -210,6 +210,7 @@ pub struct WsRequestParams {
pub stream: Option<bool>,
pub min_score: Option<f32>,
pub query: Option<String>,
pub attachment_ids: Option<Vec<Uuid>>,
}
#[derive(Debug, Clone, Serialize)]

View File

@ -22,6 +22,7 @@ db = { workspace = true }
session = { workspace = true }
queue = { workspace = true }
agent = { path = "../agent" }
config = { path = "../config" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@ -74,6 +74,7 @@ impl From<room_message::Model> for super::RoomMessageResponse {
revoked_by: value.revoked_by,
in_reply_to: value.in_reply_to,
highlighted_content: None,
attachment_ids: Vec::new(),
}
}
}
@ -431,6 +432,7 @@ impl RoomService {
revoked_by: msg.revoked_by,
in_reply_to: msg.in_reply_to,
highlighted_content: None,
attachment_ids: Vec::new(),
}
}
}

View File

@ -2,10 +2,10 @@ use crate::error::RoomError;
use crate::service::RoomService;
use crate::ws_context::WsUserContext;
use chrono::Utc;
use models::rooms::{room, room_message, room_thread};
use models::rooms::{room, room_attachment, room_message, room_thread};
use models::users::user as user_model;
use queue::RoomMessageEnvelope;
use sea_orm::*;
use sea_orm::{sea_query::Expr, *};
use serde_json;
use uuid::Uuid;
@ -97,11 +97,34 @@ impl RoomService {
revoked: msg.revoked,
revoked_by: msg.revoked_by,
highlighted_content: None,
attachment_ids: Vec::new(),
}
})
.collect();
messages.reverse();
// Batch-load attachment IDs for all returned messages
if !messages.is_empty() {
let msg_ids: Vec<Uuid> = messages.iter().map(|m| m.id).collect();
let attachments = room_attachment::Entity::find()
.filter(room_attachment::Column::Message.is_in(msg_ids))
.all(&self.db)
.await
.unwrap_or_default();
let mut attachment_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
std::collections::HashMap::new();
for att in attachments {
attachment_map.entry(att.message).or_default().push(att.id);
}
for msg in &mut messages {
if let Some(ids) = attachment_map.remove(&msg.id) {
msg.attachment_ids = ids;
}
}
}
Ok(super::RoomMessageListResponse { messages, total })
}
@ -198,6 +221,27 @@ impl RoomService {
txn.commit().await?;
// Link uploaded attachments to this message
let attachment_ids = request.attachment_ids.clone();
if !attachment_ids.is_empty() {
if let Err(e) = room_attachment::Entity::update_many()
.col_expr(
room_attachment::Column::Message,
Expr::value(Some(id)),
)
.filter(room_attachment::Column::Id.is_in(attachment_ids.clone()))
.exec(&self.db)
.await
{
slog::warn!(
self.log,
"Failed to link attachments to message {}: {}",
id,
e
);
}
}
self.publish_room_event(
project_id,
super::RoomEventType::NewMessage,
@ -278,6 +322,7 @@ impl RoomService {
revoked: None,
revoked_by: None,
highlighted_content: None,
attachment_ids,
})
}

View File

@ -326,6 +326,7 @@ impl RoomService {
revoked: msg.revoked,
revoked_by: msg.revoked_by,
highlighted_content: None,
attachment_ids: Vec::new(),
}
})
.collect()

View File

@ -53,6 +53,7 @@ static MENTION_BRACKET_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex
pub struct RoomService {
pub db: AppDatabase,
pub cache: AppCache,
pub config: config::AppConfig,
pub room_manager: Arc<RoomConnectionManager>,
pub queue: MessageProducer,
pub redis_url: String,
@ -68,6 +69,7 @@ impl RoomService {
pub fn new(
db: AppDatabase,
cache: AppCache,
config: config::AppConfig,
queue: MessageProducer,
room_manager: Arc<RoomConnectionManager>,
redis_url: String,
@ -82,6 +84,7 @@ impl RoomService {
Self {
db,
cache,
config,
room_manager,
queue,
redis_url,
@ -898,6 +901,7 @@ impl RoomService {
let request = AiRequest {
db: self.db.clone(),
cache: self.cache.clone(),
config: self.config.clone(),
model,
project: project.clone(),
sender,
@ -913,7 +917,7 @@ impl RoomService {
presence_penalty: 0.0,
think: ai_config.think,
tools: Some(chat_service.tools()),
max_tool_depth: 3,
max_tool_depth: 1000,
};
let use_streaming = ai_config.stream;

View File

@ -185,6 +185,8 @@ pub struct RoomMessageCreateRequest {
#[serde(rename = "thread_id")]
pub thread: Option<Uuid>,
pub in_reply_to: Option<Uuid>,
#[serde(default)]
pub attachment_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
@ -222,6 +224,8 @@ pub struct RoomMessageResponse {
/// Highlighted content with <mark> tags around matched terms (for search results)
#[serde(skip_serializing_if = "Option::is_none")]
pub highlighted_content: Option<String>,
#[serde(default)]
pub attachment_ids: Vec<Uuid>,
}
/// Search result wrapper (keeps API compatibility)

View File

@ -61,6 +61,7 @@ rust_decimal = { workspace = true }
calamine = { workspace = true }
csv = { workspace = true }
quick-xml = { workspace = true }
mime_guess2 = { workspace = true }
lopdf = { workspace = true }
pulldown-cmark = { workspace = true }
sqlparser = { workspace = true }

View File

@ -231,6 +231,7 @@ impl AppService {
let mut registry = ToolRegistry::new();
git_tools::register_all(&mut registry);
file_tools::register_all(&mut registry);
project_tools::register_all(&mut registry);
Some(Arc::new(
ChatService::new(client).with_tool_registry(registry),
))
@ -289,6 +290,7 @@ impl AppService {
let room = RoomService::new(
db.clone(),
cache.clone(),
config.clone(),
message_producer.clone(),
room_manager,
redis_url,
@ -362,6 +364,7 @@ pub mod git;
pub mod git_tools;
pub mod issue;
pub mod project;
pub mod project_tools;
pub mod pull_request;
pub mod search;
pub mod skill;

View File

@ -0,0 +1,227 @@
//! Tool: project_arxiv_search — search arXiv papers by query
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use serde::Deserialize;
use std::collections::HashMap;
/// Number of results to return by default.
const DEFAULT_MAX_RESULTS: usize = 10;
const MAX_MAX_RESULTS: usize = 50;
/// arXiv API base URL (Atom feed).
const ARXIV_API: &str = "http://export.arxiv.org/api/query";
/// arXiv Atom feed entry fields we care about.
#[derive(Debug, Deserialize)]
struct ArxivEntry {
#[serde(rename = "id")]
entry_id: String,
#[serde(rename = "title")]
title: String,
#[serde(rename = "summary")]
summary: String,
#[serde(default, rename = "author")]
author: Vec<ArxivAuthor>,
#[serde(rename = "published")]
published: String,
#[serde(default, rename = "link")]
link: Vec<ArxivLink>,
}
#[derive(Debug, Deserialize)]
struct ArxivAuthor {
#[serde(rename = "name")]
name: String,
}
#[derive(Debug, Deserialize)]
struct ArxivLink {
#[serde(rename = "type", default)]
link_type: String,
#[serde(rename = "href", default)]
href: String,
#[serde(rename = "title", default)]
title: String,
#[serde(rename = "rel", default)]
rel: String,
}
#[derive(Debug, Deserialize)]
struct ArxivFeed {
#[serde(default, rename = "entry")]
entry: Vec<ArxivEntry>,
}
/// Search arXiv papers by query string.
///
/// Returns up to `max_results` papers (default 10, max 50) matching the query.
/// Each result includes arXiv ID, title, authors, abstract, published date, and PDF URL.
pub async fn arxiv_search_exec(
_ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let query = args
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("query is required".into()))?;
let max_results = args
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_MAX_RESULTS as u64)
.min(MAX_MAX_RESULTS as u64) as usize;
let start = args
.get("start")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
// Build arXiv API query URL
// Encode query for URL
let encoded_query = urlencoding_encode(query);
let url = format!(
"{}?search_query=all:{}&start={}&max_results={}&sortBy=relevance&sortOrder=descending",
ARXIV_API, encoded_query, start, max_results
);
let response = reqwest::get(&url)
.await
.map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?;
if !response.status().is_success() {
return Err(ToolError::ExecutionError(format!(
"arXiv API returned status {}",
response.status()
)));
}
let body = response
.text()
.await
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response: {}", e)))?;
let feed: ArxivFeed = quick_xml::de::from_str(&body)
.map_err(|e| ToolError::ExecutionError(format!("Failed to parse Atom feed: {}", e)))?;
let results: Vec<serde_json::Value> = feed
.entry
.into_iter()
.map(|entry| {
// Extract PDF link
let pdf_url = entry
.link
.iter()
.find(|l| l.link_type == "application/pdf")
.map(|l| l.href.clone())
.or_else(|| {
entry
.link
.iter()
.find(|l| l.rel == "alternate" && l.link_type.is_empty())
.map(|l| l.href.replace("/abs/", "/pdf/"))
})
.unwrap_or_default();
// Extract arXiv ID from entry id URL
// e.g. http://arxiv.org/abs/2312.12345v1 -> 2312.12345v1
let arxiv_id = entry
.entry_id
.rsplit('/')
.next()
.unwrap_or(&entry.entry_id)
.trim();
// Whitespace-normalize title and abstract
let title = normalize_whitespace(&entry.title);
let summary = normalize_whitespace(&entry.summary);
let author_str = if entry.author.is_empty() {
"Unknown".to_string()
} else {
entry.author.iter().map(|a| a.name.as_str()).collect::<Vec<_>>().join(", ")
};
serde_json::json!({
"arxiv_id": arxiv_id,
"title": title,
"authors": author_str,
"abstract": summary,
"published": entry.published,
"pdf_url": pdf_url,
"abs_url": entry.entry_id,
})
})
.collect();
Ok(serde_json::json!({
"count": results.len(),
"query": query,
"results": results,
}))
}
// ─── helpers ───────────────────────────────────────────────────────────────────
fn urlencoding_encode(s: &str) -> String {
let mut encoded = String::with_capacity(s.len() * 2);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(b as char);
}
_ => {
encoded.push_str(&format!("%{:02X}", b));
}
}
}
encoded
}
fn normalize_whitespace(s: &str) -> String {
let s = s.trim();
let mut result = String::with_capacity(s.len());
let mut last_was_space = false;
for c in s.chars() {
if c.is_whitespace() {
if !last_was_space {
result.push(' ');
last_was_space = true;
}
} else {
result.push(c);
last_was_space = false;
}
}
result
}
// ─── tool definition ─────────────────────────────────────────────────────────
pub fn tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("query".into(), ToolParam {
name: "query".into(), param_type: "string".into(),
description: Some("Search query (required). Supports arXiv search syntax, e.g. 'ti:transformer AND au:bengio'.".into()),
required: true, properties: None, items: None,
});
p.insert("max_results".into(), ToolParam {
name: "max_results".into(), param_type: "integer".into(),
description: Some("Maximum number of results to return (default 10, max 50). Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("start".into(), ToolParam {
name: "start".into(), param_type: "integer".into(),
description: Some("Offset for pagination. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_arxiv_search")
.description(
"Search arXiv papers by keyword or phrase. \
Returns paper titles, authors, abstracts, arXiv IDs, and PDF URLs. \
Useful for finding academic papers relevant to the project or a task.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["query".into()]),
})
}

View File

@ -0,0 +1,722 @@
//! Tools: project_list_boards, project_create_board, project_update_board,
//! project_create_board_card, project_update_board_card, project_delete_board_card
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use models::projects::{
project_board, project_board_card, project_board_column, project_members,
};
use models::projects::{MemberRole, ProjectBoard, ProjectBoardCard, ProjectBoardColumn};
use models::users::user::Model as UserModel;
use sea_orm::*;
use std::collections::HashMap;
use uuid::Uuid;
// ─── helpers ──────────────────────────────────────────────────────────────────
/// Check if the sender is an admin or owner of the project.
async fn require_admin(
db: &impl ConnectionTrait,
project_id: Uuid,
sender_id: Uuid,
) -> Result<(), ToolError> {
let member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => Ok(()),
MemberRole::Member => Err(ToolError::ExecutionError(
"Only admin or owner can perform this action".into(),
)),
}
}
fn serde_user(u: &UserModel) -> serde_json::Value {
serde_json::json!({
"id": u.uid.to_string(),
"username": u.username,
"display_name": u.display_name,
})
}
// ─── list boards ──────────────────────────────────────────────────────────────
pub async fn list_boards_exec(
ctx: ToolContext,
_args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let db = ctx.db();
let boards = project_board::Entity::find()
.filter(project_board::Column::Project.eq(project_id))
.order_by_asc(project_board::Column::CreatedAt)
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let board_ids: Vec<_> = boards.iter().map(|b| b.id).collect();
// Batch-load columns and cards
let columns = project_board_column::Entity::find()
.filter(project_board_column::Column::Board.is_in(board_ids.clone()))
.order_by_asc(project_board_column::Column::Position)
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let column_ids: Vec<_> = columns.iter().map(|c| c.id).collect();
let cards = project_board_card::Entity::find()
.filter(project_board_card::Column::Column.is_in(column_ids.clone()))
.order_by_asc(project_board_card::Column::Position)
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Build column map
let col_map: std::collections::HashMap<Uuid, Vec<serde_json::Value>> = columns
.clone()
.into_iter()
.map(|c| {
let cards_in_col: Vec<serde_json::Value> = cards
.iter()
.filter(|card| card.column == c.id)
.map(|card| {
serde_json::json!({
"id": card.id.to_string(),
"issue_id": card.issue_id,
"title": card.title,
"description": card.description,
"position": card.position,
"assignee_id": card.assignee_id.map(|id| id.to_string()),
"due_date": card.due_date.map(|t| t.to_rfc3339()),
"priority": card.priority,
"created_at": card.created_at.to_rfc3339(),
})
})
.collect();
(c.id, cards_in_col)
})
.collect();
// Build column list per board
let mut board_col_map: std::collections::HashMap<Uuid, Vec<serde_json::Value>> = columns
.into_iter()
.fold(std::collections::HashMap::new(), |mut acc, c| {
let cards = col_map.get(&c.id).cloned().unwrap_or_default();
acc.entry(c.board).or_default().push(serde_json::json!({
"id": c.id.to_string(),
"name": c.name,
"position": c.position,
"wip_limit": c.wip_limit,
"color": c.color,
"cards": cards,
}));
acc
});
let result: Vec<_> = boards
.into_iter()
.map(|b| {
let cols = board_col_map.remove(&b.id).unwrap_or_default();
serde_json::json!({
"id": b.id.to_string(),
"name": b.name,
"description": b.description,
"created_by": b.created_by.to_string(),
"created_at": b.created_at.to_rfc3339(),
"updated_at": b.updated_at.to_rfc3339(),
"columns": cols,
})
})
.collect();
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
}
// ─── create board ─────────────────────────────────────────────────────────────
pub async fn create_board_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
let description = args
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let now = Utc::now();
let active = project_board::ActiveModel {
id: Set(Uuid::now_v7()),
project: Set(project_id),
name: Set(name.clone()),
description: Set(description),
created_by: Set(sender_id),
created_at: Set(now),
updated_at: Set(now),
};
let model = active
.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.name,
"description": model.description,
"created_by": model.created_by.to_string(),
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
"columns": Vec::<serde_json::Value>::new(),
}))
}
// ─── update board ─────────────────────────────────────────────────────────────
pub async fn update_board_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let board_id = args
.get("board_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())
.ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?;
let board = ProjectBoard::find_by_id(board_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?;
if board.project != project_id {
return Err(ToolError::ExecutionError(
"Board does not belong to this project".into(),
));
}
let mut active: project_board::ActiveModel = board.clone().into();
let mut updated = false;
if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
active.name = Set(name.to_string());
updated = true;
}
if let Some(description) = args.get("description") {
active.description = Set(description.as_str().map(|s| s.to_string()));
updated = true;
}
if !updated {
return Err(ToolError::ExecutionError(
"At least one field must be provided".into(),
));
}
active.updated_at = Set(Utc::now());
let model = active
.update(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.name,
"description": model.description,
"created_by": model.created_by.to_string(),
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
}))
}
// ─── create board card ───────────────────────────────────────────────────────
pub async fn create_board_card_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
let board_id = args
.get("board_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())
.ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?;
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("title is required".into()))?
.to_string();
let column_id = args
.get("column_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok());
let description = args
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let priority = args
.get("priority")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let assignee_id = args
.get("assignee_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok());
// Verify board belongs to project
let board = ProjectBoard::find_by_id(board_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?;
if board.project != project_id {
return Err(ToolError::ExecutionError(
"Board does not belong to this project".into(),
));
}
// Get target column (first column if not specified)
let target_column = if let Some(col_id) = column_id {
let col = ProjectBoardColumn::find_by_id(col_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?;
if col.board != board_id {
return Err(ToolError::ExecutionError(
"Column does not belong to this board".into(),
));
}
col
} else {
ProjectBoardColumn::find()
.filter(project_board_column::Column::Board.eq(board_id))
.order_by_asc(project_board_column::Column::Position)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("No columns found in this board".into()))?
};
// Next position
let max_pos: Option<Option<i32>> = ProjectBoardCard::find()
.filter(project_board_card::Column::Column.eq(target_column.id))
.select_only()
.column_as(project_board_card::Column::Position.max(), "max_pos")
.into_tuple::<Option<i32>>()
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let position = max_pos.flatten().unwrap_or(0) + 1;
let now = Utc::now();
let active = project_board_card::ActiveModel {
id: Set(Uuid::now_v7()),
column: Set(target_column.id),
issue_id: Set(None),
project: Set(Some(project_id)),
title: Set(title),
description: Set(description),
position: Set(position),
assignee_id: Set(assignee_id),
due_date: Set(None),
priority: Set(priority),
created_by: Set(sender_id),
created_at: Set(now),
updated_at: Set(now),
};
let model = active
.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"column_id": model.column.to_string(),
"title": model.title,
"description": model.description,
"position": model.position,
"assignee_id": model.assignee_id.map(|id| id.to_string()),
"priority": model.priority,
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
}))
}
// ─── update board card ────────────────────────────────────────────────────────
pub async fn update_board_card_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let card_id = args
.get("card_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())
.ok_or_else(|| ToolError::ExecutionError("card_id is required".into()))?;
let card = ProjectBoardCard::find_by_id(card_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Card not found".into()))?;
// Verify card belongs to a column in this project's board
let col = ProjectBoardColumn::find_by_id(card.column)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?;
let board = ProjectBoard::find_by_id(col.board)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?;
if board.project != project_id {
return Err(ToolError::ExecutionError(
"Card does not belong to this project".into(),
));
}
let mut active: project_board_card::ActiveModel = card.clone().into();
let mut updated = false;
if let Some(title) = args.get("title").and_then(|v| v.as_str()) {
active.title = Set(title.to_string());
updated = true;
}
if let Some(description) = args.get("description") {
active.description = Set(description.as_str().map(|s| s.to_string()));
updated = true;
}
if let Some(column_id) = args
.get("column_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())
{
// Verify column belongs to the same board
let new_col = ProjectBoardColumn::find_by_id(column_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?;
if new_col.board != col.board {
return Err(ToolError::ExecutionError(
"Column does not belong to this board".into(),
));
}
active.column = Set(column_id);
updated = true;
}
if let Some(position) = args.get("position").and_then(|v| v.as_i64()) {
active.position = Set(position as i32);
updated = true;
}
if let Some(assignee_id) = args.get("assignee_id") {
active.assignee_id = Set(assignee_id.as_str().and_then(|s| Uuid::parse_str(s).ok()));
updated = true;
}
if let Some(priority) = args.get("priority") {
active.priority = Set(priority.as_str().map(|s| s.to_string()));
updated = true;
}
if !updated {
return Err(ToolError::ExecutionError(
"At least one field must be provided".into(),
));
}
active.updated_at = Set(Utc::now());
let model = active
.update(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"column_id": model.column.to_string(),
"title": model.title,
"description": model.description,
"position": model.position,
"assignee_id": model.assignee_id.map(|id| id.to_string()),
"priority": model.priority,
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
}))
}
// ─── delete board card ─────────────────────────────────────────────────────────
pub async fn delete_board_card_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let card_id = args
.get("card_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())
.ok_or_else(|| ToolError::ExecutionError("card_id is required".into()))?;
let card = ProjectBoardCard::find_by_id(card_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Card not found".into()))?;
let col = ProjectBoardColumn::find_by_id(card.column)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?;
let board = ProjectBoard::find_by_id(col.board)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?;
if board.project != project_id {
return Err(ToolError::ExecutionError(
"Card does not belong to this project".into(),
));
}
ProjectBoardCard::delete_by_id(card_id)
.exec(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({ "deleted": true }))
}
// ─── tool definitions ─────────────────────────────────────────────────────────
pub fn list_tool_definition() -> ToolDefinition {
ToolDefinition::new("project_list_boards")
.description(
"List all Kanban boards in the current project. \
Returns boards with their columns and cards, including positions and priorities.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: None,
required: None,
})
}
pub fn create_board_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Board name (required).".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("Board description. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_create_board")
.description(
"Create a new Kanban board in the current project. Requires admin or owner role.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["name".into()]),
})
}
pub fn update_board_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("board_id".into(), ToolParam {
name: "board_id".into(), param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("New board name. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("New board description. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_update_board")
.description(
"Update a Kanban board (name or description). Requires admin or owner role.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["board_id".into()]),
})
}
pub fn create_card_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("board_id".into(), ToolParam {
name: "board_id".into(), param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("column_id".into(), ToolParam {
name: "column_id".into(), param_type: "string".into(),
description: Some("Column UUID. Optional — defaults to first column.".into()),
required: false, properties: None, items: None,
});
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("Card title (required).".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("Card description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("priority".into(), ToolParam {
name: "priority".into(), param_type: "string".into(),
description: Some("Card priority (e.g. 'low', 'medium', 'high'). Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("assignee_id".into(), ToolParam {
name: "assignee_id".into(), param_type: "string".into(),
description: Some("Assignee user UUID. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_create_board_card")
.description(
"Create a card on a Kanban board. If column_id is not provided, \
the card is added to the first column.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["board_id".into(), "title".into()]),
})
}
pub fn update_card_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("card_id".into(), ToolParam {
name: "card_id".into(), param_type: "string".into(),
description: Some("Card UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("New card title. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("New card description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("column_id".into(), ToolParam {
name: "column_id".into(), param_type: "string".into(),
description: Some("Move card to a different column. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("position".into(), ToolParam {
name: "position".into(), param_type: "integer".into(),
description: Some("New position within column. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("priority".into(), ToolParam {
name: "priority".into(), param_type: "string".into(),
description: Some("New priority. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("assignee_id".into(), ToolParam {
name: "assignee_id".into(), param_type: "string".into(),
description: Some("New assignee UUID. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_update_board_card")
.description(
"Update a board card (title, description, column, position, assignee, priority). \
Requires admin or owner role.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["card_id".into()]),
})
}
pub fn delete_card_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("card_id".into(), ToolParam {
name: "card_id".into(), param_type: "string".into(),
description: Some("Card UUID (required).".into()),
required: true, properties: None, items: None,
});
ToolDefinition::new("project_delete_board_card")
.description("Delete a board card. Requires admin or owner role.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["card_id".into()]),
})
}

View File

@ -0,0 +1,180 @@
//! Tool: project_curl — perform HTTP requests (GET/POST/PUT/DELETE)
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use std::collections::HashMap;
/// Maximum response body size: 1 MB.
const MAX_BODY_BYTES: usize = 1 << 20;
/// Perform an HTTP request and return the response body and metadata.
/// Supports GET, POST, PUT, DELETE methods. Useful for fetching web pages,
/// calling external APIs, or downloading resources.
pub async fn curl_exec(
_ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let url = args
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("url is required".into()))?;
let method = args
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("GET")
.to_uppercase();
let body = args.get("body").and_then(|v| v.as_str()).map(String::from);
let headers: Vec<(String, String)> = args
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
let timeout_secs = args
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(30)
.min(120);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| ToolError::ExecutionError(format!("Failed to build HTTP client: {}", e)))?;
let mut request = match method.as_str() {
"GET" => client.get(url),
"POST" => client.post(url),
"PUT" => client.put(url),
"DELETE" => client.delete(url),
"PATCH" => client.patch(url),
"HEAD" => client.head(url),
_ => {
return Err(ToolError::ExecutionError(format!(
"Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.",
method
)))
}
};
for (key, value) in &headers {
request = request.header(key, value);
}
// Set default Content-Type for POST/PUT/PATCH if not provided and body exists
if body.is_some() && !headers.iter().any(|(k, _)| k.to_lowercase() == "content-type") {
request = request.header("Content-Type", "application/json");
}
if let Some(ref b) = body {
request = request.body(b.clone());
}
let response = request
.send()
.await
.map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?;
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("");
let response_headers: std::collections::HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| {
(
k.to_string(),
v.to_str().unwrap_or("<binary>").to_string(),
)
})
.collect();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let is_text = content_type.starts_with("text/")
|| content_type.contains("json")
|| content_type.contains("xml")
|| content_type.contains("javascript");
let body_bytes = response
.bytes()
.await
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response body: {}", e)))?;
let body_len = body_bytes.len();
let truncated = body_len > MAX_BODY_BYTES;
let body_text = if truncated {
String::from("[Response truncated — exceeds 1 MB limit]")
} else if is_text {
String::from_utf8_lossy(&body_bytes).to_string()
} else {
format!(
"[Binary body, {} bytes, Content-Type: {}]",
body_len, content_type
)
};
Ok(serde_json::json!({
"url": url,
"method": method,
"status": status,
"status_text": status_text,
"headers": response_headers,
"body": body_text,
"truncated": truncated,
"size_bytes": body_len,
}))
}
// ─── tool definition ─────────────────────────────────────────────────────────
pub fn tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("url".into(), ToolParam {
name: "url".into(), param_type: "string".into(),
description: Some("Full URL to request (required).".into()),
required: true, properties: None, items: None,
});
p.insert("method".into(), ToolParam {
name: "method".into(), param_type: "string".into(),
description: Some("HTTP method: GET (default), POST, PUT, DELETE, PATCH, HEAD.".into()),
required: false, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("Request body. Defaults to 'application/json' Content-Type if provided. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("headers".into(), ToolParam {
name: "headers".into(), param_type: "object".into(),
description: Some("HTTP headers as key-value pairs. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("timeout".into(), ToolParam {
name: "timeout".into(), param_type: "integer".into(),
description: Some("Request timeout in seconds (default 30, max 120). Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_curl")
.description(
"Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \
Returns status code, headers, and response body. \
Response body is truncated at 1 MB. Binary responses are described as text metadata. \
Useful for fetching web pages, calling APIs, or downloading resources.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["url".into()]),
})
}

View File

@ -0,0 +1,535 @@
//! Tools: project_list_issues, project_create_issue, project_update_issue
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use models::issues::{issue, issue_assignee, issue_label, Issue, IssueAssignee, IssueLabel, IssueState};
use models::projects::{MemberRole, ProjectMember};
use models::projects::project_members;
use models::system::{Label, label};
use models::users::User;
use sea_orm::*;
use std::collections::HashMap;
use uuid::Uuid;
// ─── list ─────────────────────────────────────────────────────────────────────
pub async fn list_issues_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let db = ctx.db();
let state_filter = args
.get("state")
.and_then(|v| v.as_str())
.map(|s| s.to_lowercase());
let mut query = issue::Entity::find().filter(issue::Column::Project.eq(project_id));
if let Some(ref state) = state_filter {
query = query.filter(issue::Column::State.eq(state));
}
let issues = query
.order_by_desc(issue::Column::CreatedAt)
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let issue_ids: Vec<_> = issues.iter().map(|i| i.id).collect();
let assignees = IssueAssignee::find()
.filter(issue_assignee::Column::Issue.is_in(issue_ids.clone()))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let assignee_user_ids: Vec<_> = assignees.iter().map(|a| a.user).collect();
let assignee_users = User::find()
.filter(models::users::user::Column::Uid.is_in(assignee_user_ids))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let user_map: std::collections::HashMap<_, _> =
assignee_users.into_iter().map(|u| (u.uid, u)).collect();
let issue_labels = IssueLabel::find()
.filter(issue_label::Column::Issue.is_in(issue_ids.clone()))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let label_ids: Vec<_> = issue_labels.iter().map(|l| l.label).collect();
let labels = Label::find()
.filter(label::Column::Id.is_in(label_ids))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let label_map: std::collections::HashMap<_, _> =
labels.into_iter().map(|l| (l.id, l)).collect();
let assignee_map: std::collections::HashMap<_, Vec<_>> = assignees
.into_iter()
.filter_map(|a| {
let user = user_map.get(&a.user)?;
Some((
a.issue,
serde_json::json!({
"id": a.user.to_string(),
"username": user.username,
"display_name": user.display_name,
}),
))
})
.fold(
std::collections::HashMap::new(),
|mut acc, (issue_id, user)| {
acc.entry(issue_id).or_default().push(user);
acc
},
);
let issue_label_map: std::collections::HashMap<_, Vec<_>> = issue_labels
.into_iter()
.filter_map(|il| {
let label = label_map.get(&il.label)?;
Some((
il.issue,
serde_json::json!({
"id": il.label,
"name": label.name,
"color": label.color,
}),
))
})
.fold(
std::collections::HashMap::new(),
|mut acc, (issue_id, label)| {
acc.entry(issue_id).or_default().push(label);
acc
},
);
let result: Vec<_> = issues
.into_iter()
.map(|i| {
serde_json::json!({
"id": i.id.to_string(),
"number": i.number,
"title": i.title,
"body": i.body,
"state": i.state,
"author_id": i.author.to_string(),
"milestone": i.milestone,
"created_at": i.created_at.to_rfc3339(),
"updated_at": i.updated_at.to_rfc3339(),
"closed_at": i.closed_at.map(|t| t.to_rfc3339()),
"assignees": assignee_map.get(&i.id).unwrap_or(&vec![]),
"labels": issue_label_map.get(&i.id).unwrap_or(&vec![]),
})
})
.collect();
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
}
// ─── helpers ───────────────────────────────────────────────────────────────────
/// Check if the user is the issue author OR an admin/owner of the project.
async fn require_issue_modifier(
db: &impl ConnectionTrait,
project_id: Uuid,
sender_id: Uuid,
author_id: Uuid,
) -> Result<(), ToolError> {
// Author can always modify their own issue
if sender_id == author_id {
return Ok(());
}
// Otherwise require admin or owner
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => Ok(()),
MemberRole::Member => Err(ToolError::ExecutionError(
"Only the issue author or admin/owner can modify this issue".into(),
)),
}
}
// ─── create ───────────────────────────────────────────────────────────────────
async fn next_issue_number(db: &impl ConnectionTrait, project_id: Uuid) -> Result<i64, ToolError> {
let max_num: Option<Option<i64>> = Issue::find()
.filter(issue::Column::Project.eq(project_id))
.select_only()
.column_as(issue::Column::Number.max(), "max_num")
.into_tuple::<Option<i64>>()
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(max_num.flatten().unwrap_or(0) + 1)
}
pub async fn create_issue_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let db = ctx.db();
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("title is required".into()))?
.to_string();
let body = args
.get("body")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let milestone = args
.get("milestone")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let assignee_ids: Vec<Uuid> = args
.get("assignee_ids")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| Uuid::parse_str(v.as_str()?).ok())
.collect()
})
.unwrap_or_default();
let label_ids: Vec<i64> = args
.get("label_ids")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect())
.unwrap_or_default();
let author_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let number = next_issue_number(db, project_id).await?;
let now = Utc::now();
let active = issue::ActiveModel {
id: Set(Uuid::now_v7()),
project: Set(project_id),
number: Set(number),
title: Set(title.clone()),
body: Set(body),
state: Set(IssueState::Open.to_string()),
author: Set(author_id),
milestone: Set(milestone),
created_at: Set(now),
updated_at: Set(now),
closed_at: Set(None),
created_by_ai: Set(true),
..Default::default()
};
let model = active
.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Add assignees
for uid in &assignee_ids {
let a = issue_assignee::ActiveModel {
issue: Set(model.id),
user: Set(*uid),
assigned_at: Set(now),
..Default::default()
};
let _ = a.insert(db).await;
}
// Add labels
for lid in &label_ids {
let l = issue_label::ActiveModel {
issue: Set(model.id),
label: Set(*lid),
relation_at: Set(now),
..Default::default()
};
let _ = l.insert(db).await;
}
// Build assignee/label maps for response
let assignee_map: std::collections::HashMap<Uuid, serde_json::Value> =
if !assignee_ids.is_empty() {
let users = User::find()
.filter(models::users::user::Column::Uid.is_in(assignee_ids.clone()))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
users
.into_iter()
.map(|u| {
(
u.uid,
serde_json::json!({
"id": u.uid.to_string(),
"username": u.username,
"display_name": u.display_name,
}),
)
})
.collect()
} else {
std::collections::HashMap::new()
};
let label_map: std::collections::HashMap<i64, serde_json::Value> = if !label_ids.is_empty() {
let labels = Label::find()
.filter(label::Column::Id.is_in(label_ids.clone()))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
labels
.into_iter()
.map(|l| {
(
l.id,
serde_json::json!({
"id": l.id,
"name": l.name,
"color": l.color,
}),
)
})
.collect()
} else {
std::collections::HashMap::new()
};
Ok(serde_json::json!({
"id": model.id.to_string(),
"number": model.number,
"title": model.title,
"body": model.body,
"state": model.state,
"author_id": model.author.to_string(),
"milestone": model.milestone,
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
"assignees": assignee_ids.iter().filter_map(|uid| assignee_map.get(uid)).collect::<Vec<_>>(),
"labels": label_ids.iter().filter_map(|lid| label_map.get(lid)).collect::<Vec<_>>(),
}))
}
// ─── update ───────────────────────────────────────────────────────────────────
pub async fn update_issue_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
let number = args
.get("number")
.and_then(|v| v.as_i64())
.ok_or_else(|| ToolError::ExecutionError("number is required".into()))?;
// Find the issue
let issue = Issue::find()
.filter(issue::Column::Project.eq(project_id))
.filter(issue::Column::Number.eq(number))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError(format!("Issue #{} not found", number)))?;
// Permission check: author OR admin/owner
require_issue_modifier(db, project_id, sender_id, issue.author).await?;
let mut active: issue::ActiveModel = issue.clone().into();
let mut updated = false;
let now = Utc::now();
if let Some(title) = args.get("title").and_then(|v| v.as_str()) {
active.title = Set(title.to_string());
updated = true;
}
if let Some(body) = args.get("body").and_then(|v| v.as_str()) {
active.body = Set(Some(body.to_string()));
updated = true;
}
if let Some(state) = args.get("state").and_then(|v| v.as_str()) {
let s = state.to_lowercase();
if s == "open" || s == "closed" {
active.state = Set(s.clone());
active.updated_at = Set(now);
if s == "closed" {
active.closed_at = Set(Some(now));
} else {
active.closed_at = Set(None);
}
updated = true;
}
}
if let Some(milestone) = args.get("milestone") {
if milestone.is_null() {
active.milestone = Set(None);
} else if let Some(m) = milestone.as_str() {
active.milestone = Set(Some(m.to_string()));
}
updated = true;
}
if updated {
active.updated_at = Set(now);
active
.update(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
}
// Reload for response
let updated_issue = Issue::find()
.filter(issue::Column::Id.eq(issue.id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Issue not found after update".into()))?;
Ok(serde_json::json!({
"id": updated_issue.id.to_string(),
"number": updated_issue.number,
"title": updated_issue.title,
"body": updated_issue.body,
"state": updated_issue.state,
"author_id": updated_issue.author.to_string(),
"milestone": updated_issue.milestone,
"created_at": updated_issue.created_at.to_rfc3339(),
"updated_at": updated_issue.updated_at.to_rfc3339(),
"closed_at": updated_issue.closed_at.map(|t| t.to_rfc3339()),
}))
}
// ─── tool definitions ─────────────────────────────────────────────────────────
pub fn list_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("state".into(), ToolParam {
name: "state".into(), param_type: "string".into(),
description: Some("Filter by issue state: 'open' or 'closed'. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_list_issues")
.description(
"List all issues in the current project. \
Returns issue number, title, body, state, author, assignees, labels, and timestamps.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: None,
})
}
pub fn create_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("Issue title (required).".into()),
required: true, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("Issue body / description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("milestone".into(), ToolParam {
name: "milestone".into(), param_type: "string".into(),
description: Some("Milestone name. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("assignee_ids".into(), ToolParam {
name: "assignee_ids".into(), param_type: "array".into(),
description: Some("Array of user UUIDs to assign. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "string".into(),
description: None, required: false, properties: None, items: None,
})),
});
p.insert("label_ids".into(), ToolParam {
name: "label_ids".into(), param_type: "array".into(),
description: Some("Array of label IDs to apply. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "integer".into(),
description: None, required: false, properties: None, items: None,
})),
});
ToolDefinition::new("project_create_issue")
.description(
"Create a new issue in the current project. \
Returns the created issue with its number, id, and full details.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["title".into()]),
})
}
pub fn update_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("number".into(), ToolParam {
name: "number".into(), param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true, properties: None, items: None,
});
p.insert("title".into(), ToolParam {
name: "title".into(), param_type: "string".into(),
description: Some("New issue title. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("New issue body. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("state".into(), ToolParam {
name: "state".into(), param_type: "string".into(),
description: Some("New issue state: 'open' or 'closed'. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("milestone".into(), ToolParam {
name: "milestone".into(), param_type: "string".into(),
description: Some("New milestone name. Set to null to remove. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_update_issue")
.description(
"Update an existing issue in the current project by its number. \
Requires the issue author or a project admin/owner. \
Returns the updated issue. At least one field must be provided.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["number".into()]),
})
}

View File

@ -0,0 +1,64 @@
//! Tool: project_list_members
use agent::{ToolContext, ToolDefinition, ToolError, ToolSchema};
use models::projects::project_members;
use models::users::User;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
pub async fn list_members_exec(
ctx: ToolContext,
_args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let db = ctx.db();
let members = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project_id))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let user_ids: Vec<_> = members.iter().map(|m| m.user).collect();
let users = User::find()
.filter(models::users::user::Column::Uid.is_in(user_ids))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let user_map: std::collections::HashMap<_, _> =
users.into_iter().map(|u| (u.uid, u)).collect();
let result: Vec<_> = members
.into_iter()
.filter_map(|m| {
let user = user_map.get(&m.user)?;
let role = m
.scope_role()
.map(|r| r.to_string())
.unwrap_or_else(|_| "member".to_string());
Some(serde_json::json!({
"id": m.user.to_string(),
"username": user.username,
"display_name": user.display_name,
"organization": user.organization,
"role": role,
"joined_at": m.joined_at.to_rfc3339(),
}))
})
.collect();
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
}
pub fn tool_definition() -> ToolDefinition {
ToolDefinition::new("project_list_members")
.description(
"List all members in the current project. \
Returns username, display name, organization, role, and join time.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: None,
required: None,
})
}

View File

@ -0,0 +1,104 @@
//! Project tools for AI agent function calling.
//!
//! Tools that let the agent perceive and modify the current project context:
//! - list repos in the project
//! - list members in the project
//! - list / create / update issues
//! - list / create / update boards and board cards
mod arxiv;
mod boards;
mod curl;
mod issues;
mod members;
mod repos;
use agent::{ToolHandler, ToolRegistry};
pub use arxiv::arxiv_search_exec;
pub use boards::{
create_board_card_exec, create_board_exec, delete_board_card_exec, list_boards_exec,
update_board_card_exec, update_board_exec,
};
pub use curl::curl_exec;
pub use issues::{create_issue_exec, list_issues_exec, update_issue_exec};
pub use members::list_members_exec;
pub use repos::{create_commit_exec, create_repo_exec, list_repos_exec, update_repo_exec};
pub fn register_all(registry: &mut ToolRegistry) {
// arxiv
registry.register(
arxiv::tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))),
);
// curl
registry.register(
curl::tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))),
);
// repos
registry.register(
repos::list_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(list_repos_exec(ctx, args))),
);
registry.register(
repos::create_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(create_repo_exec(ctx, args))),
);
registry.register(
repos::update_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(update_repo_exec(ctx, args))),
);
registry.register(
repos::create_commit_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(create_commit_exec(ctx, args))),
);
// members
registry.register(
members::tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(list_members_exec(ctx, args))),
);
// issues
registry.register(
issues::list_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(list_issues_exec(ctx, args))),
);
registry.register(
issues::create_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(create_issue_exec(ctx, args))),
);
registry.register(
issues::update_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(update_issue_exec(ctx, args))),
);
// boards
registry.register(
boards::list_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(list_boards_exec(ctx, args))),
);
registry.register(
boards::create_board_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(create_board_exec(ctx, args))),
);
registry.register(
boards::update_board_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(update_board_exec(ctx, args))),
);
registry.register(
boards::create_card_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(create_board_card_exec(ctx, args))),
);
registry.register(
boards::update_card_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(update_board_card_exec(ctx, args))),
);
registry.register(
boards::delete_card_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(delete_board_card_exec(ctx, args))),
);
}

View File

@ -0,0 +1,559 @@
//! Tool: project_list_repos, project_create_repo, project_create_commit
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use git::commit::types::CommitOid;
use git::commit::types::CommitSignature;
use models::projects::{MemberRole, ProjectMember};
use models::projects::project_members;
use models::repos::repo;
use models::users::user_email;
use sea_orm::*;
use std::collections::HashMap;
use std::path::PathBuf;
use uuid::Uuid;
// ─── list ─────────────────────────────────────────────────────────────────────
pub async fn list_repos_exec(
ctx: ToolContext,
_args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let db = ctx.db();
let repos = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.order_by_asc(repo::Column::RepoName)
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let result: Vec<_> = repos
.into_iter()
.map(|r| {
serde_json::json!({
"id": r.id.to_string(),
"name": r.repo_name,
"description": r.description,
"default_branch": r.default_branch,
"is_private": r.is_private,
"created_at": r.created_at.to_rfc3339(),
})
})
.collect();
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
}
// ─── create ───────────────────────────────────────────────────────────────────
pub async fn create_repo_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
// Admin/owner check
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => {}
MemberRole::Member => {
return Err(ToolError::ExecutionError(
"Only admin or owner can create repositories".into(),
));
}
}
let repo_name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
let description = args
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let is_private = args
.get("is_private")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Check name uniqueness within project
let existing = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.filter(repo::Column::RepoName.eq(&repo_name))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if existing.is_some() {
return Err(ToolError::ExecutionError(format!(
"Repository '{}' already exists in this project",
repo_name
)));
}
// Look up project name for storage_path
let project = models::projects::project::Entity::find_by_id(project_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Project not found".into()))?;
let repos_root = ctx
.config()
.repos_root()
.map_err(|e| ToolError::ExecutionError(format!("repos_root config error: {}", e)))?;
let repo_dir: PathBuf = [&repos_root, &project.name, &format!("{}.git", repo_name)]
.iter()
.collect();
let now = Utc::now();
let active = repo::ActiveModel {
id: Set(Uuid::now_v7()),
repo_name: Set(repo_name.clone()),
project: Set(project_id),
description: Set(description),
default_branch: Set("main".to_string()),
is_private: Set(is_private),
storage_path: Set(repo_dir.to_string_lossy().to_string()),
created_by: Set(sender_id),
created_at: Set(now),
updated_at: Set(now),
ai_code_review_enabled: Set(false),
};
let model = active
.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.repo_name,
"description": model.description,
"default_branch": model.default_branch,
"is_private": model.is_private,
"storage_path": model.storage_path,
"created_at": model.created_at.to_rfc3339(),
}))
}
// ─── update ───────────────────────────────────────────────────────────────────
pub async fn update_repo_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
// Admin/owner check
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => {}
MemberRole::Member => {
return Err(ToolError::ExecutionError(
"Only admin or owner can update repositories".into(),
));
}
}
let repo_name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
let repo = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.filter(repo::Column::RepoName.eq(&repo_name))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| {
ToolError::ExecutionError(format!("Repository '{}' not found", repo_name))
})?;
let mut active: repo::ActiveModel = repo.clone().into();
let mut updated = false;
if let Some(description) = args.get("description") {
active.description = Set(description.as_str().map(|s| s.to_string()));
updated = true;
}
if let Some(is_private) = args.get("is_private").and_then(|v| v.as_bool()) {
active.is_private = Set(is_private);
updated = true;
}
if let Some(default_branch) = args.get("default_branch").and_then(|v| v.as_str()) {
active.default_branch = Set(default_branch.to_string());
updated = true;
}
if !updated {
return Err(ToolError::ExecutionError(
"At least one field must be provided".into(),
));
}
active.updated_at = Set(Utc::now());
let model = active
.update(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.repo_name,
"description": model.description,
"default_branch": model.default_branch,
"is_private": model.is_private,
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
}))
}
// ─── create commit ────────────────────────────────────────────────────────────
pub async fn create_commit_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
// Admin/owner check
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let member = member
.ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?;
let role = member
.scope_role()
.map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?;
match role {
MemberRole::Admin | MemberRole::Owner => {}
MemberRole::Member => {
return Err(ToolError::ExecutionError(
"Only admin or owner can create commits".into(),
));
}
}
let repo_name = args
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("repo_name is required".into()))?;
let message = args
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("message is required".into()))?
.to_string();
let branch = args
.get("branch")
.and_then(|v| v.as_str())
.unwrap_or("main")
.to_string();
let files = args
.get("files")
.and_then(|v| v.as_array())
.ok_or_else(|| {
ToolError::ExecutionError("files is required and must be an array".into())
})?;
if files.is_empty() {
return Err(ToolError::ExecutionError(
"files array cannot be empty".into(),
));
}
// Clone files data for spawn_blocking
let files_data: Vec<serde_json::Value> = files.iter().cloned().collect();
// Look up sender username and email
let sender = models::users::user::Entity::find_by_id(sender_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Sender user not found".into()))?;
let sender_email = user_email::Entity::find_by_id(sender_id)
.one(db)
.await
.ok()
.flatten()
.map(|e| e.email)
.unwrap_or_else(|| format!("{}@gitdata.ai", sender.username));
let author_name = sender
.display_name
.unwrap_or_else(|| sender.username.clone());
// Find repo
let repo_model = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.filter(repo::Column::RepoName.eq(repo_name))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| {
ToolError::ExecutionError(format!("Repository '{}' not found", repo_name))
})?;
let storage_path = repo_model.storage_path.clone();
// Run git operations in a blocking thread
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::open(&storage_path)
.map_err(|e| ToolError::ExecutionError(format!("Failed to open repo: {}", e)))?;
let repo = domain.repo();
// Get current head commit (parent)
let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok();
let parent_ids: Vec<CommitOid> = parent_oid
.map(|oid| CommitOid::from_git2(oid))
.into_iter()
.collect();
// Build index with new files
let mut index = repo
.index()
.map_err(|e| ToolError::ExecutionError(format!("Failed to get index: {}", e)))?;
for file in files_data {
let path = file
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("Each file must have a 'path'".into()))?;
let content = file
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("Each file must have 'content'".into()))?;
let _oid = repo.blob(content.as_bytes()).map_err(|e| {
ToolError::ExecutionError(format!("Failed to write blob for '{}': {}", path, e))
})?;
index.add_path(path.as_ref()).map_err(|e| {
ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e))
})?;
}
let tree_oid = index
.write_tree()
.map_err(|e| ToolError::ExecutionError(format!("Failed to write tree: {}", e)))?;
let tree_id = CommitOid::from_git2(tree_oid);
// Author signature
let author = CommitSignature {
name: author_name.clone(),
email: sender_email.clone(),
time_secs: chrono::Utc::now().timestamp(),
offset_minutes: 0,
};
// Committer signature: gitpanda <info@gitdata.ai>
let committer = CommitSignature {
name: "gitpanda".to_string(),
email: "info@gitdata.ai".to_string(),
time_secs: chrono::Utc::now().timestamp(),
offset_minutes: 0,
};
let commit_oid = domain
.commit_create(
Some(&format!("refs/heads/{}", branch)),
&author,
&committer,
&message,
&tree_id,
&parent_ids,
)
.map_err(|e| ToolError::ExecutionError(format!("Failed to create commit: {}", e)))?;
Ok::<_, ToolError>(serde_json::json!({
"commit_oid": commit_oid.to_string(),
"branch": branch,
"message": message,
"author_name": author_name,
"author_email": sender_email,
}))
})
.await
.map_err(|e| ToolError::ExecutionError(format!("Task join error: {}", e)))?;
result
}
// ─── tool definitions ─────────────────────────────────────────────────────────
pub fn list_tool_definition() -> ToolDefinition {
ToolDefinition::new("project_list_repos")
.description(
"List all repositories in the current project. \
Returns repo name, description, default branch, privacy status, and creation time.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: None,
required: None,
})
}
pub fn create_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Repository name (required). Must be unique within the project.".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("Repository description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("is_private".into(), ToolParam {
name: "is_private".into(), param_type: "boolean".into(),
description: Some("Whether the repo is private. Defaults to false. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_create_repo")
.description(
"Create a new repository in the current project. \
Requires admin or owner role. \
The repo is initialized with a bare git structure.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["name".into()]),
})
}
pub fn update_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Repository name (required).".into()),
required: true, properties: None, items: None,
});
p.insert("description".into(), ToolParam {
name: "description".into(), param_type: "string".into(),
description: Some("New repository description. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("is_private".into(), ToolParam {
name: "is_private".into(), param_type: "boolean".into(),
description: Some("New privacy setting. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("default_branch".into(), ToolParam {
name: "default_branch".into(), param_type: "string".into(),
description: Some("New default branch name. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_update_repo")
.description(
"Update a repository's description, privacy, or default branch. \
Requires admin or owner role.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["name".into()]),
})
}
pub fn create_commit_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("repo_name".into(), ToolParam {
name: "repo_name".into(), param_type: "string".into(),
description: Some("Repository name (required).".into()),
required: true, properties: None, items: None,
});
p.insert("branch".into(), ToolParam {
name: "branch".into(), param_type: "string".into(),
description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("message".into(), ToolParam {
name: "message".into(), param_type: "string".into(),
description: Some("Commit message (required).".into()),
required: true, properties: None, items: None,
});
// files items
let mut file_item = HashMap::new();
file_item.insert("path".into(), ToolParam {
name: "path".into(), param_type: "string".into(),
description: Some("File path in the repo (required).".into()),
required: true, properties: None, items: None,
});
file_item.insert("content".into(), ToolParam {
name: "content".into(), param_type: "string".into(),
description: Some("Full file content as string (required).".into()),
required: true, properties: None, items: None,
});
p.insert("files".into(), ToolParam {
name: "files".into(), param_type: "array".into(),
description: Some("Array of files to commit (required, non-empty).".into()),
required: true, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "object".into(),
description: None, required: true, properties: Some(file_item), items: None,
})),
});
ToolDefinition::new("project_create_commit")
.description(
"Create a new commit in a repository. Commits the given files to the specified branch. \
Requires admin or owner role. \
Committer is always 'gitpanda <info@gitdata.ai>'. \
Author is the sender's display name and email.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["repo_name".into(), "message".into(), "files".into()]),
})
}

View File

@ -57,4 +57,14 @@ impl AppStorage {
}
Ok(())
}
/// Read a file by key and return (bytes, content_type).
pub async fn read(&self, key: &str) -> anyhow::Result<(Vec<u8>, String)> {
let path = self.base_path.join(key);
let data = tokio::fs::read(&path).await?;
let content_type = mime_guess2::from_path(&path)
.first_or_octet_stream()
.to_string();
Ok((data, content_type))
}
}

View File

@ -29,6 +29,7 @@ function ProjectRoomInner() {
members,
} = useRoom();
const [showChannelSidebar, setShowChannelSidebar] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -119,16 +120,18 @@ function ProjectRoomInner() {
return (
<div className="discord-layout">
{/* Channel sidebar */}
<DiscordChannelSidebar
projectName={projectName}
rooms={rooms}
selectedRoomId={activeRoomId}
onSelectRoom={handleSelectRoom}
onCreateRoom={handleOpenCreate}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
onCreateCategory={handleCreateCategory}
onMoveRoomToCategory={handleMoveRoomToCategory}
/>
{showChannelSidebar && (
<DiscordChannelSidebar
projectName={projectName}
rooms={rooms}
selectedRoomId={activeRoomId}
onSelectRoom={handleSelectRoom}
onCreateRoom={handleOpenCreate}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
onCreateCategory={handleCreateCategory}
onMoveRoomToCategory={handleMoveRoomToCategory}
/>
)}
{/* Main chat area */}
{activeRoom ? (
@ -136,6 +139,8 @@ function ProjectRoomInner() {
room={activeRoom}
isAdmin={isAdmin}
onClose={handleClose}
onToggleChannelSidebar={() => setShowChannelSidebar((v) => !v)}
channelSidebarOpen={showChannelSidebar}
onDelete={() => setDeleteDialogOpen(true)}
/>
) : (

View File

@ -4434,6 +4434,7 @@ export type RoomMessageCreateRequest = {
content_type?: string | null;
thread_id?: string | null;
in_reply_to?: string | null;
attachment_ids?: string[];
};
export type RoomMessageListResponse = {
@ -4456,6 +4457,7 @@ export type RoomMessageResponse = {
send_at: string;
revoked?: string | null;
revoked_by?: string | null;
attachment_ids?: string[];
};
export type RoomMessageUpdateRequest = {

View File

@ -10,32 +10,47 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {BookOpen, Box, ChevronDown, Compass, Home, LayoutGrid, Monitor, Moon, Plus, Sun, Users} from 'lucide-react';
import {
BookOpen,
Box,
ChevronDown,
Compass,
Home,
LayoutGrid,
Monitor,
Moon,
Plus,
Sliders,
Sun,
Users
} from 'lucide-react';
import {useNavigate} from 'react-router-dom';
import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar';
import {useState} from 'react';
import {ThemeSwitcher} from '@/components/room/ThemeSwitcher';
const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm';
export function SidebarSystem({collapsed}: {collapsed: boolean}) {
export function SidebarSystem({collapsed}: { collapsed: boolean }) {
const {theme, setTheme} = useTheme();
const navigate = useNavigate();
const workspaceCtx = tryUseWorkspace();
const workspaces = workspaceCtx?.workspaces;
const currentWorkspace = workspaceCtx?.currentWorkspace;
const [themeSheetOpen, setThemeSheetOpen] = useState(false);
return (
<div className="w-full">
{/* Workspace switcher — only shown when inside WorkspaceProvider */}
{workspaceCtx && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
/>
}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
/>
}
>
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
{currentWorkspace ? (
<Avatar className="h-4 w-4">
@ -48,70 +63,74 @@ export function SidebarSystem({collapsed}: {collapsed: boolean}) {
<LayoutGrid className="h-4 w-4"/>
)}
</span>
{!collapsed && (
<span className="flex-1 truncate text-sm leading-none">
{!collapsed && (
<span className="flex-1 truncate text-sm leading-none">
{currentWorkspace?.name || 'Workspaces'}
</span>
)}
{!collapsed && <ChevronDown className="h-3 w-3 ml-auto shrink-0"/>}
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
{workspaces?.workspaces.map((ws) => (
<DropdownMenuItem
key={ws.id}
onClick={() => navigate(`/w/${ws.slug}`)}
className="gap-2"
>
<Avatar className="h-5 w-5">
<AvatarImage src={ws.avatar_url || ''}/>
<AvatarFallback className="text-[9px]">
{ws.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">{ws.name}</span>
<span className="text-xs text-muted-foreground">@{ws.slug}</span>
)}
{!collapsed && <ChevronDown className="h-3 w-3 ml-auto shrink-0"/>}
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
{workspaces?.workspaces.map((ws) => (
<DropdownMenuItem
key={ws.id}
onClick={() => navigate(`/w/${ws.slug}`)}
className="gap-2"
>
<Avatar className="h-5 w-5">
<AvatarImage src={ws.avatar_url || ''}/>
<AvatarFallback className="text-[9px]">
{ws.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">{ws.name}</span>
<span className="text-xs text-muted-foreground">@{ws.slug}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={() => navigate('/w/me')} className="gap-2">
<Users className="h-4 w-4"/>
<span>All Workspaces</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={() => navigate('/w/me')} className="gap-2">
<Users className="h-4 w-4"/>
<span>All Workspaces</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/init/workspace')} className="gap-2">
<Plus className="h-4 w-4"/>
<span>Create Workspace</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem onClick={() => navigate('/init/workspace')} className="gap-2">
<Plus className="h-4 w-4"/>
<span>Create Workspace</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => navigate('/')}>
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/')}>
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
<Home className="h-4 w-4"/>
</span>
{!collapsed && <span className="text-sm leading-none">Home</span>}
</button>
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => navigate('/explore')}>
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
<Compass className="h-4 w-4" />
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/explore')}>
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
<Compass className="h-4 w-4"/>
</span>
{!collapsed && <span className="text-sm leading-none">Explore</span>}
</button>
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => navigate('/market')}>
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
<Box className="h-4 w-4" />
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/market')}>
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
<Box className="h-4 w-4"/>
</span>
{!collapsed && <span className="text-sm leading-none">Marketplace</span>}
</button>
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => window.open('/docs', '_blank')}>
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
<BookOpen className="h-4 w-4" />
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => window.open('/docs', '_blank')}>
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
<BookOpen className="h-4 w-4"/>
</span>
{!collapsed && <span className="text-sm leading-none">Docs</span>}
</button>
@ -125,13 +144,13 @@ export function SidebarSystem({collapsed}: {collapsed: boolean}) {
/>
}
>
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
{theme === 'dark' ? (
<Moon className="h-4 w-4" />
<Moon className="h-4 w-4"/>
) : theme === 'light' ? (
<Sun className="h-4 w-4" />
<Sun className="h-4 w-4"/>
) : (
<Monitor className="h-4 w-4" />
<Monitor className="h-4 w-4"/>
)}
</span>
{!collapsed && <span className="text-sm leading-none">Theme</span>}
@ -139,36 +158,34 @@ export function SidebarSystem({collapsed}: {collapsed: boolean}) {
<DropdownMenuContent side="right" align="start">
{!collapsed && (
<DropdownMenuGroup>
<DropdownMenuLabel>Theme Settings</DropdownMenuLabel>
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setThemeSheetOpen(true)}>
<Sliders className="mr-2 h-4 w-4"/>
<span>Design System</span>
</DropdownMenuItem>
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="mr-2 h-4 w-4" />
<Sun className="mr-2 h-4 w-4"/>
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="mr-2 h-4 w-4" />
<Moon className="mr-2 h-4 w-4"/>
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="mr-2 h-4 w-4" />
<Monitor className="mr-2 h-4 w-4"/>
<span>System</span>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
{collapsed && (
<>
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</>
<DropdownMenuItem onClick={() => setThemeSheetOpen(true)}>
<Sliders className="mr-2 h-4 w-4"/>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<ThemeSwitcher open={themeSheetOpen} onOpenChange={setThemeSheetOpen}/>
</div>
);
}
}

View File

@ -10,7 +10,7 @@ import type { MessageWithMeta } from '@/contexts';
import { cn } from '@/lib/utils';
import {
Hash, Lock, Users, Search, ChevronLeft,
AtSign, Pin, Settings,
AtSign, Pin, Settings, PanelLeft,
} from 'lucide-react';
import {
useCallback,
@ -38,9 +38,11 @@ interface DiscordChatPanelProps {
isAdmin: boolean;
onClose: () => void;
onDelete: () => void;
onToggleChannelSidebar: () => void;
channelSidebarOpen: boolean;
}
export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordChatPanelProps) {
export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleChannelSidebar, channelSidebarOpen }: DiscordChatPanelProps) {
const {
messages,
members,
@ -67,7 +69,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
const [selectedMessageForHistory, setSelectedMessageForHistory] = useState<string>('');
const [showSettings, setShowSettings] = useState(false);
const [showMentions, setShowMentions] = useState(false);
const [showMemberList, setShowMemberList] = useState(true);
const [showMemberList, setShowMemberList] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
@ -79,7 +81,8 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
const handleSend = useCallback(
(content: string) => {
sendMessage(content, 'text', replyingTo?.id ?? undefined);
const attachmentIds = messageInputRef.current?.getAttachmentIds() ?? [];
sendMessage(content, 'text', replyingTo?.id ?? undefined, attachmentIds.length > 0 ? attachmentIds : undefined);
setReplyingTo(null);
messageInputRef.current?.clearContent();
},
@ -260,6 +263,18 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
</button>
)}
<button
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
style={{
color: channelSidebarOpen ? 'var(--room-accent)' : 'var(--room-text-muted)',
background: channelSidebarOpen ? 'var(--room-channel-active)' : 'transparent',
}}
onClick={onToggleChannelSidebar}
title={channelSidebarOpen ? 'Hide channels' : 'Show channels'}
>
<PanelLeft className="h-4 w-4" />
</button>
<Button
variant="ghost"
size="sm"

View File

@ -204,7 +204,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
onClick={handleSave}
disabled={!name.trim() || isPending}
className="w-full border-none"
style={{ background: 'var(--room-accent)', color: '#fff' }}
style={{ background: 'var(--room-accent)', color: 'var(--accent-fg)' }}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -264,7 +264,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
{config.think && (
<span
className="rounded px-1 py-0.5 text-[10px] shrink-0"
style={{ background: 'rgba(59,130,246,0.1)', color: 'var(--room-accent)' }}
style={{ background: 'var(--accent-subtle)', color: 'var(--room-accent)' }}
>
think
</span>
@ -439,7 +439,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
<Button
onClick={handleAddAi}
disabled={!selectedModelId || isAddingAi}
style={{ background: 'var(--room-accent)', color: '#fff', border: 'none' }}
style={{ background: 'var(--room-accent)', color: 'var(--accent-fg)', border: 'none' }}
>
{isAddingAi ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Add Model

View File

@ -0,0 +1,404 @@
/**
* Theme switcher preset selection + custom token editor.
*
* Presets:
* Default Linear / Vercel dual-color (index.css :root / .dark)
* Custom user-editable palette, stored in localStorage
*
* The panel is opened via the sidebar "Theme" button and presented as a Sheet.
*/
import { useCallback, useEffect, useState } from 'react';
import {
applyPaletteToDOM,
clearCustomPalette,
loadActivePresetId,
loadCustomPalette,
resetDOMFromPalette,
saveActivePresetId,
saveCustomPalette,
THEME_PRESETS,
} from './design-system';
import type { PaletteEntry, ThemePresetId } from './design-system';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts';
import { Check, RotateCcw, Sliders } from 'lucide-react';
// ─── Token definitions ───────────────────────────────────────────────────────
interface TokenDef {
key: keyof PaletteEntry;
label: string;
group: 'surface' | 'text' | 'accent' | 'border' | 'status' | 'message';
type: 'color';
}
const TOKEN_DEFS: TokenDef[] = [
// Surface
{ key: 'bg', label: 'Background', group: 'surface', type: 'color' },
{ key: 'bgSubtle', label: 'Subtle', group: 'surface', type: 'color' },
{ key: 'bgHover', label: 'Hover', group: 'surface', type: 'color' },
{ key: 'bgActive', label: 'Active', group: 'surface', type: 'color' },
{ key: 'surface', label: 'Surface (card)', group: 'surface', type: 'color' },
{ key: 'surface2', label: 'Surface 2', group: 'surface', type: 'color' },
{ key: 'panelBg', label: 'Panel / Sidebar', group: 'surface', type: 'color' },
// Text
{ key: 'text', label: 'Text', group: 'text', type: 'color' },
{ key: 'textMuted', label: 'Text Muted', group: 'text', type: 'color' },
{ key: 'textSubtle', label: 'Text Subtle', group: 'text', type: 'color' },
{ key: 'icon', label: 'Icon', group: 'text', type: 'color' },
{ key: 'iconHover', label: 'Icon Hover', group: 'text', type: 'color' },
// Accent
{ key: 'accent', label: 'Accent', group: 'accent', type: 'color' },
{ key: 'accentHover', label: 'Accent Hover', group: 'accent', type: 'color' },
{ key: 'accentText', label: 'Accent Text', group: 'accent', type: 'color' },
{ key: 'mentionBg', label: 'Mention BG', group: 'accent', type: 'color' },
{ key: 'mentionText', label: 'Mention Text', group: 'accent', type: 'color' },
// Border
{ key: 'border', label: 'Border', group: 'border', type: 'color' },
{ key: 'borderFocus', label: 'Border Focus', group: 'border', type: 'color' },
{ key: 'borderMuted', label: 'Border Muted', group: 'border', type: 'color' },
// Status
{ key: 'online', label: 'Online', group: 'status', type: 'color' },
{ key: 'away', label: 'Away', group: 'status', type: 'color' },
{ key: 'offline', label: 'Offline', group: 'status', type: 'color' },
// Message
{ key: 'msgBg', label: 'Message BG', group: 'message', type: 'color' },
{ key: 'msgOwnBg', label: 'Own Message BG', group: 'message', type: 'color' },
];
const GROUP_LABELS: Record<TokenDef['group'], string> = {
surface: 'Surface',
text: 'Text & Icon',
accent: 'Accent',
border: 'Border',
status: 'Status',
message: 'Message',
};
// ─── Preset Card ─────────────────────────────────────────────────────────────
function PresetCard({
preset,
active,
onClick,
}: {
preset: (typeof THEME_PRESETS)[number];
active: boolean;
onClick: () => void;
}) {
const { resolvedTheme } = useTheme();
const previewPalette = preset.palette ?? buildDefaultPreview(resolvedTheme);
const swatches = [
previewPalette.bg,
previewPalette.surface,
previewPalette.border,
previewPalette.text,
previewPalette.textMuted,
previewPalette.accent,
previewPalette.accentText,
];
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all w-full',
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active ? 'border-primary ring-1 ring-primary' : 'border-border',
)}
>
{/* Color swatches */}
<div className="flex gap-1">
{swatches.map((color, i) => (
<div
key={i}
className="h-6 flex-1 rounded-sm"
style={{ background: color }}
/>
))}
</div>
{/* Label */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{preset.label}</span>
{active && <Check className="h-3.5 w-3.5 text-primary" />}
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
{preset.description}
</p>
</button>
);
}
function buildDefaultPreview(theme: 'light' | 'dark'): PaletteEntry {
if (theme === 'dark') {
return {
bg: '#1a1a1e', bgSubtle: '#1e1e23', bgHover: '#222228', bgActive: '#2a2a30',
border: '#2e2e35', borderFocus: '#4a9eff', borderMuted: '#252528',
text: '#ececf1', textMuted: '#8a8a92', textSubtle: '#5c5c65',
accent: '#4a9eff', accentHover: '#6aafff', accentText: '#ffffff',
icon: '#7a7a84', iconHover: '#b0b0ba',
surface: '#222228', surface2: '#2a2a30',
online: '#34d399', away: '#fbbf24', offline: '#6b7280',
mentionBg: 'rgba(74,158,255,0.12)', mentionText: '#4a9eff',
msgBg: '#1e1e23', msgOwnBg: '#1a2a3a', panelBg: '#161619',
badgeAi: '', badgeRole: '',
};
}
return {
bg: '#ffffff', bgSubtle: '#f9f9fa', bgHover: '#f3f3f5', bgActive: '#ebebef',
border: '#e4e4e8', borderFocus: '#1c7ded', borderMuted: '#eeeeef',
text: '#1f1f1f', textMuted: '#8a8a8f', textSubtle: '#b8b8bd',
accent: '#1c7ded', accentHover: '#1a73d4', accentText: '#ffffff',
icon: '#8a8a8f', iconHover: '#5c5c62',
surface: '#f7f7f8', surface2: '#eeeeef',
online: '#22c55e', away: '#f59e0b', offline: '#d1d1d6',
mentionBg: 'rgba(28,125,237,0.08)', mentionText: '#1c7ded',
msgBg: '#f9f9fb', msgOwnBg: '#e8f0fe', panelBg: '#f9f9fa',
badgeAi: '', badgeRole: '',
};
}
// ─── Token Editor ─────────────────────────────────────────────────────────────
function TokenEditor({
value,
onChange,
}: {
value: PaletteEntry;
onChange: (v: PaletteEntry) => void;
}) {
const groups = (['surface', 'text', 'accent', 'border', 'status', 'message'] as const);
return (
<div className="space-y-4">
{groups.map((group) => {
const defs = TOKEN_DEFS.filter((d) => d.group === group);
return (
<div key={group}>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
{GROUP_LABELS[group]}
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{defs.map((def) => (
<div key={def.key} className="flex items-center gap-2">
{/* Swatch + native color picker */}
<div className="relative shrink-0">
<div
className="h-7 w-7 rounded border cursor-pointer overflow-hidden"
style={{ background: value[def.key] as string }}
>
<input
type="color"
value={(value[def.key] as string).startsWith('#')
? (value[def.key] as string)
: '#888888'}
onChange={(e) =>
onChange({ ...value, [def.key]: e.target.value })
}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
title={def.label}
/>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{def.label}</p>
<input
type="text"
value={value[def.key] as string}
onChange={(e) =>
onChange({ ...value, [def.key]: e.target.value })
}
className="w-full bg-transparent border-0 p-0 text-[10px] text-muted-foreground focus:outline-none focus:ring-0 font-mono"
spellCheck={false}
/>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface ThemeSwitcherProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) {
const { resolvedTheme } = useTheme();
const [activePresetId, setActivePresetId] = useState<ThemePresetId>(loadActivePresetId);
const [customPalette, setCustomPalette] = useState<PaletteEntry | null>(
loadCustomPalette,
);
// Working copy being edited
const [draft, setDraft] = useState<PaletteEntry | null>(null);
const [isDirty, setIsDirty] = useState(false);
// Reset when panel opens
useEffect(() => {
if (open) {
const id = loadActivePresetId();
setActivePresetId(id);
setCustomPalette(loadCustomPalette());
setDraft(id === 'custom' && loadCustomPalette() ? { ...loadCustomPalette()! } : null);
setIsDirty(false);
}
}, [open]);
const applyPreset = useCallback(
(presetId: ThemePresetId) => {
setActivePresetId(presetId);
saveActivePresetId(presetId);
if (presetId === 'custom') {
const stored = loadCustomPalette();
setCustomPalette(stored);
setDraft(stored ? { ...stored } : null);
if (stored) applyPaletteToDOM(stored);
} else {
clearCustomPalette();
setCustomPalette(null);
setDraft(null);
resetDOMFromPalette();
}
setIsDirty(false);
},
[],
);
const handleDraftChange = useCallback((next: PaletteEntry) => {
setDraft(next);
setIsDirty(true);
}, []);
const handleApplyCustom = useCallback(() => {
if (!draft) return;
saveCustomPalette(draft);
setCustomPalette(draft);
applyPaletteToDOM(draft);
setActivePresetId('custom');
saveActivePresetId('custom');
setIsDirty(false);
}, [draft]);
const handleReset = useCallback(() => {
applyPreset('default');
}, [applyPreset]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col overflow-y-auto w-[360px] sm:max-w-[360px]">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Sliders className="h-4 w-4" />
Theme Settings
</SheetTitle>
</SheetHeader>
{/* ── Scrollable content with padding ─────────────────────────────── */}
<div className="flex flex-col gap-5 px-5 pb-5 overflow-y-auto">
{/* ── Preset grid ─────────────────────────────────────────────────── */}
<div className="mt-6">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Presets
</p>
<div className="grid grid-cols-1 gap-2">
{THEME_PRESETS.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
active={activePresetId === preset.id}
onClick={() => applyPreset(preset.id as ThemePresetId)}
/>
))}
{/* Custom preset card — always shown */}
<button
type="button"
onClick={() => {
// Switch to custom, seed draft from current effective palette
const seed = activePresetId === 'custom' && customPalette
? { ...customPalette }
: { ...buildDefaultPreview(resolvedTheme) };
setDraft(seed);
setActivePresetId('custom');
setIsDirty(false);
if (activePresetId !== 'custom') {
saveActivePresetId('custom');
}
}}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all',
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
activePresetId === 'custom'
? 'border-primary ring-1 ring-primary'
: 'border-border border-dashed',
)}
>
{/* Mini palette swatches */}
<div className="flex gap-1">
{['#ffffff', '#f9f9fa', '#e4e4e8', '#1f1f1f', '#8a8a8f', '#1c7ded', '#ffffff'].map(
(c, i) => (
<div key={i} className="h-6 flex-1 rounded-sm" style={{ background: c }} />
),
)}
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Custom</span>
{activePresetId === 'custom' && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Define your own colors
</p>
</button>
</div>
</div>
{/* ── Custom token editor ─────────────────────────────────────────── */}
{activePresetId === 'custom' && draft && (
<div className="border-t pt-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Token Editor
</p>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleReset}
>
<RotateCcw className="h-3 w-3" />
Reset
</Button>
<Button
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleApplyCustom}
disabled={!isDirty}
>
Apply
</Button>
</div>
</div>
<TokenEditor value={draft} onChange={handleDraftChange} />
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -1,89 +1,239 @@
/**
* AI Studio design system room-wide tokens.
* Clean, modern palette. No Discord reference.
* AI Studio design system palette hooks.
*
* Architecture:
* CSS custom properties (index.css) index.css :root / .dark index.css @layer semantic
* read only custom mode writes here
*
* In default mode the hook returns the CSS variable values (live, theme-aware).
* In "custom" mode it returns the stored localStorage palette and also writes
* those values to the DOM so they override the CSS layer.
*/
import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts';
// ─── Palette ──────────────────────────────────────────────────────────────────
// ─── Preset definitions ───────────────────────────────────────────────────────
export const PALETTE = {
light: {
// Backgrounds
bg: '#ffffff',
bgSubtle: '#f9f9fa',
bgHover: '#f3f3f5',
bgActive: '#ebebef',
// Borders
border: '#e4e4e8',
borderFocus:'#1c7ded',
borderMuted:'#eeeeef',
// Text
text: '#1f1f1f',
textMuted: '#8a8a8f',
textSubtle: '#b8b8bd',
// Accent (primary action)
accent: '#1c7ded',
accentHover:'#1a73d4',
accentText: '#ffffff',
// Icon
icon: '#8a8a8f',
iconHover: '#5c5c62',
// Surfaces
surface: '#f7f7f8',
surface2: '#eeeeef',
// Status
online: '#22c55e',
away: '#f59e0b',
offline: '#d1d1d6',
// Mention highlight
mentionBg: 'rgba(28,125,237,0.08)',
mentionText:'#1c7ded',
// Message bubbles
msgBg: '#f9f9fb',
msgOwnBg: '#e8f0fe',
// Panel
panelBg: '#f5f5f7',
// Badges
badgeAi: 'bg-blue-50 text-blue-600',
badgeRole: 'bg-gray-100 text-gray-600',
},
dark: {
bg: '#1a1a1e',
bgSubtle: '#1e1e23',
bgHover: '#222228',
bgActive: '#2a2a30',
border: '#2e2e35',
borderFocus:'#4a9eff',
borderMuted:'#252528',
text: '#ececf1',
textMuted: '#8a8a92',
textSubtle: '#5c5c65',
accent: '#4a9eff',
accentHover:'#6aafff',
accentText: '#ffffff',
icon: '#7a7a84',
iconHover: '#b0b0ba',
surface: '#222228',
surface2: '#2a2a30',
online: '#34d399',
away: '#fbbf24',
offline: '#6b7280',
mentionBg: 'rgba(74,158,255,0.12)',
mentionText:'#4a9eff',
msgBg: '#1e1e23',
msgOwnBg: '#1a2a3a',
panelBg: '#161619',
badgeAi: 'bg-blue-900/40 text-blue-300',
badgeRole: 'bg-gray-800 text-gray-400',
},
} as const;
export interface PaletteEntry {
bg: string; // page background
bgSubtle: string; // slightly elevated surface
bgHover: string; // hover state
bgActive: string; // active/pressed state
border: string; // default border
borderFocus: string; // focus ring / active border
borderMuted: string; // subtle dividers
text: string; // primary text
textMuted: string; // secondary / metadata
textSubtle: string; // timestamps, hints
accent: string; // brand / action color
accentHover: string;
accentText: string; // text on accent bg
icon: string; // default icon color
iconHover: string; // icon hover color
surface: string; // card / elevated surface
surface2: string; // deeper surface
online: string; // online status dot
away: string; // away / idle dot
offline: string; // offline dot
mentionBg: string; // mention highlight bg (with alpha)
mentionText: string; // mention highlight text
msgBg: string; // received message bubble bg
msgOwnBg: string; // own message bubble bg
panelBg: string; // sidebar / panel background
badgeAi: string; // tailwind classes for AI badge
badgeRole: string; // tailwind classes for role badge
}
export type ThemePalette = typeof PALETTE.light;
export type ThemePresetId = 'default' | 'custom';
export interface ThemePreset {
id: ThemePresetId;
label: string;
description: string;
/** Pre-built palette object, or null = read from CSS vars (default mode) */
palette: PaletteEntry | null;
}
// ─── Presets ──────────────────────────────────────────────────────────────────
export const THEME_PRESETS: ThemePreset[] = [
{
id: 'default',
label: 'Default',
description: 'Linear / Vercel inspired — neutral + single indigo accent',
palette: null, // reads live from CSS vars
},
];
/** Well-known CSS vars that map to PaletteEntry keys */
const PALETTE_VAR_MAP: Record<keyof PaletteEntry, string> = {
bg: '--bg',
bgSubtle: '--surface-2',
bgHover: '--surface-3',
bgActive: '--surface-3',
border: '--border',
borderFocus: '--ring',
borderMuted: '--border-2',
text: '--fg',
textMuted: '--fg-muted',
textSubtle: '--fg-subtle',
accent: '--accent',
accentHover: '--accent-hover',
accentText: '--accent-fg',
icon: '--fg-muted',
iconHover: '--fg',
surface: '--surface-2',
surface2: '--surface-3',
online: '--success',
away: '--warning',
offline: '--room-offline',
mentionBg: '--accent-subtle',
mentionText: '--accent',
msgBg: '--surface-2',
msgOwnBg: '--accent-subtle',
panelBg: '--sidebar-bg',
badgeAi: 'bg-accent/10 text-accent font-medium',
badgeRole: 'bg-muted text-muted-foreground font-medium',
};
/** Read a CSS custom property value from the DOM, fallback to a default */
function readCssVar(name: string, fallback: string): string {
if (typeof document === 'undefined') return fallback;
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim() || fallback;
}
// ─── Custom palette storage ───────────────────────────────────────────────────
const CUSTOM_KEY = 'theme-custom-palette';
const PRESET_KEY = 'theme-preset';
export function loadCustomPalette(): PaletteEntry | null {
try {
const raw = localStorage.getItem(CUSTOM_KEY);
if (!raw) return null;
return JSON.parse(raw) as PaletteEntry;
} catch {
return null;
}
}
export function saveCustomPalette(palette: PaletteEntry) {
localStorage.setItem(CUSTOM_KEY, JSON.stringify(palette));
}
export function clearCustomPalette() {
localStorage.removeItem(CUSTOM_KEY);
localStorage.setItem(PRESET_KEY, 'default');
}
export function loadActivePresetId(): ThemePresetId {
return (localStorage.getItem(PRESET_KEY) as ThemePresetId) || 'default';
}
export function saveActivePresetId(id: ThemePresetId) {
localStorage.setItem(PRESET_KEY, id);
// Notify all useAIPalette hooks to re-read
window.dispatchEvent(new CustomEvent('theme-preset-change', { detail: id }));
}
/**
* Apply a custom palette to the DOM root so it overrides the CSS layer.
* Only the keys present in PALETTE_VAR_MAP are written.
*/
export function applyPaletteToDOM(palette: PaletteEntry) {
const root = document.documentElement;
for (const [key, cssVar] of Object.entries(PALETTE_VAR_MAP)) {
if (key === 'badgeAi' || key === 'badgeRole') continue; // class strings, skip
root.style.setProperty(cssVar, (palette as unknown as Record<string, string>)[key]);
}
}
/** Reset DOM overrides back to the CSS layer (removes custom inline styles) */
export function resetDOMFromPalette() {
const root = document.documentElement;
for (const cssVar of Object.values(PALETTE_VAR_MAP)) {
if (cssVar === '--badge-ai' || cssVar === '--badge-role') continue;
root.style.removeProperty(cssVar);
}
}
// ─── Hook ────────────────────────────────────────────────────────────────────
export function useAIPalette() {
export function useAIPalette(): PaletteEntry {
const { resolvedTheme } = useTheme();
return resolvedTheme === 'dark' ? PALETTE.dark : PALETTE.light;
const [customPalette, setCustomPalette] = useState<PaletteEntry | null>(
loadCustomPalette,
);
const [activePresetId, setActivePresetId] = useState<ThemePresetId>(loadActivePresetId);
// Re-read from localStorage when the custom palette changes
// (e.g. after ThemeSwitcher saves a new custom palette)
useEffect(() => {
const onPresetChange = () => {
setActivePresetId(loadActivePresetId());
setCustomPalette(loadCustomPalette());
};
window.addEventListener('theme-preset-change', onPresetChange);
return () => window.removeEventListener('theme-preset-change', onPresetChange);
}, []);
// ── Derive palette ──────────────────────────────────────────────────────
if (activePresetId === 'custom' && customPalette) {
// Custom mode: return the stored palette (DOM is already updated by the
// ThemeSwitcher, but we also return it here so callers get the right values)
return customPalette;
}
// Default mode: read live from CSS variables
return useMemo<PaletteEntry>(() => {
// re-compute when light/dark resolvedTheme changes
void resolvedTheme;
return {
bg: readCssVar('--bg', '#ffffff'),
bgSubtle: readCssVar('--surface-2', '#f9f9fa'),
bgHover: readCssVar('--surface-3', '#f3f3f5'),
bgActive: readCssVar('--surface-3', '#ebebef'),
border: readCssVar('--border', '#e4e4e8'),
borderFocus: readCssVar('--ring', '#1c7ded'),
borderMuted: readCssVar('--border-2', '#eeeeef'),
text: readCssVar('--fg', '#1f1f1f'),
textMuted: readCssVar('--fg-muted', '#8a8a8f'),
textSubtle: readCssVar('--fg-subtle', '#b8b8bd'),
accent: readCssVar('--accent', '#1c7ded'),
accentHover: readCssVar('--accent-hover', '#1a73d4'),
accentText: readCssVar('--accent-fg', '#ffffff'),
icon: readCssVar('--fg-muted', '#8a8a8f'),
iconHover: readCssVar('--fg', '#5c5c62'),
surface: readCssVar('--surface-2', '#f7f7f8'),
surface2: readCssVar('--surface-3', '#eeeeef'),
online: readCssVar('--success', '#22c55e'),
away: readCssVar('--warning', '#f59e0b'),
offline: readCssVar('--room-offline', '#d1d1d6'),
mentionBg: readCssVar('--accent-subtle', 'rgba(28,125,237,0.08)'),
mentionText: readCssVar('--accent', '#1c7ded'),
msgBg: readCssVar('--surface-2', '#f9f9fb'),
msgOwnBg: readCssVar('--accent-subtle', '#e8f0fe'),
panelBg: readCssVar('--sidebar-bg', '#f9f9fa'),
badgeAi: 'bg-accent/10 text-accent font-medium',
badgeRole: 'bg-muted text-muted-foreground font-medium',
};
}, [resolvedTheme]);
}
/** Trigger a custom palette: saves to localStorage, applies to DOM, sets preset */
export function activateCustomPalette(palette: PaletteEntry) {
saveCustomPalette(palette);
saveActivePresetId('custom');
applyPaletteToDOM(palette);
}
/** Reset back to the default CSS-layer theme */
export function deactivateCustomPalette() {
clearCustomPalette();
resetDOMFromPalette();
}

View File

@ -5,14 +5,15 @@
* Supports @mentions, file uploads, emoji picker, and rich message AST.
*/
import { forwardRef } from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import { IMEditor } from './editor/IMEditor';
import { useRoom } from '@/contexts';
import type { MessageAST } from './editor/types';
import type { IMEditorHandle } from './editor/IMEditor';
export interface MessageInputProps {
roomName: string;
onSend: (content: string) => void;
onSend: (content: string, attachmentIds?: string[]) => void;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
}
@ -22,13 +23,27 @@ export interface MessageInputHandle {
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[];
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ roomName, onSend, replyingTo, onCancelReply },
ref,
) {
const { members } = useRoom();
const { members, activeRoomId } = useRoom();
// Ref passed to the inner IMEditor
const innerEditorRef = useRef<IMEditorHandle | null>(null);
// Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle
useImperativeHandle(ref, () => ({
focus: () => innerEditorRef.current?.focus(),
clearContent: () => innerEditorRef.current?.clearContent(),
getContent: () => innerEditorRef.current?.getContent() ?? '',
insertMention: (type: string, id: string, label: string) =>
innerEditorRef.current?.insertMention(type, id, label),
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
}), []);
// Transform room data into MentionItems
const mentionItems = {
@ -43,11 +58,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
commands: [], // TODO: add slash commands
};
// File upload handler — integrate with your upload API
// File upload handler — POST to /rooms/{room_id}/upload
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
if (!activeRoomId) throw new Error('No active room');
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const res = await fetch(`/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
return res.json();
};
@ -59,7 +75,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
return (
<IMEditor
ref={ref}
ref={innerEditorRef}
replyingTo={replyingTo}
onCancelReply={onCancelReply}
onSend={handleSend}

View File

@ -5,478 +5,536 @@
* Colors: Clean modern palette, no Discord reference
*/
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useEditor, EditorContent, Extension } from '@tiptap/react';
import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {EditorContent, Extension, useEditor} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { CustomEmojiNode } from './EmojiNode';
import type { MentionItem, MessageAST, MentionType } from './types';
import { Paperclip, Smile, Send, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { COMMON_EMOJIS } from '../../shared';
import { useTheme } from '@/contexts';
import { useImageCompress } from '@/hooks/useImageCompress';
import {CustomEmojiNode} from './EmojiNode';
import type {MentionItem, MentionType, MessageAST} from './types';
import {Paperclip, Send, Smile, X} from 'lucide-react';
import {cn} from '@/lib/utils';
import {COMMON_EMOJIS} from '../../shared';
import {useTheme} from '@/contexts';
import {useImageCompress} from '@/hooks/useImageCompress';
export interface IMEditorProps {
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
onSend: (content: string, ast: MessageAST) => void;
mentionItems: {
users: MentionItem[];
channels: MentionItem[];
ai: MentionItem[];
commands: MentionItem[];
};
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
placeholder?: string;
replyingTo?: { id: string; display_name?: string; content: string } | null;
onCancelReply?: () => void;
onSend: (content: string, ast: MessageAST) => void;
mentionItems: {
users: MentionItem[];
channels: MentionItem[];
ai: MentionItem[];
commands: MentionItem[];
};
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
placeholder?: string;
}
export interface IMEditorHandle {
focus: () => void;
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
focus: () => void;
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
getAttachmentIds: () => string[];
}
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
const LIGHT = {
bg: '#ffffff',
bgHover: '#f7f7f8',
bgActive: '#ececf1',
border: '#e3e3e5',
borderFocus: '#1c7ded',
text: '#1f1f1f',
textMuted: '#8a8a8e',
textSubtle: '#b0b0b4',
icon: '#8a8a8e',
iconHover: '#5c5c60',
sendBg: '#1c7ded',
sendBgHover: '#1a73d4',
sendIcon: '#ffffff',
sendDisabled:'#e3e3e5',
popupBg: '#ffffff',
popupBorder: '#e3e3e5',
popupHover: '#f5f5f7',
popupSelected:'#e8f0fe',
replyBg: '#f5f5f7',
badgeAi: '#dbeafe text-blue-700',
badgeChan: '#f3f4f6 text-gray-500',
badgeCmd: '#fef3c7 text-amber-700',
bg: '#ffffff',
bgHover: '#f7f7f8',
bgActive: '#ececf1',
border: '#e3e3e5',
borderFocus: '#1c7ded',
text: '#1f1f1f',
textMuted: '#8a8a8e',
textSubtle: '#b0b0b4',
icon: '#8a8a8e',
iconHover: '#5c5c60',
sendBg: '#1c7ded',
sendBgHover: '#1a73d4',
sendIcon: '#ffffff',
sendDisabled: '#e3e3e5',
popupBg: '#ffffff',
popupBorder: '#e3e3e5',
popupHover: '#f5f5f7',
popupSelected: '#e8f0fe',
replyBg: '#f5f5f7',
badgeAi: '#dbeafe text-blue-700',
badgeChan: '#f3f4f6 text-gray-500',
badgeCmd: '#fef3c7 text-amber-700',
};
const DARK = {
bg: '#1a1a1e',
bgHover: '#222226',
bgActive: '#2a2a2f',
border: '#2e2e33',
borderFocus: '#4a9eff',
text: '#ececf1',
textMuted: '#8a8a91',
textSubtle: '#5c5c63',
icon: '#7a7a82',
iconHover: '#b0b0b8',
sendBg: '#4a9eff',
sendBgHover: '#6aafff',
sendIcon: '#ffffff',
sendDisabled:'#2e2e33',
popupBg: '#222226',
popupBorder: '#2e2e33',
popupHover: '#2a2a30',
popupSelected:'#2a3a55',
replyBg: '#1f1f23',
badgeAi: 'bg-blue-900/40 text-blue-300',
badgeChan: 'bg-gray-800 text-gray-400',
badgeCmd: 'bg-amber-900/30 text-amber-300',
bg: '#1a1a1e',
bgHover: '#222226',
bgActive: '#2a2a2f',
border: '#2e2e33',
borderFocus: '#4a9eff',
text: '#ececf1',
textMuted: '#8a8a91',
textSubtle: '#5c5c63',
icon: '#7a7a82',
iconHover: '#b0b0b8',
sendBg: '#4a9eff',
sendBgHover: '#6aafff',
sendIcon: '#ffffff',
sendDisabled: '#2e2e33',
popupBg: '#222226',
popupBorder: '#2e2e33',
popupHover: '#2a2a30',
popupSelected: '#2a3a55',
replyBg: '#1f1f23',
badgeAi: 'bg-blue-900/40 text-blue-300',
badgeChan: 'bg-gray-800 text-gray-400',
badgeCmd: 'bg-amber-900/30 text-amber-300',
};
type Palette = typeof LIGHT;
// ─── Emoji Picker ─────────────────────────────────────────────────────────────
function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) {
return (
<div
className="absolute bottom-full left-0 mb-2 z-50"
style={{
background: p.popupBg,
border: `1px solid ${p.popupBorder}`,
borderRadius: 12,
boxShadow: p === DARK
? '0 8px 32px rgba(0,0,0,0.6)'
: '0 8px 32px rgba(0,0,0,0.10)',
}}
>
<div
className="flex items-center justify-between px-3 pt-3 pb-2"
style={{ borderBottom: `1px solid ${p.popupBorder}` }}
>
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: p.textMuted }}>
function EmojiPicker({onClose, onSelect, p}: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) {
return (
<div
className="absolute bottom-full left-0 mb-2 z-50"
style={{
background: p.popupBg,
border: `1px solid ${p.popupBorder}`,
borderRadius: 12,
boxShadow: p === DARK
? '0 8px 32px rgba(0,0,0,0.6)'
: '0 8px 32px rgba(0,0,0,0.10)',
}}
>
<div
className="flex items-center justify-between px-3 pt-3 pb-2"
style={{borderBottom: `1px solid ${p.popupBorder}`}}
>
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{color: p.textMuted}}>
Emoji
</span>
<button onClick={onClose} className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors" style={{ color: p.icon }}>
<X size={11} />
</button>
</div>
<div className="grid p-2 gap-0.5" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
{COMMON_EMOJIS.map(emoji => (
<button
key={emoji}
onClick={() => onSelect(emoji)}
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110 text-[18px]"
style={{ background: 'transparent' }}
>
{emoji}
</button>
))}
</div>
</div>
);
<button onClick={onClose}
className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors"
style={{color: p.icon}}>
<X size={11}/>
</button>
</div>
<div className="grid p-2 gap-0.5" style={{gridTemplateColumns: 'repeat(6, 1fr)'}}>
{COMMON_EMOJIS.map(emoji => (
<button
key={emoji}
onClick={() => onSelect(emoji)}
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110 text-[18px]"
style={{background: 'transparent'}}
>
{emoji}
</button>
))}
</div>
</div>
);
}
// ─── Keyboard Extension ───────────────────────────────────────────────────────
const KeyboardSend = Extension.create({
name: 'keyboardSend',
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
if (editor.isEmpty) return true;
const text = editor.getText().trim();
if (!text) return true;
(editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST);
return true;
},
'Shift-Enter': ({ editor }) => {
editor.chain().focus().setHardBreak().run();
return true;
},
};
},
addStorage() {
return { onSend: null as ((t: string, a: MessageAST) => void) | null };
},
name: 'keyboardSend',
addKeyboardShortcuts() {
return {
Enter: ({editor}) => {
if (editor.isEmpty) return true;
const text = editor.getText().trim();
if (!text) return true;
(editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST);
return true;
},
'Shift-Enter': ({editor}) => {
editor.chain().focus().setHardBreak().run();
return true;
},
};
},
addStorage() {
return {onSend: null as ((t: string, a: MessageAST) => void) | null};
},
});
// ─── Helpers ─────────────────────────────────────────────────────────────────
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
return all.filter(m => m.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
return all.filter(m => m.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
}
function getBadge(type: MentionType): { label: string; cls: string } | null {
if (type === 'ai') return { label: 'AI', cls: 'bg-blue-50 text-blue-600' };
if (type === 'channel') return { label: '#', cls: 'bg-gray-100 text-gray-500' };
if (type === 'command') return { label: 'cmd', cls: 'bg-amber-50 text-amber-600' };
return null;
if (type === 'ai') return {label: 'AI', cls: 'bg-blue-50 text-blue-600'};
if (type === 'channel') return {label: '#', cls: 'bg-gray-100 text-gray-500'};
if (type === 'command') return {label: 'cmd', cls: 'bg-amber-50 text-amber-600'};
return null;
}
// ─── Mention Dropdown ────────────────────────────────────────────────────────
function MentionDropdown({
items, selectedIndex, onSelect, p, query,
}: {
items: MentionItem[];
selectedIndex: number;
onSelect: (item: MentionItem) => void;
p: Palette;
query: string;
items, selectedIndex, onSelect, p, query,
}: {
items: MentionItem[];
selectedIndex: number;
onSelect: (item: MentionItem) => void;
p: Palette;
query: string;
}) {
return (
<div
className="absolute left-0 z-50 overflow-hidden"
style={{
background: p.popupBg,
border: `1px solid ${p.popupBorder}`,
borderRadius: 10,
boxShadow: p === DARK ? '0 12px 40px rgba(0,0,0,0.55)' : '0 8px 30px rgba(0,0,0,0.09)',
minWidth: 240,
maxWidth: 300,
}}
>
{items.length === 0 ? (
<div className="px-4 py-5 text-sm text-center" style={{ color: p.textMuted }}>
No results for &ldquo;{query}&rdquo;
</div>
) : (
<div className="py-1 max-h-60 overflow-y-auto">
{items.map((item, i) => {
const badge = getBadge(item.type);
return (
<button
key={item.id}
onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
style={{ background: i === selectedIndex ? p.popupSelected : 'transparent' }}
>
{item.avatar ? (
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0" />
) : (
<span
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
style={{ background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text }}
>
return (
<div
className="absolute left-0 z-50 overflow-hidden"
style={{
background: p.popupBg,
border: `1px solid ${p.popupBorder}`,
borderRadius: 10,
boxShadow: p === DARK ? '0 12px 40px rgba(0,0,0,0.55)' : '0 8px 30px rgba(0,0,0,0.09)',
minWidth: 240,
maxWidth: 300,
}}
>
{items.length === 0 ? (
<div className="px-4 py-5 text-sm text-center" style={{color: p.textMuted}}>
No results for &ldquo;{query}&rdquo;
</div>
) : (
<div className="py-1 max-h-60 overflow-y-auto">
{items.map((item, i) => {
const badge = getBadge(item.type);
return (
<button
key={item.id}
onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
style={{background: i === selectedIndex ? p.popupSelected : 'transparent'}}
>
{item.avatar ? (
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
) : (
<span
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
style={{background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text}}
>
{item.label.charAt(0).toUpperCase()}
</span>
)}
<span className="flex-1 truncate text-sm font-medium" style={{ color: p.text }}>
)}
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
{item.label}
</span>
{badge && (
<span className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
{badge && (
<span
className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
{badge.label}
</span>
)}
</button>
);
})}
)}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
);
}
// ─── Main Component ────────────────────────────────────────────────────────────
export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEditor(
{ replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…' },
ref,
{replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…'},
ref,
) {
const { resolvedTheme } = useTheme();
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
const { compress } = useImageCompress();
const {resolvedTheme} = useTheme();
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
const {compress} = useImageCompress();
const [showEmoji, setShowEmoji] = useState(false);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]);
const [mentionIdx, setMentionIdx] = useState(0);
const [, setMentionPos] = useState({ top: 0, left: 0 });
const [focused, setFocused] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]);
const [mentionIdx, setMentionIdx] = useState(0);
const [, setMentionPos] = useState({top: 0, left: 0});
const [focused, setFocused] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const allItems = [
...mentionItems.users,
...mentionItems.channels,
...mentionItems.ai,
...mentionItems.commands,
];
const allItems = [
...mentionItems.users,
...mentionItems.channels,
...mentionItems.ai,
...mentionItems.commands,
];
const selectMention = useCallback((item: MentionItem) => {
if (!editor) return;
// Use backend-parseable format: @[type:id:label]
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
editor.chain().focus().insertContent(mentionStr).run();
setMentionOpen(false);
}, []);
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: { depth: 100 } }),
Placeholder.configure({ placeholder }),
CustomEmojiNode,
KeyboardSend,
],
editorProps: {
handlePaste: (_v, e) => {
const img = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image'));
if (img) {
e.preventDefault();
const file = img.getAsFile();
if (file && onUploadFile) void doUpload(file);
return true;
}
return false;
},
handleDrop: (_v, e) => {
if (e.dataTransfer?.files?.[0] && onUploadFile) {
e.preventDefault();
void doUpload(e.dataTransfer.files[0]);
return true;
}
return false;
},
},
onUpdate: ({ editor: ed }) => {
const text = ed.getText();
const { from } = ed.state.selection;
let ts = from;
for (let i = from - 1; i >= 1; i--) {
const c = text[i - 1];
if (c === '@') { ts = i; break; }
if (/\s/.test(c)) break;
}
const q = text.slice(ts - 1, from);
if (q.startsWith('@') && q.length > 1) {
const results = filterMentionItems(allItems, q.slice(1));
setMentionQuery(q.slice(1));
setMentionItems2(results);
setMentionIdx(0);
setMentionOpen(true);
if (wrapRef.current) {
const sel = window.getSelection();
if (sel?.rangeCount) {
const r = sel.getRangeAt(0).getBoundingClientRect();
const cr = wrapRef.current.getBoundingClientRect();
setMentionPos({ top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left) });
}
}
} else {
const selectMention = useCallback((item: MentionItem) => {
if (!editor) return;
// Use backend-parseable format: @[type:id:label]
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
editor.chain().focus().insertContent(mentionStr).run();
setMentionOpen(false);
}
},
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
}, []);
useEffect(() => {
if (editor) (editor.storage as any).keyboardSend = { onSend };
}, [editor, onSend]);
const editor = useEditor({
extensions: [
StarterKit.configure({undoRedo: {depth: 100}}),
Placeholder.configure({placeholder}),
CustomEmojiNode,
KeyboardSend,
],
editorProps: {
handlePaste: (_v, e) => {
const img = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image'));
if (img) {
e.preventDefault();
const file = img.getAsFile();
if (file && onUploadFile) void doUpload(file);
return true;
}
return false;
},
handleDrop: (_v, e) => {
if (e.dataTransfer?.files?.[0] && onUploadFile) {
e.preventDefault();
void doUpload(e.dataTransfer.files[0]);
return true;
}
return false;
},
},
onUpdate: ({editor: ed}) => {
const text = ed.getText();
const {from} = ed.state.selection;
const doUpload = async (file: File) => {
if (!editor || !onUploadFile) return;
try {
// Compress image before upload (only if it's an image and > 500KB)
let uploadFile = file;
if (file.type.startsWith('image/') && file.size > 500 * 1024) {
const result = await compress(file, { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true });
uploadFile = result.file;
}
const res = await onUploadFile(uploadFile);
editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: uploadFile.name, url: res.url, size: uploadFile.size, type: uploadFile.type, status: 'done' } }).insertContent(' ').run();
} catch { /* ignore */ }
};
let ts = from;
for (let i = from - 1; i >= 1; i--) {
const c = text[i - 1];
if (c === '@') {
ts = i;
break;
}
if (/\s/.test(c)) break;
}
const q = text.slice(ts - 1, from);
const send = () => {
if (!editor || editor.isEmpty) return;
const text = editor.getText().trim();
if (!text) return;
onSend(text, editor.getJSON() as MessageAST);
editor.commands.clearContent();
};
if (q.startsWith('@') && q.length > 1) {
const results = filterMentionItems(allItems, q.slice(1));
setMentionQuery(q.slice(1));
setMentionItems2(results);
setMentionIdx(0);
setMentionOpen(true);
useImperativeHandle(ref, () => ({
focus: () => editor?.commands.focus(),
clearContent: () => editor?.commands.clearContent(),
getContent: () => editor?.getText() ?? '',
insertMention: (type: string, id: string, label: string) => {
if (!editor) return;
const mentionStr = `@[${type}:${id}:${label}] `;
editor.chain().focus().insertContent(mentionStr).run();
},
}));
if (wrapRef.current) {
const sel = window.getSelection();
if (sel?.rangeCount) {
const r = sel.getRangeAt(0).getBoundingClientRect();
const cr = wrapRef.current.getBoundingClientRect();
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
}
}
} else {
setMentionOpen(false);
}
},
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
const hasContent = !!editor && !editor.isEmpty;
useEffect(() => {
if (editor) (editor.storage as any).keyboardSend = {onSend};
}, [editor, onSend]);
// Dynamic styles
const borderColor = focused ? p.borderFocus : p.border;
const boxShadow = focused
? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`)
: 'none';
const doUpload = async (file: File) => {
if (!editor || !onUploadFile) return;
try {
// Compress image before upload (only if it's an image and > 500KB)
let uploadFile = file;
if (file.type.startsWith('image/') && file.size > 500 * 1024) {
const result = await compress(file, {maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true});
uploadFile = result.file;
}
const res = await onUploadFile(uploadFile);
editor.chain().focus().insertContent({
type: 'file',
attrs: {
id: res.id,
name: uploadFile.name,
url: res.url,
size: uploadFile.size,
type: uploadFile.type,
status: 'done'
}
}).insertContent(' ').run();
} catch { /* ignore */
}
};
return (
<div className="relative flex flex-col" ref={wrapRef}>
{/* Reply strip */}
{replyingTo && (
<div
className="flex items-center gap-3 px-4 py-2.5"
style={{ background: p.replyBg, borderRadius: '12px 12px 0 0', borderBottom: `1px solid ${p.border}` }}
>
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-[11px] font-semibold shrink-0" style={{ color: p.borderFocus }}>Replying to</span>
<span className="truncate text-sm font-medium" style={{ color: p.text }}>{replyingTo.display_name}</span>
<span className="truncate shrink-1 text-sm" style={{ color: p.textMuted }}> {replyingTo.content}</span>
</div>
{onCancelReply && (
<button onClick={onCancelReply} className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors" style={{ color: p.icon }}>
<X size={13} />
</button>
)}
</div>
)}
const send = () => {
if (!editor || editor.isEmpty) return;
const text = editor.getText().trim();
if (!text) return;
onSend(text, editor.getJSON() as MessageAST);
editor.commands.clearContent();
};
{/* Input area */}
<div
onClick={() => editor?.commands.focus()}
style={{
background: p.bg,
border: `1.5px solid ${borderColor}`,
borderRadius: replyingTo ? '0 0 14px 14px' : '14px',
boxShadow,
transition: 'border-color 160ms ease, box-shadow 160ms ease',
}}
>
{/* Mention dropdown */}
{mentionOpen && (
<MentionDropdown
items={mentionItems2}
selectedIndex={mentionIdx}
onSelect={selectMention}
p={p}
query={mentionQuery}
/>
)}
useImperativeHandle(ref, () => ({
focus: () => editor?.commands.focus(),
clearContent: () => editor?.commands.clearContent(),
getContent: () => editor?.getText() ?? '',
insertMention: (type: string, id: string, label: string) => {
if (!editor) return;
const mentionStr = `@[${type}:${id}:${label}] `;
editor.chain().focus().insertContent(mentionStr).run();
},
getAttachmentIds: () => {
if (!editor) return [];
const json = editor.getJSON();
const ids: string[] = [];
const walk = (node: Record<string, unknown>) => {
if (node['type'] === 'file' && node['attrs']) {
const attrs = node['attrs'] as Record<string, unknown>;
if (attrs['id']) ids.push(String(attrs['id']));
}
const children = node['content'] as Record<string, unknown>[] | undefined;
if (children) children.forEach(walk);
};
walk(json as Record<string, unknown>);
return ids;
},
}));
{/* Editor */}
<div
className="ai-editor"
style={{ padding: '12px 14px 10px' }}
>
<EditorContent editor={editor} />
</div>
const hasContent = !!editor && !editor.isEmpty;
{/* Discord-style toolbar: icons left, send right */}
<div className="flex items-center justify-between px-2 pb-2">
{/* Left — emoji + attach */}
<div className="flex items-center gap-0.5">
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setShowEmoji(v => !v); }}
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{ color: showEmoji ? p.iconHover : p.icon, background: showEmoji ? p.bgActive : 'transparent' }}
title="Emoji"
>
<Smile size={18} />
</button>
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
// Dynamic styles
const borderColor = focused ? p.borderFocus : p.border;
const boxShadow = focused
? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`)
: 'none';
return (
<div className="relative flex flex-col mt-2 ml-3 mr-3 mb-1" ref={wrapRef}>
{replyingTo && (
<div
className="flex items-center gap-3 px-4 py-2.5"
style={{
background: p.replyBg,
borderRadius: '12px 12px 0 0',
borderBottom: `1px solid ${p.border}`
}}
>
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-[11px] font-semibold shrink-0"
style={{color: p.borderFocus}}>Replying to</span>
<span className="truncate text-sm font-medium"
style={{color: p.text}}>{replyingTo.display_name}</span>
<span className="truncate shrink-1 text-sm"
style={{color: p.textMuted}}> {replyingTo.content}</span>
</div>
{onCancelReply && (
<button onClick={onCancelReply}
className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors"
style={{color: p.icon}}>
<X size={13}/>
</button>
)}
</div>
)}
{/* Input area */}
<div
onClick={() => editor?.commands.focus()}
style={{
background: p.bg,
border: `1.5px solid ${borderColor}`,
borderRadius: replyingTo ? '0 0 14px 14px' : '14px',
boxShadow,
transition: 'border-color 160ms ease, box-shadow 160ms ease',
}}
>
{/* Mention dropdown */}
{mentionOpen && (
<MentionDropdown
items={mentionItems2}
selectedIndex={mentionIdx}
onSelect={selectMention}
p={p}
query={mentionQuery}
/>
)}
{/* Editor */}
<div
className="ai-editor"
style={{padding: '12px 14px 10px'}}
>
<EditorContent editor={editor}/>
</div>
{/* Discord-style toolbar: icons left, send right */}
<div className="flex items-center justify-between px-2 pb-2">
{/* Left — emoji + attach */}
<div className="flex items-center gap-0.5">
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setShowEmoji(v => !v);
}}
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{
color: showEmoji ? p.iconHover : p.icon,
background: showEmoji ? p.bgActive : 'transparent'
}}
title="Emoji"
>
<Smile size={18}/>
</button>
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => {
editor?.chain().focus().insertContent(emoji).insertContent(' ').run();
setShowEmoji(false);
}} p={p}/>}
</div>
<label
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{color: p.icon}}
title="Attach file"
>
<Paperclip size={18}/>
<input type="file" className="hidden" onChange={e => {
e.stopPropagation();
const f = e.target.files?.[0];
if (f && onUploadFile) void doUpload(f);
e.target.value = '';
}}/>
</label>
</div>
{/* Right — hint + send */}
<div className="flex items-center gap-2.5">
<span className="text-[11px]" style={{color: p.textSubtle}}> send</span>
<button
onClick={(e) => {
e.stopPropagation();
send();
}}
disabled={!hasContent}
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
style={{
background: hasContent ? p.sendBg : p.sendDisabled,
color: hasContent ? p.sendIcon : p.icon,
opacity: hasContent ? 1 : 0.55,
transform: hasContent ? 'scale(1)' : 'scale(0.92)',
}}
title="Send"
>
<Send size={13}/>
</button>
</div>
</div>
</div>
<label
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
style={{ color: p.icon }}
title="Attach file"
>
<Paperclip size={18} />
<input type="file" className="hidden" onChange={e => { e.stopPropagation(); const f = e.target.files?.[0]; if (f && onUploadFile) void doUpload(f); e.target.value = ''; }} />
</label>
</div>
{/* Right — hint + send */}
<div className="flex items-center gap-2.5">
<span className="text-[11px]" style={{ color: p.textSubtle }}> send</span>
<button
onClick={(e) => { e.stopPropagation(); send(); }}
disabled={!hasContent}
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
style={{
background: hasContent ? p.sendBg : p.sendDisabled,
color: hasContent ? p.sendIcon : p.icon,
opacity: hasContent ? 1 : 0.55,
transform: hasContent ? 'scale(1)' : 'scale(0.92)',
}}
title="Send"
>
<Send size={13} />
</button>
</div>
</div>
</div>
<style>{`
<style>{`
.ai-editor .ProseMirror {
outline: none;
white-space: pre-wrap;
@ -495,8 +553,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
}
.ai-editor .ProseMirror::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
</div>
);
});
IMEditor.displayName = 'IMEditor';

View File

@ -63,6 +63,8 @@ export type MessageWithMeta = RoomMessageResponse & {
/** True for messages sent by the current user that haven't been confirmed by the server */
isOptimistic?: boolean;
reactions?: ReactionGroup[];
/** Attachment IDs for files uploaded with this message */
attachment_ids?: string[];
};
export type RoomWithCategory = RoomResponse & {
@ -119,7 +121,7 @@ interface RoomContextValue {
isTransitioningRoom: boolean;
nextCursor: number | null;
loadMore: (cursor?: number | null) => void;
sendMessage: (content: string, contentType?: string, inReplyTo?: string) => Promise<void>;
sendMessage: (content: string, contentType?: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
revokeMessage: (messageId: string) => Promise<void>;
updateReadSeq: (seq: number) => Promise<void>;
@ -837,7 +839,7 @@ export function RoomProvider({
const sendingRef = useRef(false);
const sendMessage = useCallback(
async (content: string, contentType = 'text', inReplyTo?: string) => {
async (content: string, contentType = 'text', inReplyTo?: string, attachmentIds?: string[]) => {
const client = wsClientRef.current;
if (!activeRoomId || !client) return;
if (sendingRef.current) return;
@ -861,6 +863,7 @@ export function RoomProvider({
thread_id: inReplyTo,
in_reply_to: inReplyTo,
reactions: [],
attachment_ids: attachmentIds,
};
setMessages((prev) => [...prev, optimisticMsg]);
@ -869,6 +872,7 @@ export function RoomProvider({
const confirmedMsg = await client.messageCreate(activeRoomId, content, {
contentType,
inReplyTo,
attachmentIds,
});
// Replace optimistic message with server-confirmed one
setMessages((prev) => {

View File

@ -4,211 +4,342 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
/*
DESIGN TOKEN LAYER
Structure: Primitive Semantic Component
Philosophy: Linear/Vercel dual-color (neutral monochrome + single indigo accent)
*/
/* ── Layer 1: Primitive tokens ─────────────────────────────────────────────── */
/* These are never used directly in components — always reference via semantic */
/* Light primitives */
@layer primitives {
:root {
/* Neutral scale (achromatic gray ramp) */
--p-gray-50: oklch(0.995 0 0);
--p-gray-100: oklch(0.985 0 0);
--p-gray-200: oklch(0.967 0 0);
--p-gray-300: oklch(0.91 0 0);
--p-gray-400: oklch(0.70 0 0);
--p-gray-500: oklch(0.55 0 0);
--p-gray-600: oklch(0.40 0 0);
--p-gray-700: oklch(0.30 0 0);
--p-gray-800: oklch(0.20 0 0);
--p-gray-900: oklch(0.135 0 0);
--p-gray-950: oklch(0.11 0 0);
/* Brand accent (single hue: indigo) */
--p-accent-50: oklch(0.95 0.05 265);
--p-accent-100: oklch(0.88 0.08 265);
--p-accent-200: oklch(0.78 0.11 265);
--p-accent-300: oklch(0.65 0.14 265);
--p-accent-400: oklch(0.55 0.17 265);
--p-accent-500: oklch(0.42 0.19 265);
--p-accent-600: oklch(0.35 0.20 265);
--p-accent-700: oklch(0.30 0.21 265);
--p-accent-800: oklch(0.22 0.17 265);
--p-accent-900: oklch(0.14 0.12 265);
/* Status */
--p-success: oklch(0.55 0.14 160); /* green */
--p-warning: oklch(0.72 0.14 75); /* yellow */
--p-error: oklch(0.55 0.22 25); /* red */
/* Surface luminance — base for background */
--p-surface-light: oklch(0.995 0 0);
--p-surface-dark: oklch(0.13 0 0);
/* Shadows */
--p-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.04), 0 1px 3px oklch(0 0 0 / 0.06);
--p-shadow-md: 0 4px 6px oklch(0 0 0 / 0.04), 0 2px 4px oklch(0 0 0 / 0.06);
--p-shadow-lg: 0 10px 15px oklch(0 0 0 / 0.05), 0 4px 6px oklch(0 0 0 / 0.06);
--p-shadow-xl: 0 8px 30px oklch(0 0 0 / 0.10), 0 2px 8px oklch(0 0 0 / 0.06);
--p-shadow-focus-light: 0 0 0 3px oklch(0.42 0.19 265 / 15%);
--p-shadow-focus-dark: 0 0 0 3px oklch(0.60 0.17 265 / 25%);
/* Radius base */
--p-radius-sm: 0.25rem;
--p-radius-md: 0.375rem;
--p-radius-lg: 0.5rem;
--p-radius-xl: 0.75rem;
}
}
/* ── Layer 2: Semantic tokens ─────────────────────────────────────────────── */
/* Maps primitives to roles. Theme-switching happens HERE only. */
@layer semantic {
:root {
/* Background / foreground */
--bg: var(--p-gray-50);
--fg: var(--p-gray-900);
--fg-muted: var(--p-gray-500);
--fg-subtle: var(--p-gray-400);
/* Surface hierarchy */
--surface-1: var(--p-gray-50); /* page bg */
--surface-2: var(--p-gray-100); /* cards */
--surface-3: var(--p-gray-200); /* hover, input bg */
/* Border */
--border: var(--p-gray-300);
--border-2: var(--p-gray-200); /* subtle dividers */
/* Brand accent */
--accent: var(--p-accent-500);
--accent-hover: var(--p-accent-400);
--accent-subtle: oklch(0.42 0.19 265 / 10%);
--accent-fg: var(--p-gray-50);
/* Destructive */
--destructive: var(--p-error);
--destructive-fg: var(--p-gray-50);
/* Status */
--success: var(--p-success);
--success-subtle: oklch(0.55 0.14 160 / 12%);
--warning: var(--p-warning);
--warning-subtle: oklch(0.72 0.14 75 / 12%);
--error: var(--p-error);
--error-subtle: oklch(0.55 0.22 25 / 12%);
/* Ring / focus */
--ring: var(--accent);
--focus: var(--p-shadow-focus-light);
/* Chart — monochrome ramp */
--chart-1: var(--p-gray-600);
--chart-2: var(--p-gray-500);
--chart-3: var(--p-gray-400);
--chart-4: var(--p-gray-300);
--chart-5: var(--p-gray-200);
/* Radius */
--radius: var(--p-radius-md);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-lg: calc(var(--radius) * 1.0);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
/* ── Discord layout tokens ─────────────────────────────────────────────── */
--color-discord-bg: var(--room-bg);
--color-discord-sidebar: var(--room-sidebar);
--color-discord-channel-hover: var(--room-channel-hover);
/* Shadows */
--shadow-sm: var(--p-shadow-sm);
--shadow-md: var(--p-shadow-md);
--shadow-lg: var(--p-shadow-lg);
--shadow-xl: var(--p-shadow-xl);
/* Sidebar */
--sidebar-bg: var(--p-gray-100);
--sidebar-fg: var(--p-gray-900);
--sidebar-border: var(--p-gray-300);
--sidebar-accent: var(--p-gray-200);
--sidebar-accent-fg: var(--p-gray-700);
--sidebar-primary: var(--accent);
--sidebar-primary-fg: var(--p-gray-50);
/* Room / chat */
--room-bg: var(--p-gray-50);
--room-sidebar: var(--p-gray-100);
--room-sidebar-fg: var(--p-gray-900);
--room-hover: var(--p-gray-200);
--room-border: var(--p-gray-300);
--room-channel-active: var(--accent-subtle);
--room-text: var(--p-gray-900);
--room-text-secondary: var(--p-gray-600);
--room-text-muted: var(--p-gray-500);
--room-text-subtle: var(--p-gray-400);
--room-accent: var(--accent);
--room-accent-hover: var(--accent-hover);
--room-mention-bg: var(--accent);
--room-mention-fg: var(--p-gray-50);
--room-online: var(--p-success);
--room-away: var(--p-warning);
--room-offline: var(--p-gray-400);
}
/* Dark theme — only semantic overrides, primitives stay the same */
.dark {
--bg: var(--p-gray-950);
--fg: var(--p-gray-50);
--fg-muted: var(--p-gray-400);
--fg-subtle: var(--p-gray-600);
--surface-1: var(--p-gray-950);
--surface-2: var(--p-gray-900);
--surface-3: var(--p-gray-800);
--border: oklch(1 0 0 / 8%);
--border-2: oklch(1 0 0 / 5%);
--accent: var(--p-accent-600);
--accent-hover: var(--p-accent-500);
--accent-subtle: oklch(0.60 0.17 265 / 14%);
--accent-fg: var(--p-gray-950);
--destructive: oklch(0.65 0.18 25);
--destructive-fg: var(--p-gray-950);
--success: var(--p-success);
--success-subtle: oklch(0.65 0.13 160 / 15%);
--warning: var(--p-warning);
--warning-subtle: oklch(0.68 0.14 75 / 15%);
--error: var(--p-error);
--error-subtle: oklch(0.65 0.18 25 / 15%);
--ring: var(--accent);
--focus: var(--p-shadow-focus-dark);
--chart-1: var(--p-gray-200);
--chart-2: var(--p-gray-300);
--chart-3: var(--p-gray-400);
--chart-4: var(--p-gray-500);
--chart-5: var(--p-gray-600);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.30), 0 1px 3px oklch(0 0 0 / 0.40);
--shadow-md: 0 4px 6px oklch(0 0 0 / 0.30), 0 2px 4px oklch(0 0 0 / 0.40);
--shadow-lg: 0 10px 15px oklch(0 0 0 / 0.35), 0 4px 6px oklch(0 0 0 / 0.40);
--shadow-xl: 0 8px 30px oklch(0 0 0 / 0.50), 0 2px 8px oklch(0 0 0 / 0.40);
--sidebar-bg: var(--p-gray-950);
--sidebar-fg: var(--p-gray-50);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-accent: var(--p-gray-800);
--sidebar-accent-fg: var(--p-gray-200);
--sidebar-primary: var(--accent);
--sidebar-primary-fg: var(--p-gray-950);
--room-bg: var(--p-gray-950);
--room-sidebar: var(--p-gray-900);
--room-sidebar-fg: var(--p-gray-50);
--room-hover: var(--p-gray-800);
--room-border: oklch(1 0 0 / 8%);
--room-channel-active: var(--accent-subtle);
--room-text: var(--p-gray-50);
--room-text-secondary: var(--p-gray-200);
--room-text-muted: oklch(1 0 0 / 55%);
--room-text-subtle: oklch(1 0 0 / 38%);
--room-accent: var(--accent);
--room-accent-hover: var(--accent-hover);
--room-mention-bg: var(--accent);
--room-mention-fg: var(--p-gray-950);
--room-online: var(--p-success);
--room-away: var(--p-warning);
--room-offline: oklch(1 0 0 / 30%);
}
}
/* ── Layer 3: Tailwind @theme inline bridge ──────────────────────────────── */
/* Maps semantic tokens → Tailwind utility classes */
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-background: var(--bg);
--color-foreground: var(--fg);
--color-card: var(--surface-2);
--color-card-foreground: var(--fg);
--color-popover: var(--surface-2);
--color-popover-foreground: var(--fg);
--color-primary: var(--accent);
--color-primary-foreground: var(--accent-fg);
--color-secondary: var(--surface-3);
--color-secondary-foreground: var(--fg);
--color-muted: var(--surface-3);
--color-muted-foreground: var(--fg-muted);
--color-accent: var(--surface-3);
--color-accent-foreground: var(--fg);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-fg);
--color-border: var(--border);
--color-input: var(--border);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-2xl: var(--radius-2xl);
--radius-3xl: var(--radius-3xl);
--radius-4xl: var(--radius-4xl);
/* Sidebar */
--color-sidebar: var(--sidebar-bg);
--color-sidebar-foreground: var(--sidebar-fg);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-fg);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-fg);
--color-sidebar-ring: var(--ring);
/* Room (discord-layout) — maps to semantic tokens */
--color-discord-bg: var(--room-bg);
--color-discord-sidebar: var(--room-sidebar);
--color-discord-fg: var(--room-sidebar-fg);
--color-discord-hover: var(--room-hover);
--color-discord-border: var(--room-border);
--color-discord-channel-active: var(--room-channel-active);
--color-discord-mention-badge: var(--room-mention-badge);
--color-discord-blurple: var(--room-accent);
--color-discord-blurple-hover: var(--room-accent-hover);
--color-discord-green: var(--room-online);
--color-discord-red: oklch(0.63 0.21 25);
--color-discord-yellow: oklch(0.75 0.17 80);
--color-discord-online: var(--room-online);
--color-discord-offline: var(--room-offline);
--color-discord-idle: var(--room-away);
--color-discord-mention-text: var(--room-mention-text);
--color-discord-text: var(--room-text);
--color-discord-text: var(--room-text);
--color-discord-text-secondary: var(--room-text-secondary);
--color-discord-text-muted: var(--room-text-muted);
--color-discord-text-subtle: var(--room-text-subtle);
--color-discord-border: var(--room-border);
--color-discord-hover: var(--room-hover);
--color-discord-placeholder: var(--room-text-muted);
--color-discord-text-muted: var(--room-text-muted);
--color-discord-text-subtle: var(--room-text-subtle);
--color-discord-accent: var(--room-accent);
--color-discord-accent-hover: var(--room-accent-hover);
--color-discord-mention-bg: var(--room-mention-bg);
--color-discord-mention-fg: var(--room-mention-fg);
--color-discord-online: var(--room-online);
--color-discord-away: var(--room-away);
--color-discord-offline: var(--room-offline);
--color-discord-success: var(--success);
--color-discord-warning: var(--warning);
--color-discord-error: var(--error);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.5rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* AI Studio room palette — light */
--room-bg: oklch(0.995 0 0);
--room-sidebar: oklch(0.99 0 0);
--room-channel-hover: oklch(0.97 0 0);
--room-channel-active: oklch(0.55 0.18 253 / 8%);
--room-mention-badge: oklch(0.55 0.18 253);
--room-accent: oklch(0.55 0.18 253);
--room-accent-hover: oklch(0.52 0.19 253);
--room-online: oklch(0.63 0.19 158);
--room-offline: oklch(0.62 0 0 / 35%);
--room-away: oklch(0.75 0.17 80);
--room-text: oklch(0.145 0 0);
--room-text-secondary: oklch(0.25 0 0);
--room-text-muted: oklch(0.50 0 0);
--room-text-subtle: oklch(0.68 0 0);
--room-border: oklch(0.91 0 0);
--room-hover: oklch(0.97 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0 0);
--muted-foreground: oklch(0.65 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 10%);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.13 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.22 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* Discord dark theme */
--discord-bg: oklch(0.145 0 0);
--discord-sidebar: oklch(0.13 0 0);
--discord-channel-hover: oklch(0.2 0 0);
/* AI Studio room palette — dark */
--room-bg: oklch(0.11 0 0);
--room-sidebar: oklch(0.10 0 0);
--room-channel-hover: oklch(0.16 0 0);
--room-channel-active: oklch(0.58 0.18 253 / 12%);
--room-mention-badge: oklch(0.58 0.18 253);
--room-accent: oklch(0.58 0.18 253);
--room-accent-hover: oklch(0.65 0.20 253);
--room-online: oklch(0.65 0.17 158);
--room-offline: oklch(0.50 0 0 / 35%);
--room-away: oklch(0.72 0.16 80);
--room-text: oklch(0.985 0 0);
--room-text-secondary: oklch(0.985 0 0 / 80%);
--room-text-muted: oklch(0.985 0 0 / 58%);
--room-text-subtle: oklch(0.985 0 0 / 40%);
--room-border: oklch(0 0 0 / 18%);
--room-hover: oklch(0.16 0 0);
}
/* ── Layer 4: Base styles ──────────────────────────────────────────────────── */
@layer base {
* {
@apply border-border outline-ring/50;
}
}
body {
@apply bg-background text-foreground;
}
@apply bg-background text-foreground antialiased;
}
html {
@apply font-sans;
}
}
}
/* Placeholder support for contenteditable MentionInput */
[contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
color: var(--fg-muted);
pointer-events: none;
}
/* ── Discord layout ──────────────────────────────────────────────────────── */
/*
COMPONENT STYLES all reference semantic tokens via CSS vars or Tailwind
*/
/* ── Discord layout ─────────────────────────────────────────────────────────── */
.discord-layout {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
background: var(--room-bg);
color: var(--foreground);
color: var(--room-text);
}
/* Server sidebar (left icon strip) */
.discord-server-sidebar {
display: flex;
flex-direction: column;
@ -232,8 +363,8 @@
align-items: center;
justify-content: center;
cursor: pointer;
background: var(--room-channel-hover);
color: var(--foreground);
background: var(--room-hover);
color: var(--room-sidebar-fg);
transition: border-radius 200ms ease, background 200ms ease;
font-weight: 700;
font-size: 14px;
@ -242,16 +373,11 @@
user-select: none;
}
.discord-server-icon:hover {
border-radius: 16px;
background: var(--room-accent);
color: var(--primary-foreground);
}
.discord-server-icon:hover,
.discord-server-icon.active {
border-radius: 16px;
background: var(--room-accent);
color: var(--primary-foreground);
color: var(--room-mention-fg);
}
.discord-server-icon .home-icon {
@ -259,11 +385,10 @@
height: 28px;
}
/* Channel sidebar */
.discord-channel-sidebar {
display: flex;
flex-direction: column;
width: 260px;
width: 240px;
background: var(--room-sidebar);
border-right: 1px solid var(--room-border);
flex-shrink: 0;
@ -276,14 +401,13 @@
height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--room-border);
box-shadow: 0 1px 0 var(--room-border);
flex-shrink: 0;
}
.discord-channel-header-title {
font-weight: 600;
font-size: 15px;
color: var(--foreground);
color: var(--room-sidebar-fg);
flex: 1;
}
@ -294,20 +418,14 @@
padding: 8px 8px 8px 0;
}
.discord-channel-list::-webkit-scrollbar {
width: 4px;
}
.discord-channel-list::-webkit-scrollbar-track {
background: transparent;
}
.discord-channel-list::-webkit-scrollbar { width: 4px; }
.discord-channel-list::-webkit-scrollbar-track { background: transparent; }
.discord-channel-list::-webkit-scrollbar-thumb {
background: var(--room-border);
border-radius: 4px;
}
.discord-channel-category {
margin-bottom: 4px;
}
.discord-channel-category { margin-bottom: 4px; }
.discord-channel-category-header {
display: flex;
@ -326,17 +444,9 @@
transition: color 150ms;
}
.discord-channel-category-header:hover {
color: var(--foreground);
}
.discord-channel-category-header svg {
transition: transform 150ms;
}
.discord-channel-category-header.collapsed svg {
transform: rotate(-90deg);
}
.discord-channel-category-header:hover { color: var(--room-sidebar-fg); }
.discord-channel-category-header svg { transition: transform 150ms; }
.discord-channel-category-header.collapsed svg { transform: rotate(-90deg); }
.discord-channel-item {
display: flex;
@ -360,7 +470,7 @@
.discord-channel-item.active {
background: var(--room-channel-active);
color: var(--foreground);
color: var(--room-sidebar-fg);
}
.discord-channel-item.active::before {
@ -398,8 +508,8 @@
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--room-mention-badge);
color: var(--primary-foreground);
background: var(--room-mention-bg);
color: var(--room-mention-fg);
font-size: 11px;
font-weight: 700;
line-height: 1;
@ -421,12 +531,9 @@
text-align: left;
width: 100%;
}
.discord-add-channel-btn:hover { color: var(--room-text); }
.discord-add-channel-btn:hover {
color: var(--room-text);
}
/* Member list sidebar */
/* Member sidebar */
.discord-member-sidebar {
display: flex;
flex-direction: column;
@ -470,15 +577,9 @@
transition: background 100ms;
user-select: none;
}
.discord-member-item:hover { background: var(--room-hover); }
.discord-member-item:hover {
background: var(--room-hover);
}
.discord-member-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.discord-member-avatar-wrap { position: relative; flex-shrink: 0; }
.discord-member-status-dot {
position: absolute;
@ -490,17 +591,12 @@
border: 2px solid var(--room-sidebar);
}
.discord-member-status-dot.online {
background: var(--room-online);
}
.discord-member-status-dot.offline {
background: var(--room-offline);
}
.discord-member-status-dot.idle {
background: var(--room-away);
}
.discord-member-status-dot.online { background: var(--room-online); }
.discord-member-status-dot.offline { background: var(--room-offline); }
.discord-member-status-dot.idle { background: var(--room-away); }
/* ── Message list ──────────────────────────────────────────────────────────── */
/* ── Discord message bubbles ─────────────────────────────────────────────── */
.discord-message-list {
flex: 1;
overflow-y: auto;
@ -508,12 +604,8 @@
padding: 0 16px 8px;
}
.discord-message-list::-webkit-scrollbar {
width: 8px;
}
.discord-message-list::-webkit-scrollbar-track {
background: transparent;
}
.discord-message-list::-webkit-scrollbar { width: 8px; }
.discord-message-list::-webkit-scrollbar-track { background: transparent; }
.discord-message-list::-webkit-scrollbar-thumb {
background: var(--room-border);
border-radius: 4px;
@ -521,10 +613,7 @@
background-clip: padding-box;
}
.discord-message-group {
display: flex;
flex-direction: column;
}
.discord-message-group { display: flex; flex-direction: column; }
.discord-message-row {
display: flex;
@ -536,9 +625,7 @@
position: relative;
}
.discord-message-row:hover .discord-message-actions {
opacity: 1;
}
.discord-message-row:hover .discord-message-actions { opacity: 1; }
.discord-message-avatar {
width: 40px;
@ -549,15 +636,8 @@
margin-top: -2px;
}
.discord-message-body {
flex: 1;
min-width: 0;
}
.discord-message-avatar-spacer {
width: 40px;
flex-shrink: 0;
}
.discord-message-body { flex: 1; min-width: 0; }
.discord-message-avatar-spacer { width: 40px; flex-shrink: 0; }
.discord-message-header {
display: flex;
@ -569,14 +649,11 @@
.discord-message-author {
font-size: 15px;
font-weight: 600;
color: var(--foreground);
color: var(--room-text);
line-height: 1.2;
cursor: pointer;
}
.discord-message-author:hover {
text-decoration: underline;
}
.discord-message-author:hover { text-decoration: underline; }
.discord-message-time {
font-size: 11px;
@ -593,8 +670,8 @@
}
.discord-message-content .mention {
background: oklch(0.488 0.243 264.376 / 25%);
color: var(--room-mention-text);
background: var(--accent-subtle);
color: var(--accent);
padding: 0 4px;
border-radius: 3px;
font-weight: 500;
@ -607,13 +684,13 @@
display: flex;
align-items: center;
gap: 2px;
background: var(--card);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px;
opacity: 0;
transition: opacity 150ms;
box-shadow: 0 1px 4px var(--room-border);
box-shadow: var(--shadow-md);
}
.discord-msg-action-btn {
@ -629,7 +706,6 @@
background: none;
border: none;
}
.discord-msg-action-btn:hover {
background: var(--room-hover);
color: var(--room-text);
@ -668,10 +744,7 @@
color: var(--room-text-subtle);
}
.discord-typing-dots {
display: flex;
gap: 3px;
}
.discord-typing-dots { display: flex; gap: 3px; }
.discord-typing-dots span {
width: 6px;
@ -686,10 +759,10 @@
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
30% { transform: translateY(-4px); }
}
/* Status pill in header */
/* WebSocket status */
.discord-ws-status {
display: inline-flex;
align-items: center;
@ -704,19 +777,19 @@
border-radius: 50%;
}
.discord-ws-dot.connected { background: var(--room-online); }
.discord-ws-dot.connecting {
.discord-ws-dot.connected { background: var(--room-online); }
.discord-ws-dot.connecting {
background: var(--room-away);
animation: pulse 1.5s infinite;
}
.discord-ws-dot.disconnected { background: oklch(0.63 0.21 25); }
.discord-ws-dot.disconnected { background: var(--error); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
50% { opacity: 0.4; }
}
/* Chat input area */
/* Chat input */
.discord-chat-input-area {
display: flex;
flex-direction: column;
@ -754,11 +827,7 @@
overflow-y: auto;
font-family: inherit;
}
.discord-input-field::placeholder {
color: var(--room-text-muted);
}
.discord-input-field::placeholder { color: var(--room-text-muted); }
.discord-input-field::-webkit-scrollbar { width: 0; }
.discord-send-btn {
@ -769,17 +838,16 @@
height: 36px;
border-radius: 6px;
background: var(--room-accent);
color: var(--primary-foreground);
color: var(--room-mention-fg);
cursor: pointer;
border: none;
transition: background 150ms;
flex-shrink: 0;
}
.discord-send-btn:hover { background: var(--room-accent-hover); }
.discord-send-btn:hover { background: var(--room-accent-hover); }
.discord-send-btn:disabled { opacity: 0.5; cursor: default; }
/* Reply preview in input */
/* Reply preview */
.discord-reply-preview {
display: flex;
align-items: center;
@ -793,11 +861,7 @@
color: var(--room-text-muted);
}
.discord-reply-preview-author {
font-weight: 600;
color: var(--room-text-secondary);
}
.discord-reply-preview-author { font-weight: 600; color: var(--room-text-secondary); }
.discord-reply-preview-text {
flex: 1;
overflow: hidden;
@ -805,7 +869,7 @@
white-space: nowrap;
}
/* Streaming message cursor */
/* Streaming cursor */
.discord-streaming-cursor {
display: inline-block;
width: 2px;
@ -818,14 +882,14 @@
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
50% { opacity: 0; }
}
/* Role color dot */
/* Role dot */
.discord-role-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
}

View File

@ -622,6 +622,7 @@ export class RoomWsClient {
contentType?: string;
threadId?: string;
inReplyTo?: string;
attachmentIds?: string[];
},
): Promise<RoomMessageResponse> {
return this.request<RoomMessageResponse>('message.create', {
@ -630,6 +631,7 @@ export class RoomWsClient {
content_type: options?.contentType,
thread_id: options?.threadId,
in_reply_to: options?.inReplyTo,
attachment_ids: options?.attachmentIds,
});
}

View File

@ -91,6 +91,7 @@ export interface WsRequestParams {
min_score?: number;
query?: string;
message_ids?: string[];
attachment_ids?: string[];
}
export interface WsResponse {

View File

@ -6,6 +6,20 @@ import {UserProvider} from '@/contexts';
import {ThemeProvider} from '@/contexts/theme-context';
import './index.css';
import App from './App.tsx';
import {applyPaletteToDOM, loadActivePresetId} from '@/components/room/design-system';
// Restore custom palette on page load (before first render)
const activePreset = loadActivePresetId();
if (activePreset === 'custom') {
const customPalette = localStorage.getItem('theme-custom-palette');
if (customPalette) {
try {
applyPaletteToDOM(JSON.parse(customPalette));
} catch {
// ignore malformed stored palette
}
}
}
const queryClient = new QueryClient();