Compare commits

..

6 Commits

Author SHA1 Message Date
ZhenYi
a09f66b779 refactor(room): WebSocket queue and message editor improvements
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
- Enhance ws_universal.rs with queue message support
- Add queue types and producer improvements
- Simplify MessageBubble.tsx rendering logic
- Refactor IMEditor.tsx with improved message handling
- Update DiscordChatPanel.tsx with message enhancements
2026-04-18 19:29:36 +08:00
ZhenYi
c4fb943e07 fix(backend): add project_name and invited_by_username to InvitationResponse
InvitationResponse was missing project_name and invited_by_username fields,
causing /invitations accept to redirect to /project/undefined.
Now populated via async from_model() with batch DB lookups.
2026-04-18 19:24:43 +08:00
ZhenYi
5579e6c58e feat(backend): add git_tools service module
Add git_tools module with Git operations: branch, commit, diff, tag, tree, types, ctx
2026-04-18 19:08:06 +08:00
ZhenYi
821b0e998d refactor(room): Discord layout and room WebSocket client refactor
- Refactor room-context.tsx with improved WebSocket state management
- Enhance room-ws-client.ts with reconnect logic and message handling
- Update Discord layout components with message editor improvements
- Add WebSocket universal endpoint support in ws_universal.rs
2026-04-18 19:05:21 +08:00
ZhenYi
0cccec33b2 feat(frontend): invitations page with project and workspace support
- Add /invitations route with dedicated page and sidebar button
- Display both project invitations (accept/decline) and workspace invitations (accept only)
- Merge and sort both invitation types by most recent first
- Export new SDK functions: workspaceMyInvitations, workspaceAcceptInvitationBySlug
2026-04-18 19:05:14 +08:00
ZhenYi
9b9c12ffc8 feat(backend): add workspace invitation list and slug-based accept APIs
- Add workspace_my_pending_invitations() for listing pending invites
- Add workspace_accept_invitation_by_slug() to accept by slug without token
- Register new routes: GET /workspaces/me/invitations, POST /workspaces/invitations/accept-by-slug
2026-04-18 19:05:07 +08:00
33 changed files with 2245 additions and 270 deletions

View File

@ -98,11 +98,7 @@ impl ChatService {
frequency_penalty: Some(frequency_penalty as f32),
presence_penalty: Some(presence_penalty as f32),
stream: Some(false),
reasoning_effort: Some(if think {
ReasoningEffort::High
} else {
ReasoningEffort::None
}),
reasoning_effort: if think { Some(ReasoningEffort::High) } else { None },
tools: if tools_enabled {
Some(
tools
@ -232,11 +228,7 @@ impl ChatService {
frequency_penalty: Some(frequency_penalty as f32),
presence_penalty: Some(presence_penalty as f32),
stream: Some(true),
reasoning_effort: Some(if think {
ReasoningEffort::High
} else {
ReasoningEffort::None
}),
reasoning_effort: if think { Some(ReasoningEffort::High) } else { None },
tools: if tools_enabled {
Some(
tools

View File

@ -452,6 +452,8 @@ use utoipa::OpenApi;
crate::workspace::members::workspace_pending_invitations,
crate::workspace::members::workspace_cancel_invitation,
crate::workspace::members::workspace_accept_invitation,
crate::workspace::members::workspace_my_invitations,
crate::workspace::members::workspace_accept_invitation_by_slug,
crate::workspace::settings::workspace_update,
crate::workspace::settings::workspace_delete,
),
@ -635,6 +637,8 @@ use utoipa::OpenApi;
service::workspace::members::WorkspaceInviteParams,
service::workspace::members::WorkspaceInviteAcceptParams,
service::workspace::members::PendingInvitationInfo,
service::workspace::members::MyWorkspaceInvitation,
service::workspace::members::WorkspaceAcceptBySlugParams,
service::workspace::settings::WorkspaceUpdateParams,
// Room
room::RoomResponse,

View File

@ -289,6 +289,15 @@ pub async fn ws_universal(
continue;
}
// Handle JSON-level ping (application heartbeat).
// Client sends {"type":"ping"} and we reply with {"type":"pong"}.
if text.trim() == r#"{"type":"ping"}"# {
if session.text(r#"{"type":"pong"}"#).await.is_err() { break; }
last_activity = Instant::now();
last_heartbeat = Instant::now();
continue;
}
match serde_json::from_str::<WsRequest>(&text) {
Ok(request) => {
let action_str = request.action.to_string();
@ -388,7 +397,7 @@ async fn poll_push_streams(
if let Some(reactions) = event.reactions.clone() {
return Some(WsPushEvent::ReactionUpdated {
room_id: event.room_id,
message_id: event.id,
message_id: event.message_id.unwrap_or(event.id),
reactions,
});
}

View File

@ -2,8 +2,8 @@ use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::workspace::members::{
PendingInvitationInfo, WorkspaceInviteAcceptParams, WorkspaceInviteParams,
WorkspaceMembersResponse,
MyWorkspaceInvitation, PendingInvitationInfo, WorkspaceAcceptBySlugParams,
WorkspaceInviteAcceptParams, WorkspaceInviteParams, WorkspaceMembersResponse,
};
use session::Session;
use uuid::Uuid;
@ -203,3 +203,47 @@ pub async fn workspace_accept_invitation(
let resp = service.workspace_info(&session, ws.slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}
/// List all pending workspace invitations for the current user.
#[utoipa::path(
get,
path = "/api/workspaces/me/invitations",
responses(
(status = 200, description = "List my workspace invitations", body = ApiResponse<Vec<MyWorkspaceInvitation>>),
(status = 401, description = "Unauthorized"),
),
tag = "Workspace"
)]
pub async fn workspace_my_invitations(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, ApiError> {
let resp = service.workspace_my_pending_invitations(&session).await?;
Ok(ApiResponse::ok(resp).to_response())
}
/// Accept a workspace invitation by slug.
#[utoipa::path(
post,
path = "/api/workspaces/invitations/accept-by-slug",
request_body = WorkspaceAcceptBySlugParams,
responses(
(status = 200, description = "Accept invitation", body = ApiResponse<service::workspace::info::WorkspaceInfoResponse>),
(status = 400, description = "Invalid or expired token"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Invitation not found"),
(status = 409, description = "Already accepted"),
),
tag = "Workspace"
)]
pub async fn workspace_accept_invitation_by_slug(
service: web::Data<AppService>,
session: Session,
body: web::Json<WorkspaceAcceptBySlugParams>,
) -> Result<HttpResponse, ApiError> {
let ws = service
.workspace_accept_invitation_by_slug(&session, body.into_inner())
.await?;
let resp = service.workspace_info(&session, ws.slug).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -48,6 +48,18 @@ pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) {
web::patch().to(members::workspace_update_member_role),
)
// Invitations
.route(
"/me/invitations",
web::get().to(members::workspace_my_invitations),
)
.route(
"/invitations/accept",
web::post().to(members::workspace_accept_invitation),
)
.route(
"/invitations/accept-by-slug",
web::post().to(members::workspace_accept_invitation_by_slug),
)
.route(
"/{slug}/invitations",
web::post().to(members::workspace_invite_member),
@ -59,10 +71,6 @@ pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) {
.route(
"/{slug}/invitations/{user_id}",
web::delete().to(members::workspace_cancel_invitation),
)
.route(
"/invitations/accept",
web::post().to(members::workspace_accept_invitation),
),
);
}

View File

@ -176,7 +176,7 @@ impl MessageProducer {
pub async fn publish_reaction_event(
&self,
room_id: uuid::Uuid,
_message_id: uuid::Uuid,
message_id: uuid::Uuid,
reactions: Vec<ReactionGroup>,
) {
let Some(pubsub) = &self.pubsub else {
@ -196,6 +196,7 @@ impl MessageProducer {
seq: 0,
display_name: None,
reactions: Some(reactions),
message_id: Some(message_id),
};
pubsub.publish_room_message(room_id, &event).await;
}

View File

@ -35,6 +35,9 @@ pub struct RoomMessageEvent {
/// Present when this event carries reaction updates for the message.
#[serde(skip_serializing_if = "Option::is_none")]
pub reactions: Option<Vec<ReactionGroup>>,
/// Target message ID for reaction update events.
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -61,6 +64,7 @@ impl From<RoomMessageEnvelope> for RoomMessageEvent {
seq: e.seq,
display_name: None,
reactions: None,
message_id: None,
}
}
}

View File

@ -1065,6 +1065,7 @@ impl RoomService {
display_name: Some(ai_display_name.clone()),
in_reply_to: None,
reactions: None,
message_id: None,
};
room_manager.broadcast(room_id_inner, msg_event).await;
room_manager.metrics.messages_sent.increment(1);
@ -1207,6 +1208,7 @@ impl RoomService {
display_name: model_display_name,
in_reply_to: None,
reactions: None,
message_id: None,
};
room_manager.broadcast(room_id, event).await;

View File

@ -0,0 +1,93 @@
//! Git branch tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
async fn git_branch_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let remote_only = p.get("remote_only").and_then(|v| v.as_bool()).unwrap_or(false);
let domain = ctx.open_repo(project_name, repo_name).await?;
let branches = domain.branch_list(remote_only).map_err(|e| e.to_string())?;
let result: Vec<_> = branches.iter().map(|b| {
let oid = b.oid.to_string();
serde_json::json!({
"name": b.name, "oid": oid.clone(), "short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
"is_head": b.is_head, "is_remote": b.is_remote, "is_current": b.is_current,
"upstream": b.upstream.clone()
})
}).collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
async fn git_branch_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let info = domain.branch_get(name).map_err(|e| e.to_string())?;
let ahead_behind = if let Some(ref upstream) = info.upstream {
let (ahead, behind) = domain.branch_ahead_behind(name, upstream).unwrap_or((0, 0));
Some(serde_json::json!({ "ahead": ahead, "behind": behind }))
} else { None };
Ok(serde_json::json!({
"branch": { "name": info.name, "oid": info.oid.to_string(), "is_head": info.is_head,
"is_remote": info.is_remote, "is_current": info.is_current, "upstream": info.upstream },
"ahead_behind": ahead_behind
}))
}
async fn git_branches_merged_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let branch = p.get("branch").and_then(|v| v.as_str()).ok_or("missing branch")?;
let into = p.get("into").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "main".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let is_merged = domain.branch_is_merged(branch, &into).map_err(|e| e.to_string())?;
let merge_base = domain.merge_base(&git::commit::types::CommitOid::new(branch), &git::commit::types::CommitOid::new(&into))
.map(|oid| oid.to_string()).ok();
Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base }))
}
async fn git_branch_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let local = p.get("local").and_then(|v| v.as_str()).ok_or("missing local")?;
let remote = p.get("remote").and_then(|v| v.as_str()).unwrap_or(local).to_string();
let domain = ctx.open_repo(project_name, repo_name).await?;
let diff = domain.branch_diff(local, &remote).map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "ahead": diff.ahead, "behind": diff.behind, "diverged": diff.diverged }))
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_branch_list", git_branch_list_exec);
register_fn!(registry, "git_branch_info", git_branch_info_exec);
register_fn!(registry, "git_branches_merged", git_branches_merged_exec);
register_fn!(registry, "git_branch_diff", git_branch_diff_exec);
}

View File

@ -0,0 +1,230 @@
//! Git commit-related tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
use chrono::TimeZone;
// --- Execution functions for each tool ---
async fn git_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let skip = p.get("skip").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commits = domain.commit_log(rev.as_deref(), skip, limit)
.map_err(|e| e.to_string())?;
// Flatten to simple JSON
let result: Vec<_> = commits.iter().map(|c| {
use chrono::TimeZone;
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
let time_str = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
let oid = c.oid.to_string();
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
serde_json::json!({
"oid": oid,
"short_oid": short_oid,
"message": c.message,
"summary": c.summary,
"author_name": c.author.name,
"author_email": c.author.email,
"author_time": time_str,
"committer_name": c.committer.name,
"committer_email": c.committer.email,
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
"tree_oid": c.tree_id.to_string()
})
}).collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let meta = if rev.len() >= 40 {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
};
let refs = domain.commit_refs(&meta.oid).map_err(|e| e.to_string())?;
use chrono::TimeZone;
let ts = meta.author.time_secs + (meta.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", meta.author.time_secs));
let oid = meta.oid.to_string();
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
Ok(serde_json::json!({
"commit": {
"oid": oid, "short_oid": short_oid, "message": meta.message, "summary": meta.summary,
"author_name": meta.author.name, "author_email": meta.author.email, "author_time": author_time,
"committer_name": meta.committer.name, "committer_email": meta.committer.email,
"parent_oids": meta.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
"tree_oid": meta.tree_id.to_string()
},
"refs": refs.into_iter().map(|r| serde_json::json!({ "name": r.name, "is_tag": r.is_tag })).collect::<Vec<_>>()
}))
}
async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let query = p.get("query").and_then(|v| v.as_str()).ok_or("missing query")?;
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commits = domain.commit_log(Some("HEAD"), 0, 100).map_err(|e| e.to_string())?;
let q = query.to_lowercase();
let result: Vec<_> = commits.iter()
.filter(|c| c.message.to_lowercase().contains(&q))
.take(limit)
.map(|c| flatten_commit(c))
.collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
use chrono::TimeZone;
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
let oid = c.oid.to_string();
serde_json::json!({
"oid": oid.clone(),
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
"message": c.message, "summary": c.summary,
"author_name": c.author.name, "author_email": c.author.email, "author_time": author_time,
"committer_name": c.committer.name, "committer_email": c.committer.email,
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
"tree_oid": c.tree_id.to_string()
})
}
async fn git_commit_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let meta = if rev.len() >= 40 {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
};
Ok(flatten_commit(&meta))
}
async fn git_graph_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commits = domain.commit_log(rev.as_deref(), 0, limit).map_err(|e| e.to_string())?;
let mut col_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let lines: Vec<_> = commits.iter().map(|m| {
let lane_index = *col_map.get(m.oid.as_str()).unwrap_or(&0);
let oid = m.oid.to_string();
let refs = match domain.commit_refs(&m.oid) {
Ok(refs) => refs.iter().map(|r| {
if r.is_tag { format!("tag: {}", r.name.trim_start_matches("refs/tags/")) }
else if r.is_remote { r.name.trim_start_matches("refs/remotes/").to_string() }
else { r.name.trim_start_matches("refs/heads/").to_string() }
}).collect::<Vec<_>>().join(", "),
Err(_) => String::new(),
};
for (i, p) in m.parent_ids.iter().enumerate() {
if i == 0 { col_map.insert(p.to_string(), lane_index); } else { col_map.remove(p.as_str()); }
}
let ts = m.author.time_secs + (m.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", m.author.time_secs));
serde_json::json!({
"oid": oid.clone(),
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
"refs": refs,
"short_message": m.summary,
"lane_index": lane_index,
"author_name": m.author.name,
"author_email": m.author.email,
"author_time": author_time,
"parent_oids": m.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>()
})
}).collect();
Ok(serde_json::to_value(lines).map_err(|e| e.to_string())?)
}
async fn git_reflog_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let ref_name = p.get("ref_name").and_then(|v| v.as_str()).map(|s| s.to_string());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let entries = domain.reflog_entries(ref_name.as_deref()).map_err(|e| e.to_string())?;
let result: Vec<_> = entries.iter()
.take(limit)
.map(|e| {
let ts = e.time_secs;
let time_str = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", ts));
serde_json::json!({
"oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(),
"committer_name": e.committer_name, "committer_email": e.committer_email,
"time": time_str, "message": e.message, "ref_name": e.ref_name
})
})
.collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
// --- Registration macro ---
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_log", git_log_exec);
register_fn!(registry, "git_show", git_show_exec);
register_fn!(registry, "git_search_commits", git_search_commits_exec);
register_fn!(registry, "git_commit_info", git_commit_info_exec);
register_fn!(registry, "git_graph", git_graph_exec);
register_fn!(registry, "git_reflog", git_reflog_exec);
}

View File

@ -0,0 +1,53 @@
//! Context wrapper for git tool handlers.
//!
//! Provides `GitToolCtx` which wraps `ToolContext` and adds git-domain operations.
use agent::ToolContext;
use git::GitDomain;
use models::projects::project;
use models::repos::repo;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
/// Wrapper around `ToolContext` providing git-domain operations for tool handlers.
#[derive(Clone)]
pub struct GitToolCtx {
pub ctx: ToolContext,
}
impl GitToolCtx {
pub fn new(ctx: ToolContext) -> Self {
Self { ctx }
}
/// Opens a git repository by project name and repo name.
pub async fn open_repo(&self, project_name: &str, repo_name: &str) -> Result<GitDomain, String> {
let db = self.ctx.db();
resolve_project_and_repo(db, project_name, repo_name)
.await
.and_then(|(_, path)| GitDomain::open(&path).map_err(|e| e.to_string()))
}
}
/// Free helper to resolve project_id + storage_path from names. Used by registry.
async fn resolve_project_and_repo(
db: &db::database::AppDatabase,
project_name: &str,
repo_name: &str,
) -> Result<(uuid::Uuid, String), String> {
let project = project::Entity::find()
.filter(project::Column::Name.eq(project_name))
.one(db)
.await
.map_err(|e| format!("DB error looking up project '{}': {}", project_name, e))?
.ok_or_else(|| format!("project '{}' not found", project_name))?;
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| format!("DB error looking up repo '{}/{}': {}", project_name, repo_name, e))?
.ok_or_else(|| format!("repo '{}/{}' not found", project_name, repo_name))?;
Ok((project.id, repo_model.storage_path))
}

View File

@ -0,0 +1,147 @@
//! Git diff and blame tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let base = p.get("base").and_then(|v| v.as_str()).map(|s| s.to_string());
let head = p.get("head").and_then(|v| v.as_str()).map(|s| s.to_string());
let paths = p.get("paths").and_then(|v| v.as_array()).map(|a| {
a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect::<Vec<_>>()
});
let domain = ctx.open_repo(project_name, repo_name).await?;
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
if rev.len() >= 40 {
Ok(git::commit::types::CommitOid::new(rev))
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
}
};
let base_oid = match &base {
Some(b) => Some(resolve(b)?),
None => None,
};
let head_oid = match &head {
Some(h) => Some(resolve(h)?),
None => None,
};
let opts = paths.map(|ps| {
let mut o = git::diff::types::DiffOptions::new();
for p in ps { o = o.pathspec(&p); }
Some(o)
}).flatten();
let result = match (&base_oid, &head_oid) {
(None, None) => {
let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?;
domain.diff_commit_to_workdir(&head_meta.oid, opts).map_err(|e| e.to_string())?
}
(Some(base), None) => {
domain.diff_commit_to_workdir(base, opts).map_err(|e| e.to_string())?
}
(Some(base), Some(head_oid_val)) => {
domain.diff_tree_to_tree(Some(base), Some(head_oid_val), opts).map_err(|e| e.to_string())?
}
(None, Some(_)) => {
return Err("base revision required when head is specified".into());
}
};
let files: Vec<_> = result.deltas.iter().map(|d| {
serde_json::json!({ "path": d.new_file.path, "status": format!("{:?}", d.status), "is_binary": d.new_file.is_binary })
}).collect();
Ok(serde_json::json!({
"stats": { "files_changed": result.stats.files_changed, "insertions": result.stats.insertions, "deletions": result.stats.deletions },
"files": files
}))
}
async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let base = p.get("base").and_then(|v| v.as_str()).ok_or("missing base")?;
let head = p.get("head").and_then(|v| v.as_str()).ok_or("missing head")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let stats = if base.len() >= 40 || head.len() >= 40 {
domain.diff_stats(&git::commit::types::CommitOid::new(base), &git::commit::types::CommitOid::new(head))
.map_err(|e| e.to_string())?
} else {
let b = domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid;
let h = domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid;
domain.diff_stats(&b, &h).map_err(|e| e.to_string())?
};
Ok(serde_json::json!({
"files_changed": stats.files_changed,
"insertions": stats.insertions,
"deletions": stats.deletions
}))
}
async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
let from_line = p.get("from_line").and_then(|v| v.as_u64().map(|n| n as u32));
let to_line = p.get("to_line").and_then(|v| v.as_u64().map(|n| n as u32));
let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
};
use git::blame::ops::BlameOptions;
let mut bopts = BlameOptions::new();
if let Some(fl) = from_line { bopts = bopts.min_line(fl as usize); }
if let Some(tl) = to_line { bopts = bopts.max_line(tl as usize); }
let hunks = domain.blame_file(&oid, path, Some(bopts)).map_err(|e| e.to_string())?;
let result: Vec<_> = hunks.iter().map(|h| {
let oid = h.commit_oid.to_string();
serde_json::json!({
"commit_oid": oid.clone(),
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
"final_start_line": h.final_start_line,
"final_lines": h.final_lines,
"orig_start_line": h.orig_start_line,
"orig_path": h.orig_path,
"boundary": h.boundary
})
}).collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_diff", git_diff_exec);
register_fn!(registry, "git_diff_stats", git_diff_stats_exec);
register_fn!(registry, "git_blame", git_blame_exec);
}

View File

@ -0,0 +1,21 @@
//! Git tools for AI agent function calling.
//!
//! Each module defines async exec functions + a `register_git_tools()` call.
//! All tools take `project_name` + `repo_name` as required params.
pub mod branch;
pub mod commit;
pub mod ctx;
pub mod diff;
pub mod tag;
pub mod tree;
pub mod types;
/// Batch-register all git tools into a ToolRegistry.
pub fn register_all(registry: &mut agent::ToolRegistry) {
commit::register_git_tools(registry);
branch::register_git_tools(registry);
diff::register_git_tools(registry);
tree::register_git_tools(registry);
tag::register_git_tools(registry);
}

View File

@ -0,0 +1,73 @@
//! Git tag tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
async fn git_tag_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let pattern = p.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let all_tags = domain.tag_list().map_err(|e| e.to_string())?;
let result: Vec<_> = match pattern {
Some(ref pat) => {
let pat_lower = pat.to_lowercase();
let has_wildcard = pat.contains('*');
all_tags.iter()
.filter(|t| {
let n = t.name.to_lowercase();
if has_wildcard { n.contains(&pat_lower.replace('*', "")) }
else { n.contains(&pat_lower) }
})
.map(|t| tag_to_json(t))
.collect()
}
None => all_tags.into_iter().map(|t| tag_to_json(&t)).collect(),
};
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
fn tag_to_json(t: &git::tags::types::TagInfo) -> serde_json::Value {
serde_json::json!({
"name": t.name,
"oid": t.oid.to_string(),
"target": t.target.to_string(),
"is_annotated": t.is_annotated,
"message": t.message.clone(),
"tagger_name": t.tagger.clone(),
"tagger_email": t.tagger_email.clone()
})
}
async fn git_tag_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let info = domain.tag_get(name).map_err(|e| e.to_string())?;
Ok(tag_to_json(&info))
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_tag_list", git_tag_list_exec);
register_fn!(registry, "git_tag_info", git_tag_info_exec);
}

View File

@ -0,0 +1,126 @@
//! Git tree and file tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
use base64::Engine;
async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
};
let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?;
let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?;
let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
let (display_content, is_binary) = if blob_info.is_binary {
(base64::engine::general_purpose::STANDARD.encode(&content.content), true)
} else {
(String::from_utf8_lossy(&content.content).to_string(), false)
};
Ok(serde_json::json!({
"path": path,
"oid": entry.oid.to_string(),
"size": blob_info.size,
"content": display_content,
"is_binary": is_binary
}))
}
async fn git_tree_ls_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let dir_path = p.get("path").and_then(|v| v.as_str()).map(|s| s.to_string());
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
};
let entries = match dir_path {
Some(ref dp) => {
let entry = domain.tree_entry_by_path(&commit_oid, dp).map_err(|e| e.to_string())?;
domain.tree_list(&entry.oid).map_err(|e| e.to_string())?
}
None => domain.tree_list(&commit_oid).map_err(|e| e.to_string())?,
};
let result: Vec<_> = entries.iter().map(|e| {
serde_json::json!({
"name": e.name,
"oid": e.oid.to_string(),
"kind": e.kind,
"is_binary": e.is_binary
})
}).collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
async fn git_file_history_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commits = domain.commit_log(Some("HEAD"), 0, 500).map_err(|e| e.to_string())?;
let result: Vec<_> = commits.iter()
.filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok())
.take(limit)
.map(|c| flatten_commit(c))
.collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
use chrono::TimeZone;
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
let oid = c.oid.to_string();
serde_json::json!({
"oid": oid.clone(),
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
"message": c.message, "summary": c.summary,
"author_name": c.author.name, "author_email": c.author.email, "author_time": author_time,
"committer_name": c.committer.name, "committer_email": c.committer.email,
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
"tree_oid": c.tree_id.to_string()
})
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_file_content", git_file_content_exec);
register_fn!(registry, "git_tree_ls", git_tree_ls_exec);
register_fn!(registry, "git_file_history", git_file_history_exec);
}

View File

@ -0,0 +1,411 @@
//! Flat, JSON-friendly output types for git tools.
//!
//! These types convert from internal `git` crate types to clean, flat structures
//! suitable for JSON serialization (function call responses).
use base64::Engine;
use chrono::TimeZone;
use git::commit::types::{CommitMeta, CommitReflogEntry};
use git::diff::types::{DiffDelta, DiffStats};
use git::tree::types::TreeEntry;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Parameter structs used by all tool modules
// ---------------------------------------------------------------------------
#[derive(serde::Deserialize)]
pub struct RevQuery { pub rev: String }
#[derive(serde::Deserialize)]
pub struct SearchCommits { pub query: String, #[serde(default = "dl")] pub limit: u32 }
fn dl() -> u32 { 20 }
#[derive(serde::Deserialize)]
pub struct GraphParams { #[serde(default)] pub rev: Option<String>, #[serde(default = "dl")] pub limit: u32 }
#[derive(serde::Deserialize)]
pub struct ReflogParams { #[serde(default)] pub ref_name: Option<String>, #[serde(default = "reflog_def")] pub limit: u32 }
fn reflog_def() -> u32 { 50 }
#[derive(serde::Deserialize)]
pub struct SingleBranch { pub name: String }
#[derive(serde::Deserialize)]
pub struct BranchesMerged { pub branch: String, #[serde(default)] pub into: Option<String> }
#[derive(serde::Deserialize)]
pub struct BranchDiffP { pub local: String, #[serde(default)] pub remote: Option<String> }
#[derive(serde::Deserialize)]
pub struct DiffP { #[serde(default)] pub base: Option<String>, #[serde(default)] pub head: Option<String>, #[serde(default)] pub paths: Option<Vec<String>> }
#[derive(serde::Deserialize)]
pub struct DiffStatsP { pub base: String, pub head: String }
#[derive(serde::Deserialize)]
pub struct BlameP { pub path: String, #[serde(default)] pub rev: Option<String>, #[serde(default)] pub from_line: Option<u32>, #[serde(default)] pub to_line: Option<u32> }
#[derive(serde::Deserialize)]
pub struct FileContentP { pub path: String, #[serde(default)] pub rev: Option<String> }
#[derive(serde::Deserialize)]
pub struct TreeLsP { #[serde(default)] pub path: Option<String>, #[serde(default)] pub rev: Option<String> }
#[derive(serde::Deserialize)]
pub struct FileHistoryP { pub path: String, #[serde(default = "dl")] pub limit: u32 }
#[derive(serde::Deserialize)]
pub struct TagListP { #[serde(default)] pub pattern: Option<String> }
#[derive(serde::Deserialize)]
pub struct SingleTagP { pub name: String }
#[derive(serde::Deserialize)]
pub struct GitLogP { #[serde(default)] pub rev: Option<String>, #[serde(default = "dl")] pub limit: u32, #[serde(default)] pub skip: u32 }
// ---------------------------------------------------------------------------
// Commit types
// ---------------------------------------------------------------------------
/// Flat commit information for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitInfo {
pub oid: String,
pub short_oid: String,
pub message: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub author_time: String,
pub committer_name: String,
pub committer_email: String,
pub parent_oids: Vec<String>,
pub tree_oid: String,
}
impl CommitInfo {
pub fn from_meta(meta: &CommitMeta) -> Self {
let ts = meta.author.time_secs;
let offset = meta.author.offset_minutes;
let author_time = format_rfc3339(ts, offset);
Self {
oid: meta.oid.to_string(),
short_oid: meta.oid.to_string().get(..7).unwrap_or(&meta.oid.to_string()).to_string(),
message: meta.message.clone(),
summary: meta.summary.clone(),
author_name: meta.author.name.clone(),
author_email: meta.author.email.clone(),
author_time,
committer_name: meta.committer.name.clone(),
committer_email: meta.committer.email.clone(),
parent_oids: meta.parent_ids.iter().map(|p| p.to_string()).collect(),
tree_oid: meta.tree_id.to_string(),
}
}
}
/// Commit reflog entry for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReflogEntryInfo {
pub oid_new: String,
pub oid_old: String,
pub committer_name: String,
pub committer_email: String,
pub time: String,
pub message: Option<String>,
pub ref_name: String,
}
impl ReflogEntryInfo {
pub fn from_entry(entry: &CommitReflogEntry) -> Self {
let ts = entry.time_secs;
let time = format_rfc3339(ts, 0);
Self {
oid_new: entry.oid_new.to_string(),
oid_old: entry.oid_old.to_string(),
committer_name: entry.committer_name.clone(),
committer_email: entry.committer_email.clone(),
time,
message: entry.message.clone(),
ref_name: entry.ref_name.clone(),
}
}
}
// ---------------------------------------------------------------------------
// Branch types
// ---------------------------------------------------------------------------
/// Flat branch info for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchInfoOut {
pub name: String,
pub oid: String,
pub short_oid: String,
pub is_head: bool,
pub is_remote: bool,
pub is_current: bool,
pub upstream: Option<String>,
}
impl From<&git::branch::types::BranchInfo> for BranchInfoOut {
fn from(b: &git::branch::types::BranchInfo) -> Self {
let oid = b.oid.to_string();
Self {
name: b.name.clone(),
oid: oid.clone(),
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
is_head: b.is_head,
is_remote: b.is_remote,
is_current: b.is_current,
upstream: b.upstream.clone(),
}
}
}
/// Branch diff (ahead/behind) for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchDiffOut {
pub ahead: usize,
pub behind: usize,
pub diverged: bool,
}
impl From<&git::branch::types::BranchDiff> for BranchDiffOut {
fn from(d: &git::branch::types::BranchDiff) -> Self {
Self {
ahead: d.ahead,
behind: d.behind,
diverged: d.diverged,
}
}
}
// ---------------------------------------------------------------------------
// Diff types
// ---------------------------------------------------------------------------
/// Diff statistics for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffStatsOut {
pub files_changed: u32,
pub insertions: u32,
pub deletions: u32,
}
impl From<&DiffStats> for DiffStatsOut {
fn from(s: &DiffStats) -> Self {
Self {
files_changed: s.files_changed as u32,
insertions: s.insertions as u32,
deletions: s.deletions as u32,
}
}
}
/// A single diff file change for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffFileOut {
pub path: Option<String>,
pub status: String,
pub is_binary: bool,
pub size: u64,
}
impl DiffFileOut {
pub fn from_delta(delta: &DiffDelta) -> Self {
Self {
path: delta.new_file.path.clone(),
status: format!("{:?}", delta.status),
is_binary: delta.new_file.is_binary,
size: delta.new_file.size,
}
}
}
/// Diff summary (files + stats) for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffOut {
pub stats: DiffStatsOut,
pub files: Vec<DiffFileOut>,
}
impl DiffOut {
pub fn from_result(result: &git::diff::types::DiffResult) -> Self {
let stats = DiffStatsOut::from(&result.stats);
let files = result.deltas.iter().map(DiffFileOut::from_delta).collect();
Self { stats, files }
}
}
/// Blame hunk for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlameHunkOut {
pub commit_oid: String,
pub short_oid: String,
pub final_start_line: u32,
pub final_lines: u32,
pub orig_start_line: u32,
pub orig_path: Option<String>,
pub boundary: bool,
}
impl From<&git::commit::types::CommitBlameHunk> for BlameHunkOut {
fn from(h: &git::commit::types::CommitBlameHunk) -> Self {
let oid = h.commit_oid.to_string();
Self {
commit_oid: oid.clone(),
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
final_start_line: h.final_start_line,
final_lines: h.final_lines,
orig_start_line: h.orig_start_line,
orig_path: h.orig_path.clone(),
boundary: h.boundary,
}
}
}
// ---------------------------------------------------------------------------
// Tree types
// ---------------------------------------------------------------------------
/// Directory entry for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeLsEntry {
pub name: String,
pub oid: String,
pub kind: String,
pub is_binary: bool,
}
impl From<&TreeEntry> for TreeLsEntry {
fn from(entry: &TreeEntry) -> Self {
Self {
name: entry.name.clone(),
oid: entry.oid.to_string(),
kind: entry.kind.clone(),
is_binary: entry.is_binary,
}
}
}
/// File content for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileContentOut {
pub path: String,
pub oid: String,
pub size: u64,
pub content: String,
pub is_binary: bool,
}
impl FileContentOut {
pub fn from_blob(
path: String,
oid: &git::commit::types::CommitOid,
content: &[u8],
is_binary: bool,
) -> Self {
let (display_content, size) = if is_binary {
(
base64::engine::general_purpose::STANDARD.encode(content),
content.len() as u64,
)
} else {
(
String::from_utf8_lossy(content).to_string(),
content.len() as u64,
)
};
Self {
path,
oid: oid.to_string(),
size,
content: display_content,
is_binary,
}
}
}
// ---------------------------------------------------------------------------
// Tag types
// ---------------------------------------------------------------------------
/// Tag info for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagInfoOut {
pub name: String,
pub oid: String,
pub target: String,
pub is_annotated: bool,
pub message: Option<String>,
pub tagger_name: Option<String>,
pub tagger_email: Option<String>,
}
impl From<&git::tags::types::TagInfo> for TagInfoOut {
fn from(t: &git::tags::types::TagInfo) -> Self {
Self {
name: t.name.clone(),
oid: t.oid.to_string(),
target: t.target.to_string(),
is_annotated: t.is_annotated,
message: t.message.clone(),
tagger_name: t.tagger.clone(),
tagger_email: t.tagger_email.clone(),
}
}
}
// ---------------------------------------------------------------------------
// Graph types
// ---------------------------------------------------------------------------
/// Commit graph line for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphLineOut {
pub oid: String,
pub short_oid: String,
pub refs: String,
pub short_message: String,
pub lane_index: usize,
pub author_name: String,
pub author_email: String,
pub author_time: String,
pub parent_oids: Vec<String>,
}
impl From<&git::commit::graph::CommitGraphLine> for GraphLineOut {
fn from(line: &git::commit::graph::CommitGraphLine) -> Self {
let oid = line.oid.to_string();
let ts = line.meta.author.time_secs;
let offset = line.meta.author.offset_minutes;
Self {
oid: oid.clone(),
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
refs: line.refs.clone(),
short_message: line.short_message.clone(),
lane_index: line.lane_index,
author_name: line.meta.author.name.clone(),
author_email: line.meta.author.email.clone(),
author_time: format_rfc3339(ts, offset),
parent_oids: line.meta.parent_ids.iter().map(|p| p.to_string()).collect(),
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn format_rfc3339(time_secs: i64, offset_minutes: i32) -> String {
let secs = time_secs + (offset_minutes as i64 * 60);
chrono::Utc
.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| format!("{}", time_secs))
}

View File

@ -1,6 +1,8 @@
use std::sync::Arc;
use ::agent::chat::ChatService;
use ::agent::task::service::TaskService;
use async_openai::config::OpenAIConfig;
use avatar::AppAvatar;
use config::AppConfig;
use db::cache::AppCache;
@ -132,13 +134,36 @@ impl AppService {
.and_then(|urls| urls.first().cloned())
.unwrap_or_else(|| "redis://127.0.0.1:6379".to_string());
// Build ChatService if AI is configured; otherwise AI chat is disabled (graceful degradation)
let chat_service: Option<Arc<ChatService>> = match (
config.ai_api_key(),
config.ai_basic_url(),
) {
(Ok(api_key), Ok(base_url)) => {
slog::info!(logs, "AI chat enabled — connecting to {}", base_url);
let cfg = OpenAIConfig::new()
.with_api_key(&api_key)
.with_api_base(&base_url);
let client = async_openai::Client::with_config(cfg);
Some(Arc::new(ChatService::new(client)))
}
(Err(e), _) => {
slog::warn!(logs, "AI chat disabled — {}", e);
None
}
(_, Err(e)) => {
slog::warn!(logs, "AI chat disabled — {}", e);
None
}
};
let room = RoomService::new(
db.clone(),
cache.clone(),
message_producer.clone(),
room_manager,
redis_url,
None,
chat_service,
Some(task_service.clone()),
logs.clone(),
None,
@ -200,6 +225,7 @@ pub mod agent;
pub mod auth;
pub mod error;
pub mod git;
pub mod git_tools;
pub mod issue;
pub mod project;
pub mod pull_request;

View File

@ -1,8 +1,9 @@
use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Utc};
use futures::future::join_all;
use models::projects::{
MemberRole, project_audit_log, project_member_invitations, project_members,
project, MemberRole, project_audit_log, project_member_invitations, project_members,
};
use models::users::{user, user_email};
use sea_orm::*;
@ -13,8 +14,10 @@ use uuid::Uuid;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct InvitationResponse {
pub project_uid: Uuid,
pub project_name: String,
pub user_uid: Uuid,
pub invited_by: Uuid,
pub invited_by_username: Option<String>,
pub scope: String,
pub accepted: bool,
pub accepted_at: Option<DateTime<Utc>>,
@ -31,18 +34,36 @@ pub struct InvitationListResponse {
pub per_page: u64,
}
impl From<project_member_invitations::Model> for InvitationResponse {
fn from(invitation: project_member_invitations::Model) -> Self {
impl InvitationResponse {
pub async fn from_model(
inv: project_member_invitations::Model,
db: &DatabaseConnection,
) -> Self {
let project_name = project::Entity::find_by_id(inv.project)
.one(db)
.await
.ok()
.flatten()
.map(|p| p.name)
.unwrap_or_default();
let invited_by_username = user::Entity::find_by_id(inv.invited_by)
.one(db)
.await
.ok()
.flatten()
.map(|u| u.username);
InvitationResponse {
project_uid: invitation.project,
user_uid: invitation.user,
invited_by: invitation.invited_by,
scope: invitation.scope,
accepted: invitation.accepted,
accepted_at: invitation.accepted_at,
rejected: invitation.rejected,
rejected_at: invitation.rejected_at,
created_at: invitation.created_at,
project_uid: inv.project,
project_name,
user_uid: inv.user,
invited_by: inv.invited_by,
invited_by_username,
scope: inv.scope,
accepted: inv.accepted,
accepted_at: inv.accepted_at,
rejected: inv.rejected,
rejected_at: inv.rejected_at,
created_at: inv.created_at,
}
}
}
@ -82,9 +103,31 @@ impl AppService {
.count(&self.db)
.await?;
let project_name = project.name.clone();
let inviter_ids: Vec<Uuid> = invitations.iter().map(|i| i.invited_by).collect();
let inviters: std::collections::HashMap<Uuid, Option<String>> = user::Entity::find()
.filter(user::Column::Uid.is_in(inviter_ids))
.all(&self.db)
.await?
.into_iter()
.map(|u| (u.uid, Some(u.username)))
.collect();
let invitations = invitations
.into_iter()
.map(InvitationResponse::from)
.map(|inv| InvitationResponse {
project_uid: inv.project,
project_name: project_name.clone(),
user_uid: inv.user,
invited_by: inv.invited_by,
invited_by_username: inviters.get(&inv.invited_by).cloned().flatten(),
scope: inv.scope,
accepted: inv.accepted,
accepted_at: inv.accepted_at,
rejected: inv.rejected,
rejected_at: inv.rejected_at,
created_at: inv.created_at,
})
.collect();
Ok(InvitationListResponse {
@ -122,9 +165,12 @@ impl AppService {
.count(&self.db)
.await?;
let invitations = invitations
.into_iter()
.map(InvitationResponse::from)
let invitations = join_all(
invitations
.into_iter()
.map(|inv| InvitationResponse::from_model(inv, self.db.writer())),
)
.await;
.collect();
Ok(InvitationListResponse {

View File

@ -36,6 +36,24 @@ pub struct PendingInvitationInfo {
pub expires_at: Option<chrono::DateTime<Utc>>,
}
/// Invitation received by the current user (workspace invitation for self).
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MyWorkspaceInvitation {
pub workspace_id: Uuid,
pub workspace_slug: String,
pub workspace_name: String,
pub role: String,
pub invited_by_username: Option<String>,
pub invited_at: chrono::DateTime<Utc>,
pub expires_at: Option<chrono::DateTime<Utc>>,
}
/// Request body for accepting workspace invitation by slug.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceAcceptBySlugParams {
pub slug: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkspaceMembersResponse {
pub members: Vec<WorkspaceMemberInfo>,
@ -395,6 +413,105 @@ impl AppService {
self.utils_find_workspace_by_id(ws_id).await
}
/// List all pending workspace invitations for the current user (where user is invitee).
pub async fn workspace_my_pending_invitations(
&self,
ctx: &Session,
) -> Result<Vec<MyWorkspaceInvitation>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let pending = workspace_membership::Entity::find()
.filter(workspace_membership::Column::UserId.eq(user_uid))
.filter(workspace_membership::Column::Status.eq("pending"))
.order_by_desc(workspace_membership::Column::JoinedAt)
.all(&self.db)
.await?;
if pending.is_empty() {
return Ok(vec![]);
}
// Fetch workspace info
let ws_ids: Vec<Uuid> = pending.iter().map(|m| m.workspace_id).collect();
let workspaces: std::collections::HashMap<Uuid, workspace::Model> = workspace::Entity::find()
.filter(workspace::Column::Id.is_in(ws_ids.clone()))
.all(&self.db)
.await?
.into_iter()
.map(|w| (w.id, w))
.collect();
// Fetch inviter usernames
let inviter_ids: Vec<Uuid> = pending.iter().filter_map(|m| m.invited_by).collect();
let inviters: std::collections::HashMap<Uuid, String> = if !inviter_ids.is_empty() {
user::Entity::find()
.filter(user::Column::Uid.is_in(inviter_ids))
.all(&self.db)
.await?
.into_iter()
.map(|u| (u.uid, u.username))
.collect()
} else {
std::collections::HashMap::new()
};
let invitations: Vec<MyWorkspaceInvitation> = pending
.into_iter()
.filter_map(|m| {
let ws = workspaces.get(&m.workspace_id)?;
let invited_by_username = m.invited_by.and_then(|uid| inviters.get(&uid).cloned());
Some(MyWorkspaceInvitation {
workspace_id: m.workspace_id,
workspace_slug: ws.slug.clone(),
workspace_name: ws.name.clone(),
role: m.role,
invited_by_username,
invited_at: m.joined_at,
expires_at: m.invite_expires_at,
})
})
.collect();
Ok(invitations)
}
/// Accept a workspace invitation by slug (for the current user).
pub async fn workspace_accept_invitation_by_slug(
&self,
ctx: &Session,
params: WorkspaceAcceptBySlugParams,
) -> Result<workspace::Model, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.utils_find_workspace_by_slug(params.slug.clone()).await?;
let membership = workspace_membership::Entity::find()
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
.filter(workspace_membership::Column::UserId.eq(user_uid))
.one(&self.db)
.await?
.ok_or(AppError::NotFound(
"No pending invitation found for this workspace".to_string(),
))?;
if membership.status == "active" {
return Err(AppError::WorkspaceInviteAlreadyAccepted);
}
if let Some(expires_at) = membership.invite_expires_at {
if Utc::now() > expires_at {
return Err(AppError::WorkspaceInviteExpired);
}
}
let mut m: workspace_membership::ActiveModel = membership.into();
m.status = Set("active".to_string());
m.invite_token = Set(None);
m.invite_expires_at = Set(None);
m.update(&self.db).await?;
Ok(ws)
}
pub async fn workspace_remove_member(
&self,
ctx: &Session,

View File

@ -57,6 +57,8 @@ import {SettingsPreferences} from '@/app/settings/preferences';
import {SettingsActivity} from '@/app/settings/activity';
import NotifyLayout from '@/app/notify/layout';
import NotifyPage from '@/app/notify/page';
import InvitationsLayout from '@/app/invitations/layout';
import InvitationsPage from '@/app/invitations/page';
import LandingPage from '@/app/page';
import SearchPage from '@/app/search/page';
import PricingPage from '@/app/pricing/page';
@ -143,6 +145,10 @@ function App() {
<Route index element={<NotifyPage/>}/>
</Route>
<Route path="/invitations" element={<InvitationsLayout/>}>
<Route index element={<InvitationsPage/>}/>
</Route>
<Route path="/repository/:namespace/:repoName" element={<RepoLayout/>}>
<Route index element={<RepoOverview/>}/>
<Route path="branches" element={<RepoBranches/>}/>

View File

@ -0,0 +1,94 @@
import { Bell, ChevronLeft, UserPlus } from "lucide-react";
import { Outlet, useNavigate } from "react-router-dom";
import { SidebarSystem } from "@/components/layout/sidebar-system";
import { SidebarUser } from "@/components/layout/sidebar-user";
import { useSidebarCollapse } from "@/hooks/use-sidebar-collapse";
import { cn } from "@/lib/utils";
import { useLocation } from "react-router-dom";
function InvitationsSidebar() {
const navigate = useNavigate();
const location = useLocation();
const { collapsed, setCollapsed } = useSidebarCollapse();
return (
<aside
className={cn(
"hidden md:flex h-full flex-col border-r bg-background transition-all duration-200",
collapsed ? "w-16" : "w-64",
)}
>
<div
className={cn("border-b h-12 flex items-center", collapsed ? "justify-center" : "justify-between")}
>
{!collapsed && (
<div className="flex min-w-0 items-center gap-3 overflow-hidden px-3">
<div className="bg-muted rounded-full w-8 h-8 flex items-center justify-center">
<UserPlus className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
My Invitations
</div>
</div>
)}
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-muted cursor-pointer bg-transparent border-0"
onClick={() => setCollapsed(!collapsed)}
>
<ChevronLeft className="h-4 w-4" />
</button>
</div>
<nav className="flex-1 p-2">
<div className="space-y-1">
<button
type="button"
onClick={() => navigate("/invitations")}
className={cn(
"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",
location.pathname === "/invitations" && "bg-primary/10 text-primary",
collapsed ? "justify-center px-0" : "justify-start px-2",
)}
>
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<UserPlus className="h-4 w-4" />
</span>
{!collapsed && "Invitations"}
</button>
<button
type="button"
onClick={() => navigate("/notify")}
className={cn(
"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",
location.pathname === "/notify" && "bg-primary/10 text-primary",
collapsed ? "justify-center px-0" : "justify-start px-2",
)}
>
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<Bell className="h-4 w-4" />
</span>
{!collapsed && "Notifications"}
</button>
</div>
</nav>
<div className="border-t flex flex-col justify-end">
<SidebarSystem collapsed={collapsed} />
<SidebarUser collapsed={collapsed} />
</div>
</aside>
);
}
export default function InvitationsLayout() {
return (
<div className="flex h-screen w-full bg-background">
<InvitationsSidebar />
<main className="flex-1 h-screen overflow-auto">
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,316 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Building2,
Check,
FolderKanban,
Loader2,
UserPlus,
X,
} from "lucide-react";
import {
projectMyInvitations,
projectAcceptInvitation,
projectRejectInvitation,
workspaceMyInvitations,
workspaceAcceptInvitationBySlug,
} from "@/client";
import type { InvitationResponse, MyWorkspaceInvitation } from "@/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { getApiErrorMessage } from "@/lib/api-error";
import { useNavigate } from "react-router-dom";
function formatTime(dateStr: string): string {
const d = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString();
}
type UnifiedInvitation =
| ({ type: "project" } & InvitationResponse)
| ({ type: "workspace" } & MyWorkspaceInvitation);
function ProjectInvitationItem({
inv,
onAccept,
onReject,
isAccepting,
isRejecting,
}: {
inv: InvitationResponse;
onAccept: () => void;
onReject: () => void;
isAccepting: boolean;
isRejecting: boolean;
}) {
return (
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-purple-500/10 text-purple-600 border-purple-500/20 flex items-center justify-center">
<FolderKanban className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold truncate">Project Invitation</p>
<Badge variant="outline" className="text-xs bg-purple-500/10 text-purple-600 border-purple-500/20">
{inv.scope}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
You&apos;ve been invited to join{" "}
<span className="font-medium text-foreground">{inv.project_name}</span>
</p>
<div className="flex items-center gap-2 mt-1.5">
{inv.invited_by_username && (
<span className="text-xs text-muted-foreground">
by {inv.invited_by_username}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(inv.created_at)}
</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={onReject}
disabled={isAccepting || isRejecting}
>
{isRejecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Decline
</Button>
<Button
size="sm"
variant="default"
className="h-8 text-xs gap-1"
onClick={onAccept}
disabled={isAccepting || isRejecting}
>
{isAccepting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Accept
</Button>
</div>
</div>
</div>
</div>
);
}
function WorkspaceInvitationItem({
inv,
onAccept,
isAccepting,
}: {
inv: MyWorkspaceInvitation;
onAccept: () => void;
isAccepting: boolean;
}) {
return (
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-blue-500/10 text-blue-600 border-blue-500/20 flex items-center justify-center">
<Building2 className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold truncate">Workspace Invitation</p>
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-600 border-blue-500/20">
{inv.role}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
You&apos;ve been invited to join workspace{" "}
<span className="font-medium text-foreground">{inv.workspace_name}</span>
</p>
<div className="flex items-center gap-2 mt-1.5">
{inv.invited_by_username && (
<span className="text-xs text-muted-foreground">
by {inv.invited_by_username}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(inv.invited_at)}
</span>
</div>
</div>
<div className="flex-shrink-0">
<Button
size="sm"
variant="default"
className="h-8 text-xs gap-1"
onClick={onAccept}
disabled={isAccepting}
>
{isAccepting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Accept
</Button>
</div>
</div>
</div>
</div>
);
}
export default function InvitationsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: projectData, isLoading: projectLoading } = useQuery({
queryKey: ["projectInvitations"],
queryFn: async () => {
const resp = await projectMyInvitations();
return resp.data?.data ?? null;
},
});
const { data: workspaceData, isLoading: workspaceLoading } = useQuery({
queryKey: ["workspaceInvitations"],
queryFn: async () => {
const resp = await workspaceMyInvitations();
return resp.data?.data ?? [];
},
});
const acceptProjectMutation = useMutation({
mutationFn: async (projectName: string) => {
await projectAcceptInvitation({ path: { project_name: projectName } });
},
onSuccess: (_data, projectName) => {
toast.success(`You've joined project: ${projectName}`);
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
navigate(`/project/${projectName}`);
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
},
});
const rejectProjectMutation = useMutation({
mutationFn: async (projectName: string) => {
await projectRejectInvitation({ path: { project_name: projectName } });
},
onSuccess: (_data, projectName) => {
toast.success(`Invitation to ${projectName} declined`);
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to decline invitation"));
},
});
const acceptWorkspaceMutation = useMutation({
mutationFn: async (slug: string) => {
await workspaceAcceptInvitationBySlug({
body: { slug },
});
},
onSuccess: () => {
toast.success("You've joined the workspace");
queryClient.invalidateQueries({ queryKey: ["workspaceInvitations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
},
});
const projectInvitations: InvitationResponse[] = projectData?.invitations ?? [];
const workspaceInvitations: MyWorkspaceInvitation[] = workspaceData ?? [];
const total = projectInvitations.length + workspaceInvitations.length;
const unified: UnifiedInvitation[] = [
...projectInvitations.map((inv) => ({ type: "project" as const, ...inv })),
...workspaceInvitations.map((inv) => ({ type: "workspace" as const, ...inv })),
].sort((a, b) => {
const aTime = a.type === "project" ? a.created_at : a.invited_at;
const bTime = b.type === "project" ? b.created_at : b.invited_at;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
const isLoading = projectLoading || workspaceLoading;
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<UserPlus className="h-5 w-5" />
My Invitations
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{total > 0
? `${total} pending invitation${total !== 1 ? "s" : ""}`
: "No pending invitations"}
</p>
</div>
</div>
<div className="border rounded-lg bg-card overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : unified.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<UserPlus className="h-10 w-10 mb-3 opacity-40" />
<p className="font-medium">No pending invitations</p>
<p className="text-sm mt-1">
Project and workspace invitations will appear here.
</p>
</div>
) : (
unified.map((inv) => {
if (inv.type === "project") {
return (
<ProjectInvitationItem
key={`project-${inv.project_uid}`}
inv={inv}
onAccept={() => acceptProjectMutation.mutate(inv.project_name)}
onReject={() => rejectProjectMutation.mutate(inv.project_name)}
isAccepting={acceptProjectMutation.isPending}
isRejecting={rejectProjectMutation.isPending}
/>
);
} else {
return (
<WorkspaceInvitationItem
key={`workspace-${inv.workspace_id}`}
inv={inv}
onAccept={() => acceptWorkspaceMutation.mutate(inv.workspace_slug)}
isAccepting={acceptWorkspaceMutation.isPending}
/>
);
}
})
)}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { Bell, ChevronLeft } from "lucide-react";
import { Bell, ChevronLeft, UserPlus } from "lucide-react";
import { Outlet, useNavigate } from "react-router-dom";
import { SidebarSystem } from "@/components/layout/sidebar-system";
import { SidebarUser } from "@/components/layout/sidebar-user";
@ -43,6 +43,20 @@ function NotifySidebar() {
<nav className="flex-1 p-2">
<div className="space-y-1">
<button
type="button"
onClick={() => navigate("/invitations")}
className={cn(
"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",
location.pathname === "/invitations" && "bg-primary/10 text-primary",
collapsed ? "justify-center px-0" : "justify-start px-2",
)}
>
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<UserPlus className="h-4 w-4" />
</span>
{!collapsed && "Invitations"}
</button>
<button
type="button"
onClick={() => navigate("/notify")}
@ -55,7 +69,7 @@ function NotifySidebar() {
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
<Bell className="h-4 w-4" />
</span>
{!collapsed && "All Notifications"}
{!collapsed && "Notifications"}
</button>
</div>
</nav>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3310,8 +3310,10 @@ export type InvitationListResponse = {
export type InvitationResponse = {
project_uid: string;
project_name: string;
user_uid: string;
invited_by: string;
invited_by_username?: string | null;
scope: string;
accepted: boolean;
accepted_at?: string | null;
@ -3814,6 +3816,16 @@ export type PendingInvitationInfo = {
expires_at?: string | null;
};
export type MyWorkspaceInvitation = {
workspace_id: string;
workspace_slug: string;
workspace_name: string;
role: string;
invited_by_username?: string | null;
invited_at: string;
expires_at?: string | null;
};
export type PrCommitResponse = {
oid: string;
short_oid: string;
@ -5127,6 +5139,10 @@ export type WorkspaceInviteParams = {
role?: string | null;
};
export type WorkspaceAcceptBySlugParams = {
slug: string;
};
export type WorkspaceListItem = {
id: string;
slug: string;
@ -18237,6 +18253,66 @@ export type WorkspaceAcceptInvitationResponses = {
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
export type WorkspaceMyInvitationsData = {
body?: never;
path?: never;
query?: never;
url: '/api/workspaces/me/invitations';
};
export type WorkspaceMyInvitationsErrors = {
/**
* Unauthorized
*/
401: unknown;
};
export type ApiResponseVecMyWorkspaceInvitation = {
code: number;
message: string;
data?: Array<MyWorkspaceInvitation>;
};
export type WorkspaceMyInvitationsResponses = {
/**
* List my workspace invitations
*/
200: ApiResponseVecMyWorkspaceInvitation;
};
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
export type WorkspaceAcceptInvitationBySlugData = {
body: WorkspaceAcceptBySlugParams;
path?: never;
query?: never;
url: '/api/workspaces/invitations/accept-by-slug';
};
export type WorkspaceAcceptInvitationBySlugErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Invitation not found
*/
404: unknown;
/**
* Already accepted
*/
409: unknown;
};
export type WorkspaceAcceptInvitationBySlugResponses = {
/**
* Accept invitation
*/
200: ApiResponseWorkspaceInfoResponse;
};
export type WorkspaceAcceptInvitationBySlugResponse = WorkspaceAcceptInvitationBySlugResponses[keyof WorkspaceAcceptInvitationBySlugResponses];
export type WorkspaceListData = {
body?: never;
path?: never;

View File

@ -10,7 +10,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {useUser} from '@/contexts';
import {cn} from '@/lib/utils';
import {Mail} from 'lucide-react';
import {Mail, UserPlus} from 'lucide-react';
import {useNavigate} from 'react-router-dom';
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';
@ -21,6 +21,14 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) {
return (
<div className="w-full mb-2">
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/invitations')}>
<span className="flex h-6 items-center shrink-0 w-6">
<UserPlus className="h-4 w-4"/>
</span>
{!collapsed && <span className="text-sm leading-none">Invitations</span>}
</button>
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/notify')}>
<span className="relative flex h-6 items-center shrink-0 w-6">

View File

@ -79,6 +79,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
(content: string) => {
sendMessage(content, 'text', replyingTo?.id ?? undefined);
setReplyingTo(null);
messageInputRef.current?.clearContent();
},
[sendMessage, replyingTo],
);
@ -277,6 +278,10 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
onRevoke={handleRevoke}
onReply={setReplyingTo}
onMention={undefined}
onOpenUserCard={({ userId, username }) => {
messageInputRef.current?.insertMention('user', userId, username);
messageInputRef.current?.focus();
}}
onOpenThread={handleOpenThread}
onCreateThread={handleCreateThread}
/>
@ -294,7 +299,12 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
<DiscordMemberList
members={members}
membersLoading={membersLoading}
onMemberClick={() => {}}
onMemberClick={({ user, user_info, role }) => {
const label = user_info?.username ?? user;
const type = role === 'ai' ? 'ai' : 'user';
messageInputRef.current?.insertMention(type, user, label);
messageInputRef.current?.focus();
}}
aiConfigs={roomAiConfigs}
/>
)}

View File

@ -11,7 +11,6 @@ import { Button } from '@/components/ui/button';
import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser';
import { formatMessageTime } from '../shared/formatters';
import { cn } from '@/lib/utils';
import { SmilePlus } from 'lucide-react';
import { useUser, useRoom, useTheme } from '@/contexts';
import { memo, useMemo, useState, useCallback, useRef } from 'react';
import { ModelIcon } from '../icon-match';
@ -20,6 +19,7 @@ import { MessageContent } from './MessageContent';
import { ThreadIndicator } from '../RoomThreadPanel';
import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
import { MessageReactions } from './MessageReactions';
import { ReactionPicker } from './ReactionPicker';
// Sender colors — AI Studio clean palette
const SENDER_COLORS: Record<string, string> = {
@ -375,14 +375,7 @@ export const MessageBubble = memo(function MessageBubble({
className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity absolute -top-3 right-3"
style={{ background: 'var(--card)', border: '1px solid var(--room-border)', borderRadius: 6 }}
>
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => handleReaction('👍')}
title="Add reaction"
>
<SmilePlus className="size-3.5" />
</button>
<ReactionPicker onReact={handleReaction} />
{onReply && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"

View File

@ -21,6 +21,7 @@ export interface MessageInputHandle {
focus: () => void;
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(

View File

@ -13,6 +13,7 @@ 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';
export interface IMEditorProps {
@ -33,6 +34,7 @@ export interface IMEditorHandle {
focus: () => void;
clearContent: () => void;
getContent: () => string;
insertMention: (type: string, id: string, label: string) => void;
}
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
@ -91,22 +93,7 @@ type Palette = typeof LIGHT;
// ─── Emoji Picker ─────────────────────────────────────────────────────────────
const EMOJIS = [
{ name: 'thumbsup', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44d.png' },
{ name: 'thumbsdown', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44e.png' },
{ name: 'heart', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2764.png' },
{ name: 'laugh', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f602.png' },
{ name: 'rocket', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f680.png' },
{ name: 'fire', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f525.png' },
{ name: 'eyes', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f440.png' },
{ name: 'check', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2705.png' },
{ name: 'star', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2b50.png' },
{ name: 'clap', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44f.png' },
{ name: 'thinking', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f914.png' },
{ name: 'wave', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44b.png' },
];
function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (n: string, u: string) => void; p: Palette }) {
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"
@ -131,14 +118,14 @@ function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect:
</button>
</div>
<div className="grid p-2 gap-0.5" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
{EMOJIS.map(e => (
{COMMON_EMOJIS.map(emoji => (
<button
key={e.name}
onClick={() => onSelect(e.name, e.url)}
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110"
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' }}
>
<img src={e.url} alt={e.name} className="w-5 h-5 pointer-events-none" />
{emoji}
</button>
))}
</div>
@ -368,6 +355,11 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
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();
},
}));
const hasContent = !!editor && !editor.isEmpty;
@ -442,7 +434,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
>
<Smile size={18} />
</button>
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(n, u) => { editor?.chain().focus().insertContent({ type: 'emoji', attrs: { name: n, url: u } }).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
</div>
<label

View File

@ -30,13 +30,6 @@ import {
} from '@/lib/room-ws-client';
import { requestWsToken } from '@/lib/ws-token';
import { useUser } from '@/contexts';
import {
saveMessage,
saveMessages,
loadMessages as loadMessagesFromIdb,
loadOlderMessagesFromIdb,
deleteMessage as deleteMessageFromIdb,
} from '@/lib/storage/indexed-db';
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
@ -249,23 +242,17 @@ export function RoomProvider({
}
}, [activeRoomId]);
// ── Subscribe to room (WS must already be connected) ───────────────────────
useEffect(() => {
const client = wsClientRef.current;
if (!activeRoomId || !client) return;
const setup = async () => {
// IDB load does NOT need WS — show cached messages immediately.
// loadMore checks IDB first, then falls back to API (WS-first + HTTP).
loadMore(null);
// Load messages via WS (with HTTP fallback)
loadMore(null);
// Connect WS in parallel for real-time push + reactions batch-fetch.
// connect() is idempotent — no-op if already connecting/open.
// subscribeRoom uses WS-first request() with HTTP fallback.
await client.connect();
if (activeRoomIdRef.current !== activeRoomId) return;
client.subscribeRoom(activeRoomId).catch(() => {});
};
setup().catch(() => {});
// Subscribe to room events. connect() is already called at the provider
// level — subscribe/unsubscribe only manage per-room event routing.
client.subscribeRoom(activeRoomId).catch(() => {});
return () => {
client.unsubscribeRoom(activeRoomId).catch(() => {});
@ -284,26 +271,32 @@ export function RoomProvider({
) => {
const msgIds = msgs.map((m) => m.id);
if (msgIds.length === 0) return;
client
.reactionListBatch(roomId, msgIds)
.then((reactionResults: ReactionListData[]) => {
const reactionMap = new Map<string, ReactionListData['reactions']>();
for (const result of reactionResults) {
if (result.reactions.length > 0) {
reactionMap.set(result.message_id, result.reactions);
}
const doLoad = async () => {
let reactionResults: ReactionListData[];
if (client.getStatus() === 'open') {
try {
reactionResults = await client.reactionListBatchWs(roomId, msgIds);
} catch {
reactionResults = await client.reactionListBatch(roomId, msgIds);
}
if (reactionMap.size > 0) {
setMessages((prev) =>
prev.map((m) =>
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
),
);
} else {
reactionResults = await client.reactionListBatch(roomId, msgIds);
}
const reactionMap = new Map<string, ReactionListData['reactions']>();
for (const result of reactionResults) {
if (result.reactions.length > 0) {
reactionMap.set(result.message_id, result.reactions);
}
})
.catch(() => {
// Non-fatal: WS push will keep reactions up to date
});
}
if (reactionMap.size > 0) {
setMessages((prev) =>
prev.map((m) =>
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
),
);
}
};
doLoad().catch(() => {});
};
const loadMore = useCallback(
@ -321,56 +314,28 @@ export function RoomProvider({
const isInitial = cursor === null || cursor === undefined;
const limit = isInitial ? 200 : 50;
// --- Initial load: try IndexedDB first for instant render ---
if (isInitial) {
const cached = await loadMessagesFromIdb(activeRoomId);
if (cached.length > 0) {
setMessages(cached);
setIsTransitioningRoom(false);
const minSeq = cached[0].seq;
setNextCursor(minSeq > 0 ? minSeq - 1 : null);
setIsLoadingMore(false);
// No API call needed — WS will push any new messages that arrived while away.
// Fetch reactions via WS (with HTTP fallback) so reactions appear without extra latency.
thisLoadReactions(activeRoomId, client, cached);
return;
}
}
// --- Load older history: try IDB first, then fall back to API ---
if (!isInitial && cursor != null) {
const idbMessages = await loadOlderMessagesFromIdb(activeRoomId, cursor, limit);
if (idbMessages.length > 0) {
setMessages((prev) => {
if (abortController.signal.aborted) return prev;
const existingIds = new Set(prev.map((m) => m.id));
const filtered = idbMessages.filter((m) => !existingIds.has(m.id));
let merged = [...filtered, ...prev];
merged.sort((a, b) => a.seq - b.seq);
if (merged.length > MAX_MESSAGES_IN_MEMORY) {
merged = merged.slice(-MAX_MESSAGES_IN_MEMORY);
}
return merged;
// Try WebSocket first; fall back to HTTP on failure
let resp: import('@/lib/room-ws-client').RoomMessageListResponse;
if (client.getStatus() === 'open') {
try {
resp = await client.messageListWs(activeRoomId, {
beforeSeq: cursor ?? undefined,
limit,
});
} catch {
// WS failed — fall back to HTTP
resp = await client.messageList(activeRoomId, {
beforeSeq: cursor ?? undefined,
limit,
});
const oldest = idbMessages[0];
setNextCursor(oldest.seq > 0 ? oldest.seq - 1 : null);
if (idbMessages.length < limit) {
setIsHistoryLoaded(true);
}
setIsLoadingMore(false);
// Also fetch reactions for the IDB-loaded history messages.
thisLoadReactions(activeRoomId, client, idbMessages);
return;
}
// IDB empty for this range — fall through to API
} else {
resp = await client.messageList(activeRoomId, {
beforeSeq: cursor ?? undefined,
limit,
});
}
// --- API fetch ---
const resp = await client.messageList(activeRoomId, {
beforeSeq: cursor ?? undefined,
limit,
});
if (abortController.signal.aborted) return;
const newMessages = resp.messages.map((m) => ({
@ -396,16 +361,12 @@ export function RoomProvider({
return merged;
});
if (newMessages.length > 0) {
saveMessages(activeRoomId, newMessages).catch(() => {});
}
if (resp.messages.length < limit) {
setIsHistoryLoaded(true);
}
setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null);
// Fetch reactions for all loaded messages (WS-first with HTTP fallback)
// Fetch reactions for all loaded messages
thisLoadReactions(activeRoomId, client, newMessages);
} catch (error) {
if (abortController.signal.aborted) return;
@ -434,8 +395,6 @@ export function RoomProvider({
// Room AI configs for @ai: mention suggestions
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
// Available models (for looking up AI model names) — TODO: wire up once model sync API is available
const [_availableModels, _setAvailableModels] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
@ -446,37 +405,28 @@ export function RoomProvider({
// Use ref to get current activeRoomId to avoid stale closure
if (payload.room_id === activeRoomIdRef.current) {
setMessages((prev) => {
// Check if this is a reaction-update event (same ID, different reactions).
// publish_reaction_event sends RoomMessageEvent with reactions field set.
const existingIdx = prev.findIndex((m) => m.id === payload.id);
if (existingIdx !== -1) {
// Message already exists — update reactions if provided.
// Reaction events have empty content/sender_type.
// Message already exists — update reactions if provided
if (payload.reactions !== undefined) {
const updated = [...prev];
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
const msg = updated[existingIdx];
saveMessage(msg).catch(() => {});
return updated;
}
// Duplicate of a real message — ignore
return prev;
}
// Also check if there's an optimistic message with the same seq that should be replaced
// Replace optimistic message with server-confirmed one
const optimisticIdx = prev.findIndex(
(m) => m.isOptimistic && m.seq === payload.seq && m.seq !== 0,
);
if (optimisticIdx !== -1) {
// Replace optimistic message with confirmed one
const confirmed: MessageWithMeta = {
...wsMessageToUiMessage(payload),
reactions: prev[optimisticIdx].reactions,
};
const next = [...prev];
next[optimisticIdx] = confirmed;
// Remove optimistic from IDB, save confirmed
deleteMessageFromIdb(prev[optimisticIdx].id).catch(() => {});
saveMessage(confirmed).catch(() => {});
return next;
}
const newMsg = wsMessageToUiMessage(payload);
@ -487,39 +437,29 @@ export function RoomProvider({
}
return updated;
});
// Persist to IndexedDB
const msg = wsMessageToUiMessage(payload);
saveMessage(msg).catch(() => {});
}
},
onAiStreamChunk: (chunk) => {
if (chunk.done) {
// When streaming is done, update the message content and remove from streaming
setStreamingContent((prev) => {
const next = new Map(prev);
next.delete(chunk.message_id);
return next;
});
setMessages((prev) => {
const updated = prev.map((m) =>
setMessages((prev) =>
prev.map((m) =>
m.id === chunk.message_id
? { ...m, content: chunk.content, is_streaming: false }
: m,
);
// Persist final content to IndexedDB
const msg = updated.find((m) => m.id === chunk.message_id);
if (msg) saveMessage(msg).catch(() => {});
return updated;
});
),
);
} else {
// Accumulate streaming content
setStreamingContent((prev) => {
const next = new Map(prev);
const existing = next.get(chunk.message_id) ?? '';
next.set(chunk.message_id, existing + chunk.content);
return next;
});
// Create streaming message placeholder if it doesn't exist
setMessages((prev) => {
if (prev.some((m) => m.id === chunk.message_id)) {
return prev;
@ -539,48 +479,34 @@ export function RoomProvider({
}
},
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
// Guard: ignore events for rooms that are no longer active.
// Without this, a WS event arriving after room switch could update
// the wrong room's message list (same message ID, different room).
if (!activeRoomIdRef.current) return;
setMessages((prev) => {
const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
if (existingIdx === -1) return prev;
const updated = [...prev];
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
// Persist reaction update to IndexedDB
saveMessage(updated[existingIdx]).catch(() => {});
return updated;
});
},
onMessageEdited: async (payload) => {
// The event only contains message_id and edited_at.
// Optimistically update edited_at, then fetch the full message from the API.
if (payload.room_id !== activeRoomIdRef.current) return;
const client = wsClientRef.current;
if (!client) return;
// Capture original edited_at for rollback if fetch fails
let rollbackEditedAt: string | null = null;
setMessages((prev) => {
const msg = prev.find((m) => m.id === payload.message_id);
rollbackEditedAt = msg?.edited_at ?? null;
const updated = prev.map((m) =>
return prev.map((m) =>
m.id === payload.message_id ? { ...m, edited_at: payload.edited_at } : m,
);
const saved = updated.find((m) => m.id === payload.message_id);
if (saved) saveMessage(saved).catch(() => {});
return updated;
});
// Fetch full updated message from API
try {
const updatedMsg = await client.messageGet(payload.message_id);
if (!updatedMsg) return;
setMessages((prev) => {
const merged = prev.map((m) =>
setMessages((prev) =>
prev.map((m) =>
m.id === payload.message_id
? {
...m,
@ -589,14 +515,9 @@ export function RoomProvider({
edited_at: payload.edited_at,
}
: m,
);
// Persist to IndexedDB
const saved = merged.find((m) => m.id === payload.message_id);
if (saved) saveMessage(saved).catch(() => {});
return merged;
});
),
);
} catch {
// Revert edited_at if the fetch failed
if (rollbackEditedAt !== null) {
setMessages((prev) =>
prev.map((m) =>
@ -608,8 +529,8 @@ export function RoomProvider({
},
onMessageRevoked: async (payload) => {
if (payload.room_id !== activeRoomIdRef.current) return;
setMessages((prev) => {
const updated = prev.map((m) =>
setMessages((prev) =>
prev.map((m) =>
m.id === payload.message_id
? {
...m,
@ -619,12 +540,8 @@ export function RoomProvider({
display_content: '',
}
: m,
);
// Persist to IndexedDB
const msg = updated.find((m) => m.id === payload.message_id);
if (msg) saveMessage(msg).catch(() => {});
return updated;
});
),
);
},
onMessagePinned: async (payload) => {
if (payload.room_id !== activeRoomIdRef.current) return;
@ -670,18 +587,14 @@ export function RoomProvider({
};
}, [wsToken]);
// ── Connect WS whenever a new client is created ─────────────────────────────
// Intentionally depends on wsClient (not wsClientRef) so a new client triggers connect().
// connect() is idempotent — no-op if already connecting/open.
useEffect(() => {
// NOTE: intentionally omitted [wsClient] from deps.
// In React StrictMode the component mounts twice — if wsClient were a dep,
// the first mount's effect would connect client-1, then StrictMode cleanup
// would disconnect it, then the second mount's effect would connect client-2,
// then immediately the first mount's *second* cleanup would fire and
// disconnect client-2 — leaving WS unconnected. Using a ref for the initial
// connect avoids this. The client is always ready by the time this runs.
wsClientRef.current?.connect().catch((e) => {
console.error('[RoomContext] WS connect error:', e);
});
}, []);
}, [wsClient]);
const connectWs = useCallback(async () => {
const client = wsClientRef.current;
@ -899,8 +812,6 @@ export function RoomProvider({
};
setMessages((prev) => [...prev, optimisticMsg]);
// Persist optimistic message to IndexedDB so it's not lost on refresh
saveMessage(optimisticMsg).catch(() => {});
try {
const confirmedMsg = await client.messageCreate(activeRoomId, content, {
@ -918,10 +829,6 @@ export function RoomProvider({
isOptimistic: false,
reactions: [],
};
// Remove optimistic from IDB
deleteMessageFromIdb(optimisticId).catch(() => {});
// Save confirmed to IDB
saveMessage(confirmed).catch(() => {});
return [...without, confirmed];
});
} catch (err) {
@ -944,7 +851,6 @@ export function RoomProvider({
const client = wsClientRef.current;
if (!client) return;
// Capture original content for rollback on server rejection
let rollbackContent: string | null = null;
setMessages((prev) => {
const msg = prev.find((m) => m.id === messageId);
@ -955,15 +861,16 @@ export function RoomProvider({
});
try {
await client.messageUpdate(messageId, content);
// Persist updated content to IndexedDB
setMessages((prev) => {
const msg = prev.find((m) => m.id === messageId);
if (msg) saveMessage(msg).catch(() => {});
return prev;
});
if (client.getStatus() === 'open') {
try {
await client.messageUpdateWs(messageId, content);
} catch {
await client.messageUpdate(messageId, content);
}
} else {
await client.messageUpdate(messageId, content);
}
} catch (err) {
// Rollback optimistic update on server rejection
if (rollbackContent !== null) {
setMessages((prev) =>
prev.map((m) =>
@ -982,7 +889,6 @@ export function RoomProvider({
const client = wsClientRef.current;
if (!client) return;
// Optimistic removal: hide message immediately
let rollbackMsg: MessageWithMeta | null = null;
setMessages((prev) => {
rollbackMsg = prev.find((m) => m.id === messageId) ?? null;
@ -990,13 +896,18 @@ export function RoomProvider({
});
try {
await client.messageRevoke(messageId);
deleteMessageFromIdb(messageId).catch(() => {});
if (client.getStatus() === 'open') {
try {
await client.messageRevokeWs(messageId);
} catch {
await client.messageRevoke(messageId);
}
} else {
await client.messageRevoke(messageId);
}
} catch (err) {
// Rollback: restore message on server rejection
if (rollbackMsg) {
setMessages((prev) => [...prev, rollbackMsg!]);
saveMessage(rollbackMsg!).catch(() => {});
}
handleRoomError('Delete message', err);
}
@ -1160,25 +1071,10 @@ export function RoomProvider({
}
}, [activeRoomId]);
// Fetch available models (for AI model name lookup)
const fetchAvailableModels = useCallback(async () => {
try {
const resp = await (await import('@/client')).modelList({});
const inner = (resp.data as { data?: { data?: { id: string; name: string }[] } } | undefined);
_setAvailableModels(inner?.data?.data ?? []);
} catch {
// Non-fatal
}
}, []);
useEffect(() => {
fetchProjectRepos();
}, [fetchProjectRepos]);
useEffect(() => {
fetchAvailableModels();
}, [fetchAvailableModels]);
useEffect(() => {
fetchRoomAiConfigs();
}, [fetchRoomAiConfigs]);

View File

@ -77,6 +77,8 @@ export interface RoomWsCallbacks {
onMessageUnpinned?: (payload: import('./ws-protocol').MessageUnpinnedPayload) => void;
onStatusChange?: (status: RoomWsStatus) => void;
onError?: (error: Error) => void;
/** Called each time the client sends a heartbeat ping */
onHeartbeat?: () => void;
}
export class RoomWsClient {
@ -93,7 +95,11 @@ export class RoomWsClient {
private readonly reconnectBaseDelay: number;
private readonly reconnectMaxDelay: number;
private readonly requestTimeout: number;
private readonly heartbeatInterval: number;
private readonly heartbeatTimeout: number;
private wsToken: string | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private lastHeartbeat: number = 0;
constructor(
baseUrl: string,
@ -102,6 +108,8 @@ export class RoomWsClient {
reconnectBaseDelay?: number;
reconnectMaxDelay?: number;
requestTimeout?: number;
heartbeatInterval?: number;
heartbeatTimeout?: number;
wsToken?: string;
} = {},
) {
@ -110,6 +118,9 @@ export class RoomWsClient {
this.reconnectBaseDelay = options.reconnectBaseDelay ?? 1000;
this.reconnectMaxDelay = options.reconnectMaxDelay ?? 15000;
this.requestTimeout = options.requestTimeout ?? 30_000;
// Heartbeat: send a ping every 25s, timeout after 55s of inactivity
this.heartbeatInterval = options.heartbeatInterval ?? 25_000;
this.heartbeatTimeout = options.heartbeatTimeout ?? 55_000;
this.wsToken = options.wsToken ?? null;
}
@ -200,22 +211,21 @@ export class RoomWsClient {
console.debug('[RoomWs] Connected');
this.reconnectAttempt = 0;
this.setStatus('open');
this.startHeartbeat();
this.resubscribeAll().catch(() => {});
resolve();
};
this.ws!.onmessage = (ev: MessageEvent) => {
try {
const message: WsInMessage = JSON.parse(ev.data);
this.handleMessage(message);
} catch (e) {
console.warn('[RoomWs] parse error:', e);
}
const text = ev.data;
if (typeof text !== 'string') return;
this.handleMessage(text);
};
this.ws!.onclose = (ev: CloseEvent) => {
clearTimeout(timeoutId);
console.debug(`[RoomWs] onclose code=${ev.code} reason=${ev.reason || 'none'} wasClean=${ev.wasClean}`);
this.stopHeartbeat();
this.ws = null;
this.setStatus('closed');
for (const [, req] of this.pendingRequests) {
@ -241,6 +251,7 @@ export class RoomWsClient {
disconnect(): void {
this.shouldReconnect = false;
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
@ -253,6 +264,39 @@ export class RoomWsClient {
this.setStatus('closed');
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.lastHeartbeat = Date.now();
this.heartbeatTimer = setInterval(() => {
if (this.status !== 'open' || !this.ws) {
this.stopHeartbeat();
return;
}
// Detect heartbeat timeout (server died or network dropped)
if (Date.now() - this.lastHeartbeat > this.heartbeatTimeout) {
console.warn('[RoomWs] Heartbeat timeout — closing connection');
this.callbacks.onError?.(new Error('Heartbeat timeout'));
this.stopHeartbeat();
this.ws.close();
return;
}
// Send application-level ping
try {
this.ws.send(JSON.stringify({ type: 'ping' }));
this.callbacks.onHeartbeat?.();
} catch {
this.stopHeartbeat();
}
}, this.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private async request<T = WsResponseData>(action: WsAction, params?: WsRequestParams): Promise<T> {
if (this.ws && this.status === 'open') {
return await this.requestWs<T>(action, params);
@ -486,6 +530,87 @@ export class RoomWsClient {
return data || { messages: [], total: 0 };
}
/**
* Load messages via WebSocket only. Throws if WS is not connected.
* Use this for WS-first loading; falls back to `messageList()` on failure.
*/
async messageListWs(
roomId: string,
options?: {
beforeSeq?: number;
afterSeq?: number;
limit?: number;
},
): Promise<RoomMessageListResponse> {
const data = await this.requestWs<RoomMessageListResponse>('message.list', {
room_id: roomId,
before_seq: options?.beforeSeq,
after_seq: options?.afterSeq,
limit: options?.limit,
});
return data || { messages: [], total: 0 };
}
async messageGetWs(messageId: string): Promise<RoomMessageResponse | null> {
const data = await this.requestWs<RoomMessageResponse>('message.get', {
message_id: messageId,
});
return data || null;
}
async messageUpdateWs(messageId: string, content: string): Promise<RoomMessageResponse> {
return this.requestWs<RoomMessageResponse>('message.update', {
message_id: messageId,
content,
});
}
async messageRevokeWs(messageId: string): Promise<RoomMessageResponse> {
return this.requestWs<RoomMessageResponse>('message.revoke', {
message_id: messageId,
});
}
async messageSearchWs(
roomId: string,
query: string,
options?: { limit?: number; offset?: number },
): Promise<SearchResultData> {
return this.requestWs<SearchResultData>('message.search', {
room_id: roomId,
query,
limit: options?.limit,
offset: options?.offset,
});
}
async messageEditHistoryWs(messageId: string): Promise<MessageEditHistoryResponse> {
return this.requestWs<MessageEditHistoryResponse>('message.edit_history', {
message_id: messageId,
});
}
async threadMessagesWs(
threadId: string,
options?: { beforeSeq?: number; afterSeq?: number; limit?: number },
): Promise<RoomMessageListResponse> {
const data = await this.requestWs<RoomMessageListResponse>('thread.messages', {
thread_id: threadId,
before_seq: options?.beforeSeq,
after_seq: options?.afterSeq,
limit: options?.limit,
});
return data || { messages: [], total: 0 };
}
async reactionListBatchWs(roomId: string, messageIds: string[]): Promise<ReactionListData[]> {
const data = await this.requestWs<ReactionListData[]>('reaction.list_batch', {
room_id: roomId,
message_ids: messageIds,
});
return Array.isArray(data) ? data : [];
}
async messageCreate(
roomId: string,
content: string,
@ -827,7 +952,23 @@ export class RoomWsClient {
return url;
}
private handleMessage(message: WsInMessage): void {
private handleMessage(rawText: string): void {
// Handle raw JSON pong before full parsing — resets heartbeat
if (rawText.trim() === '{"type":"pong"}') {
this.lastHeartbeat = Date.now();
return;
}
// Reset heartbeat on any other message (server is alive)
this.lastHeartbeat = Date.now();
let message: WsInMessage;
try {
message = JSON.parse(rawText);
} catch {
return;
}
if ('type' in message && message.type === 'error') {
return;
}
@ -966,6 +1107,8 @@ export function createRoomWsClient(
reconnectBaseDelay?: number;
reconnectMaxDelay?: number;
requestTimeout?: number;
heartbeatInterval?: number;
heartbeatTimeout?: number;
wsToken?: string;
},
): RoomWsClient {