Compare commits

..

8 Commits

Author SHA1 Message Date
ZhenYi
0a9dfef9b4 chore: remove unused instance_id import in main.rs
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
2026-04-25 09:55:08 +08:00
ZhenYi
76de013a60 fix(git): add offset_minutes to reflog entry for timezone-correct timestamps
CommitReflogEntry now includes offset_minutes field, populated from
sig.when().offset_minutes() — matches git's internal timezone offset
representation. Used by git_tools for accurate reflog timestamps.
2026-04-25 09:54:30 +08:00
ZhenYi
99bc4eeb80 chore: API and frontend UI adjustments
- API: issue label bulk add, search messages, room WS push, openapi
- Frontend: notify page, issue detail AI triage banner, search page,
  repository settings, preferences, PR components, file browser
- Room: DiscordChannelSidebar, RoomPinPanel, RoomMessageActions,
  RoomThreadPanel, MessageContent, repository-context
- Frontend SDK regenerated from openapi.json
2026-04-25 09:54:05 +08:00
ZhenYi
dfa5f7664a feat: add notification drawer, command registry, keyboard shortcuts, hooks
New components:
- NotificationDrawer: global bell button with unread badge + Sheet drawer
- CommandPalette: Cmd+K / Ctrl+Alt+F command palette with real API data
- KeyboardShortcutsSheet: ? shortcut reference sheet
- GlobalNavigationShortcuts: g+n/i/r/m two-key navigation
- useNotification: real-time notification management hook
- useCommandRegistry: global command registration hook
- useKeyboardShortcut: keyboard shortcut formatting hook
- useTypingIndicator: unified typing indicator hook
- LinkPreview / CodeBlock / CodeReference: code-aware chat rendering
- ContentRenderer: unified content rendering
- MiniChat: compact inline chat component
- MentionBadge: @mention badge renderer

New libs:
- libs/api/agent/issue_triage.rs: AI issue triage API endpoint
- libs/service/agent/issue_triage.rs: AI triage service
- src/lib/mention.ts: mention parsing and rendering
- src/lib/link-unfurl.ts: URL pattern detection
- src/lib/code-lang-detect.ts: code language detection
- src/lib/code-ref-parser.ts: line-level code reference parsing
2026-04-25 09:53:49 +08:00
ZhenYi
f7e087e066 fix(agent/service): retry jitter, tool executor ordering, curl SSRF, grep/JSON
- agent/client: full jitter backoff (random(0, base_ms)) instead of equal jitter
- agent/tool/executor: fix buffer_unordered ordering mismatch with
  HashMap-by-index approach for concurrent tool execution
- agent/chat: AiChunkType emit fixes, is_retryable_tool_error refinements,
  process_react uses request.max_tool_depth
- agent/chat/context: fix Function message sender_name field
- file_tools/curl: shared reqwest::Client via OnceLock, manual redirect
  following with per-hop SSRF validation, blocked sensitive headers
- file_tools/grep: fix case-insensitive glob matching, segment consumption
- file_tools/json: bracket notation support, remove .vscodeignore from JSONC
- git_tools: git_diff_stats resolve base/head independently,
  DiffFileOut old_file.path for Deleted, reflog offset_minutes
- git/repo: create_commit read parent tree into index, bare repo init
- project_tools/repos: branch/path validation, .git/ prefix check
- service/agent: tokent integration, billing, pr_summary, code_review fixes
2026-04-25 09:53:31 +08:00
ZhenYi
7620f2f281 feat(command): use real API data for navigation, fix notification button
CommandPalette: replace workspaceProjects with getCurrentUserProjects
(no workspace dependency so it works outside WorkspaceProvider).
Repos fetched per-project to preserve correct /repository/ns/repo
routes. Keyboard shortcut correctly matches Ctrl+Alt+F / Cmd+Ctrl+F.

sidebar-user: fix notification button layout — bell icon and label
now on the same row instead of separate stacked elements.
2026-04-25 09:53:12 +08:00
ZhenYi
616c0c0e88 fix(room): scroll-to-bottom logic and AI sender display name
- Remove duplicate smooth scroll effect from DiscordChatPanel; handle
  all scroll logic in MessageList instead
- MessageList: track isInitialLoadRef to instant-jump to bottom on
  first load (no animation), and only auto-scroll for new messages
  when user is already near the bottom
- sender.ts: getSenderDisplayName rejects UUID values and falls back
  to 'AI' for AI messages; getSenderModelId uses display_name
2026-04-25 09:52:58 +08:00
ZhenYi
57d0fc371e fix(room): include display_name in RoomMessageEnvelope for AI streaming
RoomMessageEvent was losing the AI model name because the
From<RoomMessageEnvelope> impl hardcoded display_name: None.
Add display_name to RoomMessageEnvelope and propagate it through
all AI streaming code paths (chat, ReAct, non-streaming).
Member messages keep display_name: None.
2026-04-25 09:52:41 +08:00
97 changed files with 7116 additions and 846 deletions

View File

@ -7,7 +7,7 @@ use db::cache::AppCache;
use db::database::AppDatabase;
use observability::{
init_tracing_subscriber, install_recorder, prometheus_handler, spawn_http_metrics_poller,
HttpMetrics, HttpSnapshotGuard, MetricsMiddleware, TracingSpanMiddleware, instance_id,
HttpMetrics, HttpSnapshotGuard, MetricsMiddleware, TracingSpanMiddleware,
};
use sea_orm::ConnectionTrait;
use service::AppService;

View File

@ -40,7 +40,7 @@ impl AiContextSenderType {
models::rooms::MessageSenderType::Owner => Self::User,
models::rooms::MessageSenderType::Ai => Self::Ai,
models::rooms::MessageSenderType::System => Self::System,
models::rooms::MessageSenderType::Tool => Self::Function,
models::rooms::MessageSenderType::Tool => Self::FunctionResult,
models::rooms::MessageSenderType::Guest => Self::User,
}
}
@ -135,7 +135,7 @@ impl RoomMessageContext {
AiContextSenderType::Function => {
ChatCompletionRequestMessage::Function(ChatCompletionRequestFunctionMessage {
content: Some(self.content.clone()),
name: self.display_content(), // Function name is stored in content
name: self.sender_name.clone().unwrap_or_else(|| "unknown".to_string()),
})
}
AiContextSenderType::FunctionResult => {

View File

@ -13,13 +13,36 @@ use std::collections::HashMap;
use uuid::Uuid;
/// Maximum recursion rounds for tool-call loops (AI → tool → result → AI).
pub const DEFAULT_MAX_TOOL_DEPTH: usize = 3;
/// Previous default of 3 caused frequent silent termination on realistic multi-step queries.
pub const DEFAULT_MAX_TOOL_DEPTH: usize = 99;
/// A single chunk from an AI streaming response.
#[derive(Debug, Clone)]
pub struct AiStreamChunk {
pub content: String,
pub done: bool,
/// What kind of content this chunk contains — helps the frontend render
/// thinking, tool calls, and results with different styles.
pub chunk_type: AiChunkType,
}
/// Type of streaming chunk, used by the frontend for rendering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AiChunkType {
/// AI reasoning/thinking text before a tool call or answer.
Thinking,
/// Final answer text from the AI.
Answer,
/// A tool call is being executed (content = tool name + args summary).
ToolCall,
/// Tool execution result (content = result or error).
ToolResult,
}
impl Default for AiChunkType {
fn default() -> Self {
Self::Answer
}
}
/// Optional streaming callback: called for each token chunk.

View File

@ -7,7 +7,7 @@ use async_openai::types::chat::{
ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage,
ChatCompletionRequestSystemMessage, ChatCompletionRequestToolMessage,
ChatCompletionRequestToolMessageContent, ChatCompletionRequestUserMessage,
ChatCompletionRequestUserMessageContent, ChatCompletionTool,
ChatCompletionTool,
ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
CreateChatCompletionStreamResponse, FinishReason, ReasoningEffort, ToolChoiceOptions,
};
@ -18,7 +18,7 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid;
use super::context::RoomMessageContext;
use super::{AiRequest, AiStreamChunk, Mention, StreamCallback};
use super::{AiChunkType, AiRequest, AiStreamChunk, Mention, StreamCallback};
use crate::client::AiClientConfig;
use crate::compact::{CompactConfig, CompactService};
use crate::embed::EmbedService;
@ -195,29 +195,38 @@ impl ChatService {
.collect();
if !calls.is_empty() {
let calls_for_error = calls.clone();
let tool_messages = match self.execute_tool_calls(calls, &request).await {
Ok(msgs) => msgs,
Err(e) => {
// Surface the error as a tool result so the model can continue
let err_text = format!("[Tool call failed: {}]", e);
messages.push(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(err_text.clone()),
name: None,
},
));
tool_depth += 1;
if tool_depth >= max_tool_depth {
return Ok(err_text);
}
continue;
// Surface the error as per-call Tool messages (with matching IDs)
// so the API contract (Tool after Assistant+tool_calls) is preserved.
calls_for_error.iter().map(|c| {
ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
tool_call_id: c.id.clone(),
content: ChatCompletionRequestToolMessageContent::Text(
format!("[Tool call failed: {}]", e),
),
},
)
}).collect()
}
};
messages.extend(tool_messages);
tool_depth += 1;
if tool_depth >= max_tool_depth {
return Ok(String::new());
// Return accumulated content rather than empty string so the user
// sees whatever the AI has produced so far.
let text = choice.message.content.unwrap_or_default();
if text.is_empty() {
return Ok(format!(
"[AI reached maximum tool depth ({}) — no final answer produced]",
max_tool_depth
));
}
return Ok(text);
}
continue;
}
@ -320,6 +329,7 @@ impl ChatService {
on_chunk(AiStreamChunk {
content: text_accumulated.clone(),
done: false,
chunk_type: AiChunkType::Answer,
})
.await;
}
@ -364,12 +374,13 @@ impl ChatService {
.collect();
if !tool_calls.is_empty() {
// Capture thinking text, send it as a completed chunk, then clear for the next turn
// Capture thinking text, send it as a non-final chunk, then clear for the next turn
let thinking_text = text_accumulated.clone();
if !thinking_text.is_empty() {
on_chunk(AiStreamChunk {
content: thinking_text.clone(),
done: true,
done: false,
chunk_type: AiChunkType::Thinking,
})
.await;
}
@ -406,29 +417,88 @@ impl ChatService {
},
));
let calls_for_error = tool_calls.clone();
// Notify frontend which tools are being called
let call_summary: Vec<String> = calls_for_error.iter().map(|c| {
let args_preview: String = {
let args_json: serde_json::Value = serde_json::from_str(&c.arguments)
.unwrap_or(serde_json::Value::Null);
// Show truncated args for readability
let s = serde_json::to_string(&args_json).unwrap_or_default();
if s.len() > 200 { s[..200].to_string() + "..." } else { s }
};
format!("{}({})", c.name, args_preview)
}).collect();
on_chunk(AiStreamChunk {
content: format!("[Calling tools: {}]", call_summary.join(", ")),
done: false,
chunk_type: AiChunkType::ToolCall,
})
.await;
let tool_messages = match self.execute_tool_calls(tool_calls, &request).await {
Ok(msgs) => msgs,
Ok(msgs) => {
// Stream tool results to frontend so user can see what happened
let result_summary: Vec<String> = msgs.iter().map(|m| {
if let ChatCompletionRequestMessage::Tool(tm) = m {
match &tm.content {
ChatCompletionRequestToolMessageContent::Text(t) => {
if t.len() > 300 { t[..300].to_string() + "..." } else { t.clone() }
}
_ => "[binary content]".to_string(),
}
} else { "unknown".to_string() }
}).collect();
on_chunk(AiStreamChunk {
content: format!("[Tool results: {}]", result_summary.join("; ")),
done: false,
chunk_type: AiChunkType::ToolResult,
})
.await;
msgs
}
Err(e) => {
// Stream the FC error as an observation so the user sees it
// Stream the FC error as a non-final observation so the user sees it,
// but do NOT mark done=true — the AI will continue after seeing the error.
let err_text = format!("[Tool call failed: {}]", e);
on_chunk(AiStreamChunk {
content: err_text.clone(),
done: true,
done: false,
chunk_type: AiChunkType::ToolResult,
})
.await;
// Return an empty tool result so the loop can continue
vec![ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
tool_call_id: String::new(),
content: ChatCompletionRequestToolMessageContent::Text(err_text),
},
)]
// Return per-call Tool messages with matching IDs to preserve API contract
calls_for_error.iter().map(|c| {
ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
tool_call_id: c.id.clone(),
content: ChatCompletionRequestToolMessageContent::Text(err_text.clone()),
},
)
}).collect()
}
};
messages.extend(tool_messages);
tool_depth += 1;
if tool_depth >= max_tool_depth {
// Emit a final done chunk with whatever content we have so the
// client receives a completion signal instead of hanging forever.
let final_content = if text_accumulated.is_empty() {
format!(
"[AI reached maximum tool depth ({}) — no final answer produced]",
max_tool_depth
)
} else {
text_accumulated.clone()
};
on_chunk(AiStreamChunk {
content: final_content,
done: true,
chunk_type: AiChunkType::Answer,
})
.await;
return Ok(());
}
continue;
@ -438,6 +508,7 @@ impl ChatService {
on_chunk(AiStreamChunk {
content: text_accumulated,
done: true,
chunk_type: AiChunkType::Answer,
})
.await;
return Ok(());
@ -748,7 +819,8 @@ impl ChatService {
/// Returns true if the error message indicates a transient failure that can be retried.
fn is_retryable_tool_error(msg: &str) -> bool {
let msg_lower = msg.to_lowercase();
// Transient errors: network, timeouts, rate limits, permission issues that may be temporary
// Transient errors: network, timeouts, rate limits
// Permission/access errors are NOT retryable — they won't succeed on retry.
msg_lower.contains("connection")
|| msg_lower.contains("timeout")
|| msg_lower.contains("timed out")
@ -762,9 +834,6 @@ impl ChatService {
|| msg_lower.contains("broken pipe")
|| msg_lower.contains("deadline exceeded")
|| msg_lower.contains("try again")
|| msg_lower.contains("not found") // DB/Redis transient not-found
|| msg_lower.contains("permission denied")
|| msg_lower.contains("access denied")
}
/// Process a request using the ReAct (Reasoning + Acting) agent.
@ -885,7 +954,7 @@ impl ChatService {
let tools = self.tools();
let config = ReactConfig {
max_steps: 20,
max_steps: request.max_tool_depth,
stop_sequences: Vec::new(),
tool_executor: Some(executor),
};

View File

@ -113,16 +113,17 @@ impl RetryState {
self.attempt < self.max_retries
}
/// Calculate backoff duration with "full jitter" technique.
/// Calculate backoff duration with full jitter technique.
/// sleep = random(0, min(cap, base * 2^attempt))
fn backoff_duration(&self) -> std::time::Duration {
let exp = self.attempt.min(5);
// base = 500 * 2^exp, capped at max_backoff_ms
let base_ms = 500u64
.saturating_mul(2u64.pow(exp))
.min(self.max_backoff_ms);
// jitter: random [0, base_ms/2]
let jitter = (fastrand_u64(base_ms / 2 + 1)) as u64;
std::time::Duration::from_millis(base_ms / 2 + jitter)
// Full jitter: random value in [0, base_ms]
let jitter = fastrand_u64(base_ms + 1) as u64;
std::time::Duration::from_millis(jitter)
}
fn next(&mut self) {
@ -239,7 +240,11 @@ pub async fn call_with_retry(
}
}
/// Call with custom parameters (temperature, max_tokens, optional tools).
/// Call with custom parameters (temperature, max_tokens, optional tools, optional tool_choice).
///
/// When `tool_choice` is `None` and tools are present, the default is `Auto`.
/// Pass `Some(ChatCompletionToolChoiceOption::None)` to force the model to respond
/// with text only (e.g. when you want JSON-in-text for ReAct parsing).
pub async fn call_with_params(
messages: &[ChatCompletionRequestMessage],
model: &str,
@ -248,6 +253,7 @@ pub async fn call_with_params(
max_tokens: u32,
max_retries: Option<u32>,
tools: Option<&[ChatCompletionTool]>,
tool_choice: Option<ChatCompletionToolChoiceOption>,
) -> Result<AiCallResponse> {
let client = config.build_client();
let mut state = RetryState::new(max_retries.unwrap_or(3));
@ -265,11 +271,7 @@ pub async fn call_with_params(
.map(|t| ChatCompletionTools::Function(t.clone()))
.collect()
}),
tool_choice: tools.filter(|ts| !ts.is_empty()).map(|_| {
ChatCompletionToolChoiceOption::Mode(
async_openai::types::chat::ToolChoiceOptions::Auto,
)
}),
tool_choice: tool_choice.clone(),
..Default::default()
};

View File

@ -137,8 +137,9 @@ impl EmbedClient {
text: &str,
room_id: &str,
user_id: Option<&str>,
model: &str,
) -> crate::Result<()> {
let vector = self.embed_text(text, "").await?;
let vector = self.embed_text(text, model).await?;
let point = EmbedVector {
id: id.to_string(),
vector,
@ -176,9 +177,10 @@ impl EmbedClient {
description: &str,
content: &str,
project_uuid: &str,
model: &str,
) -> crate::Result<()> {
let text = format!("{}: {} {}", name, description, content);
let vector = self.embed_text(&text, "").await?;
let vector = self.embed_text(&text, model).await?;
let point = EmbedVector {
id: id.to_string(),
vector,

View File

@ -188,7 +188,7 @@ impl EmbedService {
let desc = description.unwrap_or_default();
let id = skill_id.to_string();
self.client
.embed_skill(&id, name, desc, content, project_uuid)
.embed_skill(&id, name, desc, content, project_uuid, &self.model_name)
.await
}
@ -214,7 +214,7 @@ impl EmbedService {
) -> crate::Result<()> {
let id = message_id.to_string();
self.client
.embed_memory(&id, text, room_id, user_id)
.embed_memory(&id, text, room_id, user_id, &self.model_name)
.await
}

View File

@ -1,13 +1,14 @@
//! ReAct (Reasoning + Acting) agent core.
use async_openai::types::chat::FunctionCall;
use async_openai::types::chat::{
ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent,
ChatCompletionRequestMessage, ChatCompletionRequestToolMessage,
ChatCompletionRequestToolMessageContent, ChatCompletionRequestUserMessage,
ChatCompletionRequestUserMessageContent,
ChatCompletionRequestUserMessageContent, ToolChoiceOptions,
};
use async_openai::types::chat::ChatCompletionToolChoiceOption;
use async_openai::types::chat::FunctionCall;
use uuid::Uuid;
use std::sync::Arc;
@ -37,9 +38,11 @@ impl ReactAgent {
tools: Vec<async_openai::types::chat::ChatCompletionTool>,
config: ReactConfig,
) -> Self {
let messages = vec![ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(system_prompt.to_string()),
let messages = vec![ChatCompletionRequestMessage::System(
async_openai::types::chat::ChatCompletionRequestSystemMessage {
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
system_prompt.to_string(),
),
..Default::default()
},
)];
@ -109,15 +112,30 @@ impl ReactAgent {
{
loop {
if self.step_count >= self.config.max_steps {
return Err(AgentError::Internal(format!(
"ReAct agent reached max steps ({})",
// Emit a final Answer chunk so the caller receives a completion signal
// rather than a bare Err with no on_chunk notification.
let msg = format!(
"Agent reached maximum reasoning steps ({}) without producing a final answer.",
self.config.max_steps
)));
);
on_chunk(ReactStep::Answer {
step: self.step_count,
answer: msg.clone(),
});
return Ok(msg);
}
self.step_count += 1;
let step = self.step_count;
let tool_choice = if self.tool_definitions.is_empty() {
None
} else {
// Force text-only response so the model follows our JSON-in-text format.
// With tool_choice=Auto the model might return native tool_calls which
// the ReAct parser ignores.
Some(ChatCompletionToolChoiceOption::Mode(ToolChoiceOptions::None))
};
let response = call_with_params(
&self.messages,
model_name,
@ -130,6 +148,7 @@ impl ReactAgent {
} else {
Some(self.tool_definitions.as_slice())
},
tool_choice,
)
.await?;
@ -234,6 +253,10 @@ impl ReactAgent {
_ => {}
}
// Append assistant message with tool_calls so the Tool message has a matching parent.
let assistant_msg = build_tool_call_message(&act);
self.messages.push(assistant_msg);
// Append observation as a tool message so the model sees it in context.
self.messages.push(ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {

View File

@ -477,8 +477,9 @@ impl TaskService {
/// Propagate child task status up the tree.
///
/// When a child task reaches a terminal state, checks whether all its
/// siblings are also terminal. If so, marks the parent as failed so that
/// a stuck parent is never left in the `Running` state.
/// siblings are also terminal. If so, marks the parent appropriately:
/// - Done if any child succeeded
/// - Failed if all children failed or were cancelled
pub async fn propagate_to_parent(&self, task_id: i64) -> Result<Option<Model>, DbErr> {
let model = self
.get(task_id)
@ -496,9 +497,15 @@ impl TaskService {
})?;
if parent.is_running() {
let mut active: ActiveModel = parent.into();
active.status = sea_orm::Set(TaskStatus::Failed);
active.error =
sea_orm::Set(Some("All sub-tasks failed or were cancelled".to_string()));
let has_success = siblings.iter().any(|s| s.status == TaskStatus::Done);
if has_success {
active.status = sea_orm::Set(TaskStatus::Done);
active.error = sea_orm::Set(None);
} else {
active.status = sea_orm::Set(TaskStatus::Failed);
active.error =
sea_orm::Set(Some("All sub-tasks failed or were cancelled".to_string()));
}
active.done_at = sea_orm::Set(Some(chrono::Utc::now().into()));
active.updated_at = sea_orm::Set(chrono::Utc::now().into());
let updated = active.update(&self.db).await?;

View File

@ -125,6 +125,8 @@ pub fn truncate_to_token_budget(
while low + 100 < high {
let mid = (low + high) / 2;
// Find the nearest valid char boundary to avoid panicking on multi-byte UTF-8
let mid = text.floor_char_boundary(mid);
let candidate = &text[..mid];
let tokens = bpe.encode_ordinary(candidate);

View File

@ -22,11 +22,13 @@ impl ToolCall {
/// The result of executing a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
#[serde(tag = "status", content = "value")]
pub enum ToolResult {
/// Successful result with a JSON value.
#[serde(rename = "ok")]
Ok(serde_json::Value),
/// Error result with an error message.
#[serde(rename = "error")]
Error(String),
}

View File

@ -70,34 +70,33 @@ impl ToolExecutor {
ctx.increment_tool_calls();
let concurrency = self.max_concurrency;
use tokio::sync::Mutex as AsyncMutex;
let results: AsyncMutex<Vec<ToolCallResult>> =
AsyncMutex::new(Vec::with_capacity(calls.len()));
let calls_clone: Vec<ToolCall> = calls.clone();
stream::iter(calls.into_iter().map(|call| {
let child_ctx = ctx.child_context();
async move { self.execute_one(call, child_ctx).await }
}))
.buffer_unordered(concurrency)
.for_each_concurrent(
concurrency,
|result: Result<ToolCallResult, ToolError>| async {
let r = result.unwrap_or_else(|e| {
ToolCallResult::error(
ToolCall {
id: String::new(),
name: String::new(),
arguments: String::new(),
},
e.to_string(),
)
});
results.lock().await.push(r);
},
// Execute tool calls concurrently but preserve input order for ID matching.
// buffer_unordered returns results in *completion* order, which mispairs IDs
// on concurrent errors. Instead, track each result with its original index.
let indexed_results: Vec<(usize, Result<ToolCallResult, ToolError>)> = stream::iter(
calls.into_iter().enumerate().map(|(i, call)| {
let child_ctx = ctx.child_context();
async move { (i, self.execute_one(call, child_ctx).await) }
})
)
.buffer_unordered(concurrency)
.collect()
.await;
Ok(results.into_inner())
// Re-sort by original index to restore input order, then pair with original calls.
let mut result_map: std::collections::HashMap<usize, Result<ToolCallResult, ToolError>> =
indexed_results.into_iter().collect();
let results: Vec<ToolCallResult> = calls_clone.into_iter().enumerate().map(|(i, call)| {
let r = result_map.remove(&i).expect("every index must have a result");
r.unwrap_or_else(|e: ToolError| {
ToolCallResult::error(call, e.to_string())
})
}).collect();
Ok(results)
}
async fn execute_one(

View File

@ -0,0 +1,37 @@
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use session::Session;
use crate::ApiResponse;
#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
pub struct TriageIssueQuery {
pub issue_number: i64,
}
#[utoipa::path(
get,
path = "/api/agents/{project}/triage",
params(
("project" = String, Path, description = "Project name"),
("issue_number" = i64, Query, description = "Issue number to triage"),
),
responses(
(status = 200, description = "Issue triage result", body = ApiResponse<service::agent::issue_triage::IssueTriageResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Issue not found"),
),
tag = "Agent"
)]
pub async fn triage_issue(
service: web::Data<AppService>,
_session: Session,
path: web::Path<String>,
query: web::Query<TriageIssueQuery>,
) -> Result<HttpResponse, crate::error::ApiError> {
let project_name = path.into_inner();
let resp = service
.triage_issue(project_name, query.issue_number)
.await?;
Ok(crate::ApiResponse::ok(resp).to_response())
}

View File

@ -1,4 +1,5 @@
pub mod code_review;
pub mod issue_triage;
pub mod model;
pub mod model_capability;
pub mod model_parameter_profile;
@ -16,6 +17,10 @@ pub fn init_agent_routes(cfg: &mut web::ServiceConfig) {
"/code-review/{namespace}/{repo}",
web::post().to(code_review::trigger_code_review),
)
.route(
"/{project}/issues/{issue_number}/triage",
web::get().to(issue_triage::triage_issue),
)
.route(
"/pr-description/{namespace}/{repo}",
web::post().to(pr_summary::generate_pr_description),

View File

@ -1,5 +1,6 @@
use crate::{ApiResponse, error::ApiError};
use actix_web::{HttpResponse, Result, web};
use service::issue::IssueAddLabelsByNamesRequest;
use service::AppService;
use session::Session;
@ -85,3 +86,32 @@ pub async fn issue_label_remove(
.await?;
Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response())
}
#[utoipa::path(
post,
path = "/api/issue/{project}/issues/{number}/labels/bulk",
params(
("project" = String, Path),
("number" = i64, Path),
),
request_body = IssueAddLabelsByNamesRequest,
responses(
(status = 200, description = "Add labels to issue by name", body = ApiResponse<Vec<service::issue::IssueLabelResponse>>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
tag = "Issues"
)]
pub async fn issue_label_add_bulk(
service: web::Data<AppService>,
session: Session,
path: web::Path<(String, i64)>,
body: web::Json<IssueAddLabelsByNamesRequest>,
) -> Result<HttpResponse, ApiError> {
let (project, issue_number) = path.into_inner();
let resp = service
.issue_label_add_by_names(project, issue_number, body.into_inner(), &session)
.await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -255,6 +255,10 @@ pub fn init_issue_routes(cfg: &mut web::ServiceConfig) {
"/issues/{number}/labels",
web::post().to(issue_label::issue_label_add),
)
.route(
"/issues/{number}/labels/bulk",
web::post().to(issue_label::issue_label_add_bulk),
)
.route(
"/issues/{number}/labels/{label_id}",
web::delete().to(issue_label::issue_label_remove),

View File

@ -36,6 +36,7 @@ use utoipa::OpenApi;
crate::admin::alerts::admin_check_alerts,
// Agent (CRUD)
crate::agent::code_review::trigger_code_review,
crate::agent::issue_triage::triage_issue,
crate::agent::pr_summary::generate_pr_description,
crate::agent::provider::provider_list,
crate::agent::provider::provider_get,
@ -247,6 +248,7 @@ use utoipa::OpenApi;
crate::issue::issue_summary,
crate::issue::issue_label::issue_label_list,
crate::issue::issue_label::issue_label_add,
crate::issue::issue_label::issue_label_add_bulk,
crate::issue::issue_label::issue_label_remove,
crate::issue::label::label_list,
crate::issue::label::label_create,
@ -403,6 +405,7 @@ use utoipa::OpenApi;
crate::room::draft_and_history::mention_read_all,
// Search
crate::search::service::search,
crate::search::service::search_messages,
crate::room::reaction::message_search,
// User
crate::user::profile::get_my_profile,
@ -482,6 +485,7 @@ use utoipa::OpenApi;
service::issue::IssueCommentListResponse,
service::issue::IssueLabelResponse,
service::issue::IssueAddLabelRequest,
service::issue::IssueAddLabelsByNamesRequest,
service::issue::LabelResponse,
service::issue::CreateLabelRequest,
service::issue::ReactionAddRequest,
@ -585,6 +589,8 @@ use utoipa::OpenApi;
service::agent::code_review::TriggerCodeReviewRequest,
service::agent::code_review::TriggerCodeReviewResponse,
service::agent::code_review::CommentCreated,
service::agent::issue_triage::IssueTriageSuggestion,
service::agent::issue_triage::IssueTriageResponse,
service::agent::pr_summary::GeneratePrDescriptionRequest,
service::agent::pr_summary::GeneratePrDescriptionResponse,
service::agent::provider::ProviderResponse,
@ -715,6 +721,8 @@ use utoipa::OpenApi;
service::search::RepoSearchItem,
service::search::IssueSearchItem,
service::search::UserSearchItem,
service::search::GlobalMessageSearchResponse,
service::search::GlobalMessageSearchItem,
)
),
tags(

View File

@ -10,6 +10,7 @@ use uuid::Uuid;
use crate::error::ApiError;
use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent};
use room::types::NotificationEvent;
use room::connection::RoomConnectionManager;
use service::AppService;
@ -24,7 +25,7 @@ const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(1);
/// Unified push event from any subscribed room.
/// Unified push event from any subscribed room or user notification channel.
#[derive(Debug, Clone)]
pub enum WsPushEvent {
RoomMessage {
@ -44,6 +45,9 @@ pub enum WsPushEvent {
room_id: Uuid,
event: Arc<TypingEvent>,
},
Notification {
event: Arc<NotificationEvent>,
},
}
/// Maps room_id -> (room_message_broadcast_stream, stream_chunk_broadcast_stream)
@ -159,6 +163,10 @@ pub async fn ws_universal(
manager.metrics.ws_connections_active.increment(1.0);
manager.metrics.ws_connections_total.increment(1);
// Subscribe to user-level notification stream immediately on connect
let notif_rx = manager.subscribe_user_notification(user_id).await;
let mut notif_stream = BroadcastStream::new(notif_rx);
let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?;
actix::spawn(async move {
let handler = WsRequestHandler::new(Arc::new(service), user_id);
@ -195,6 +203,30 @@ pub async fn ws_universal(
let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await;
break;
}
notif_result = notif_stream.next() => {
match notif_result {
Some(Ok(event)) => {
let payload = serde_json::json!({
"type": "event",
"event": "notification_created",
"data": {
"event_type": event.event_type,
"notification": event.notification,
"deep_link_url": event.deep_link_url,
"timestamp": event.timestamp,
},
});
if session.text(payload.to_string()).await.is_err() {
break;
}
}
Some(Err(_)) | None => {
// Notification channel lagged or closed — re-subscribe
let rx = manager.subscribe_user_notification(user_id).await;
notif_stream = BroadcastStream::new(rx);
}
}
}
push_event = poll_push_streams(&mut push_streams, &manager, &handler.service(), user_id) => {
match push_event {
Some(WsPushEvent::RoomMessage { room_id, event }) => {
@ -268,6 +300,9 @@ pub async fn ws_universal(
}
None => {
}
Some(WsPushEvent::Notification { .. }) => {
// Notification events are handled via the notif_stream branch above
}
}
}
msg = msg_stream.recv() => {

View File

@ -4,4 +4,5 @@ use actix_web::web;
pub fn init_search_routes(cfg: &mut web::ServiceConfig) {
cfg.route("/search", web::to(service::search));
cfg.route("/search/messages", web::to(service::search_messages));
}

View File

@ -2,7 +2,7 @@ use crate::ApiResponse;
use crate::error::ApiError;
use actix_web::{HttpResponse, Result, web};
use service::AppService;
use service::search::{SearchQuery, SearchResponse};
use service::search::{GlobalMessageSearchQuery, GlobalMessageSearchResponse, SearchQuery, SearchResponse};
use session::Session;
#[utoipa::path(
@ -29,3 +29,27 @@ pub async fn search(
let resp = service.search(&session, query.into_inner()).await?;
Ok(ApiResponse::ok(resp).to_response())
}
#[utoipa::path(
get,
path = "/api/search/messages",
params(
("q" = String, Query, description = "Search keyword", min_length = 1, max_length = 200),
("page" = Option<u32>, Query, description = "Page number, default 1"),
("per_page" = Option<u32>, Query, description = "Results per page, default 20, max 100"),
),
responses(
(status = 200, description = "Message search results across all accessible rooms", body = ApiResponse<GlobalMessageSearchResponse>),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
tag = "Search"
)]
pub async fn search_messages(
service: web::Data<AppService>,
session: Session,
query: web::Query<GlobalMessageSearchQuery>,
) -> Result<HttpResponse, ApiError> {
let resp = service.global_message_search(&session, query.into_inner()).await?;
Ok(ApiResponse::ok(resp).to_response())
}

View File

@ -121,6 +121,7 @@ impl GitDomain {
committer_name: sig.name().unwrap_or("").to_string(),
committer_email: sig.email().unwrap_or("").to_string(),
time_secs: sig.when().seconds(),
offset_minutes: sig.when().offset_minutes(),
message: entry.message().map(String::from),
ref_name: refname.clone(),
});
@ -207,6 +208,7 @@ impl GitDomain {
committer_name: sig.name().unwrap_or("").to_string(),
committer_email: sig.email().unwrap_or("").to_string(),
time_secs: sig.when().seconds(),
offset_minutes: sig.when().offset_minutes(),
message: entry.message().map(String::from),
ref_name: refname.clone(),
});

View File

@ -145,6 +145,7 @@ pub struct CommitReflogEntry {
pub committer_name: String,
pub committer_email: String,
pub time_secs: i64,
pub offset_minutes: i32,
pub message: Option<String>,
pub ref_name: String,
}

View File

@ -19,6 +19,9 @@ pub struct RoomMessageEnvelope {
pub content_type: String,
pub send_at: DateTime<Utc>,
pub seq: i64,
/// Pre-resolved display name for the sender (e.g. AI model name).
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -75,7 +78,7 @@ impl From<RoomMessageEnvelope> for RoomMessageEvent {
content_type: e.content_type,
send_at: e.send_at,
seq: e.seq,
display_name: None,
display_name: e.display_name,
reactions: None,
message_id: None,
}
@ -102,6 +105,8 @@ pub struct RoomMessageStreamChunkEvent {
pub error: Option<String>,
/// Human-readable AI model name (e.g. "Claude 3.5 Sonnet") for display.
pub display_name: Option<String>,
/// What kind of content this chunk contains: "thinking", "answer", "tool_call", "tool_result".
pub chunk_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -177,6 +177,7 @@ impl RoomService {
content_type: content_type_str.clone(),
send_at: now,
seq,
display_name: None,
};
let db = &self.db;

View File

@ -267,7 +267,10 @@ impl RoomService {
user_id: Uuid,
notification: super::NotificationResponse,
) {
let event = super::NotificationEvent::new(notification.clone());
let deep_link = build_deep_link(&notification);
let deep_link_url = deep_link.clone();
let event = super::NotificationEvent::new(notification.clone())
.with_deep_link(deep_link);
self.room_manager
.push_user_notification(user_id, Arc::new(event))
.await;
@ -278,7 +281,7 @@ impl RoomService {
user_id,
notification.title.clone(),
notification.content.clone(),
None, // URL — could be derived from room/project
Some(deep_link_url),
);
}
}
@ -336,3 +339,20 @@ impl RoomService {
});
}
}
/// Build a frontend deep-link URL from the notification's related IDs.
fn build_deep_link(n: &super::NotificationResponse) -> String {
if let Some(room_id) = n.related_room_id {
return format!("/rooms/{}", room_id);
}
if let Some(room_id) = n.room {
return format!("/rooms/{}", room_id);
}
if let Some(_msg_id) = n.related_message_id {
return "/notify".to_string();
}
if let Some(project_id) = n.project {
return format!("/project/{}", project_id);
}
"/notify".to_string()
}

View File

@ -28,7 +28,8 @@ const DEFAULT_MAX_CONCURRENT_WORKERS: usize = 1024;
/// Callback type for sending push notifications.
/// The caller (AppService) provides this to RoomService so it can trigger
/// browser push notifications without depending on the service crate directly.
pub type PushNotificationFn = Arc<dyn Fn(Uuid, String, Option<String>, Option<String>) + Send + Sync>;
pub type PushNotificationFn =
Arc<dyn Fn(Uuid, String, Option<String>, Option<String>) + Send + Sync>;
/// Legacy: <user>uuid</user> or <user>username</user>
static USER_MENTION_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex> =
@ -116,7 +117,11 @@ impl RoomService {
// Save a clone for task subscriber handles before `project_ids` gets moved.
let task_project_ids = project_ids.clone();
tracing::info!(room_count = room_ids.len(), project_count = project_ids.len(), "starting room workers");
tracing::info!(
room_count = room_ids.len(),
project_count = project_ids.len(),
"starting room workers"
);
let persist_fn: PersistFn = make_persist_fn(
self.db.clone(),
@ -133,13 +138,7 @@ impl RoomService {
let get_redis = get_redis.clone();
let persist_fn = persist_fn.clone();
async move {
queue::start_worker(
worker_room_ids,
get_redis,
persist_fn,
worker_shutdown,
)
.await;
queue::start_worker(worker_room_ids, get_redis, persist_fn, worker_shutdown).await;
}
});
@ -761,11 +760,7 @@ impl RoomService {
/// - use_exact = false → respond to every text message.
/// - use_exact = true → only respond when the message contains an @[ai:...] or
/// <mention type="ai">... tag that mentions this room's configured AI model.
pub async fn should_ai_respond(
&self,
room_id: Uuid,
content: &str,
) -> Result<bool, RoomError> {
pub async fn should_ai_respond(&self, room_id: Uuid, content: &str) -> Result<bool, RoomError> {
use models::rooms::room_ai;
let ai_config = room_ai::Entity::find()
@ -1046,6 +1041,12 @@ impl RoomService {
// Clone display_name INSIDE the async block so the outer closure stays `Fn`.
let ai_display_name_for_chunk = ai_display_name_for_chunk.clone();
async move {
let chunk_type_str = match chunk.chunk_type {
agent::chat::AiChunkType::Thinking => "thinking",
agent::chat::AiChunkType::Answer => "answer",
agent::chat::AiChunkType::ToolCall => "tool_call",
agent::chat::AiChunkType::ToolResult => "tool_result",
};
let event = RoomMessageStreamChunkEvent {
message_id: streaming_msg_id,
room_id,
@ -1053,6 +1054,7 @@ impl RoomService {
done: chunk.done,
error: None,
display_name: Some(ai_display_name_for_chunk),
chunk_type: Some(chunk_type_str.to_string()),
};
room_manager.broadcast_stream_chunk(event).await;
@ -1091,6 +1093,7 @@ impl RoomService {
send_at: now,
seq,
in_reply_to: None,
display_name: Some(ai_display_name_for_final.clone()),
};
if let Err(e) = queue.publish(room_id_inner, envelope).await {
@ -1152,6 +1155,7 @@ impl RoomService {
done: true,
error: Some(e.to_string()),
display_name: Some(ai_display_name.clone()),
chunk_type: None,
};
room_manager.broadcast_stream_chunk(event).await;
}
@ -1376,17 +1380,15 @@ impl RoomService {
format!("[Thinking] {}", thought)
}
ReactStep::Action { step: _, action } => {
format!(
"[Action] Calling `{}` with {:?}",
action.name, action.args
)
format!("[Action] Calling `{}` with {:?}", action.name, action.args)
}
ReactStep::Observation { step: _, observation } => {
ReactStep::Observation {
step: _,
observation,
} => {
format!("[Observation] {}", observation)
}
ReactStep::Answer { step: _, answer } => {
answer.clone()
}
ReactStep::Answer { step: _, answer } => answer.clone(),
};
let is_answer = matches!(&step, ReactStep::Answer { .. });
@ -1408,6 +1410,11 @@ impl RoomService {
done,
error: None,
display_name: Some((*ai_name).clone()),
chunk_type: Some(if is_answer {
"answer".to_string()
} else {
"thinking".to_string()
}),
};
room_manager.broadcast_stream_chunk(event).await;
@ -1423,9 +1430,7 @@ impl RoomService {
}
};
let result = chat_service
.process_react(&request, on_step)
.await;
let result = chat_service.process_react(&request, on_step).await;
let final_content = answer_buffer.lock().unwrap().clone();
let reasoning_chain = reasoning_buffer.lock().unwrap().clone();
@ -1454,7 +1459,8 @@ impl RoomService {
format!(
"{}\n[Error during reasoning: {}]",
content_to_persist.trim_end(),
msg.trim_start_matches("[Agent error: ").trim_end_matches("]")
msg.trim_start_matches("[Agent error: ")
.trim_end_matches("]")
)
} else {
content_to_persist
@ -1482,6 +1488,7 @@ impl RoomService {
send_at: now,
seq,
in_reply_to: None,
display_name: Some(ai_display_name.clone()),
};
if let Err(e) = queue.publish(room_id_inner, envelope).await {
@ -1567,6 +1574,7 @@ impl RoomService {
send_at: now,
seq,
in_reply_to: None,
display_name: model_display_name.clone(),
};
queue.publish(room_id, envelope).await?;

View File

@ -369,6 +369,7 @@ pub struct NotificationListResponse {
pub struct NotificationEvent {
pub event_type: String,
pub notification: NotificationResponse,
pub deep_link_url: Option<String>,
pub timestamp: DateTime<Utc>,
}
@ -377,7 +378,13 @@ impl NotificationEvent {
Self {
event_type: "notification_created".into(),
notification,
deep_link_url: None,
timestamp: Utc::now(),
}
}
pub fn with_deep_link(mut self, url: String) -> Self {
self.deep_link_url = Some(url);
self
}
}

View File

@ -412,7 +412,7 @@ async fn call_ai_model(
),
];
agent::call_with_params(&messages, model_name, &client_config, 0.2, 8192, None, None)
agent::call_with_params(&messages, model_name, &client_config, 0.2, 8192, None, None, None)
.await
.map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e)))
}

View File

@ -0,0 +1,232 @@
//! AI-powered issue triage service.
//!
//! Analyzes newly created issues and suggests labels and priority.
use crate::AppService;
use crate::error::AppError;
use chrono::Utc;
use config::AppConfig;
use models::agents::ModelStatus;
use models::agents::model::{Column as MColumn, Entity as MEntity};
use models::issues::{issue, issue_comment};
use sea_orm::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IssueTriageSuggestion {
pub suggested_labels: Vec<String>,
pub priority: String,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IssueTriageResponse {
pub suggestions: Option<IssueTriageSuggestion>,
pub comment_posted: bool,
}
fn build_triage_prompt(title: &str, body: Option<&str>, existing_labels: &[String]) -> String {
let body_text = body.unwrap_or("(no description)");
let labels_text = if existing_labels.is_empty() {
"none".to_string()
} else {
existing_labels.join(", ")
};
format!(
r#"You are an expert software project manager. Analyze the following GitHub issue and suggest how to triage it.
Issue Title: {}
Issue Body:
{}
Existing Labels: {}
Based on the issue, suggest:
1. Additional labels from this standard set: bug, enhancement, documentation, question, help wanted, good first issue, priority:high, priority:medium, priority:low, kind:backend, kind:frontend, kind:dx, kind:security, kind:performance
2. A priority level: high, medium, or low
3. A brief reasoning for your assessment
Respond in JSON format like:
{{
"suggested_labels": ["bug", "priority:high"],
"priority": "high",
"reasoning": "This is a critical security vulnerability in the auth module..."
}}
Only suggest labels not already in the existing list. Be concise."#,
title, body_text, labels_text
)
}
fn parse_triage_response(content: &str) -> Option<IssueTriageSuggestion> {
let content = content.trim();
let json_str = if content.starts_with("```json") {
content
.strip_prefix("```json")?
.strip_prefix('\n')
.unwrap_or(content)
.trim_end_matches("```")
.trim()
} else if content.starts_with("```") {
content
.strip_prefix("```")?
.strip_prefix('\n')
.unwrap_or(content)
.trim_end_matches("```")
.trim()
} else {
content
};
let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?;
Some(IssueTriageSuggestion {
suggested_labels: parsed
.get("suggested_labels")?
.as_array()?
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
priority: parsed.get("priority")?.as_str()?.to_string(),
reasoning: parsed.get("reasoning")?.as_str()?.to_string(),
})
}
async fn call_ai_for_triage(
model_name: &str,
prompt: &str,
app_config: &AppConfig,
) -> Result<String, AppError> {
let api_key = app_config
.ai_api_key()
.map_err(|e| AppError::InternalServerError(format!("AI API key not configured: {}", e)))?;
let base_url = app_config
.ai_basic_url()
.unwrap_or_else(|_| "https://api.openai.com".into());
let client_config =
::agent::AiClientConfig::new(api_key).with_base_url(base_url);
let messages = vec![async_openai::types::chat::ChatCompletionRequestMessage::User(
async_openai::types::chat::ChatCompletionRequestUserMessage {
content:
async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
prompt.to_string(),
),
..Default::default()
},
)];
let response = ::agent::call_with_params(
&messages,
model_name,
&client_config,
0.3,
1024,
None,
None,
None,
)
.await
.map_err(|e| {
AppError::InternalServerError(format!("AI triage call failed: {}", e))
})?;
Ok(response.content)
}
impl AppService {
/// Run AI triage on a newly created issue and post a suggestion comment.
/// Called asynchronously after issue creation.
pub async fn triage_issue(
&self,
project_name: String,
issue_number: i64,
) -> Result<IssueTriageResponse, AppError> {
let project = self.utils_find_project_by_name(project_name.clone()).await?;
let issue_model = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.filter(issue::Column::Number.eq(issue_number))
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound("Issue not found".to_string()))?;
let existing_labels: Vec<String> = Vec::new();
let model = match MEntity::find()
.filter(MColumn::Status.eq(ModelStatus::Active.to_string()))
.order_by_asc(MColumn::Name)
.one(&self.db)
.await?
{
Some(m) => m,
None => {
tracing::debug!(
project = %project_name,
issue = issue_number,
"No active AI model for triage — skipping"
);
return Ok(IssueTriageResponse {
suggestions: None,
comment_posted: false,
});
}
};
let prompt =
build_triage_prompt(&issue_model.title, issue_model.body.as_deref(), &existing_labels);
let ai_content = match call_ai_for_triage(&model.name, &prompt, &self.config).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(
project = %project_name,
issue = issue_number,
error = ?e,
"AI triage failed"
);
return Ok(IssueTriageResponse {
suggestions: None,
comment_posted: false,
});
}
};
let suggestions = parse_triage_response(&ai_content);
let mut comment_posted = false;
if let Some(ref s) = suggestions {
let comment_body = format!(
"## AI Triage Suggestions\n\n**Priority:** *{}*\n\n{}\n\n**Suggested Labels:** \
{}\n\n_This analysis was generated automatically by the AI collaborator._",
s.priority.to_uppercase(),
s.reasoning,
if s.suggested_labels.is_empty() {
"none".to_string()
} else {
s.suggested_labels.join(", ")
}
);
let now = Utc::now();
let active = issue_comment::ActiveModel {
issue: Set(issue_model.id),
author: Set(Uuid::nil()),
body: Set(comment_body),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
if active.insert(&self.db).await.is_ok() {
comment_posted = true;
}
}
Ok(IssueTriageResponse {
suggestions,
comment_posted,
})
}
}

View File

@ -6,6 +6,7 @@ pub mod provider;
pub mod billing;
pub mod code_review;
pub mod issue_triage;
pub mod model;
pub mod pr_summary;
pub mod sync;

View File

@ -147,7 +147,7 @@ async fn call_ai_model_for_description(
),
];
agent::call_with_params(&messages, model_name, &client_config, 0.3, 4096, None, None)
agent::call_with_params(&messages, model_name, &client_config, 0.3, 4096, None, None, None)
.await
.map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e)))
}

View File

@ -201,11 +201,10 @@ async fn git_grep_exec(
}
fn glob_match(path: &str, pattern: &str) -> bool {
// Simple glob: support *, ?, **
let parts: Vec<&str> = pattern.split('/').collect();
let path_parts: Vec<&str> = path.split('/').collect();
let _path_lower = path.to_lowercase();
let path_lower = path.to_lowercase();
let pattern_lower = pattern.to_lowercase();
let parts: Vec<&str> = pattern_lower.split('/').collect();
let path_parts: Vec<&str> = path_lower.split('/').collect();
fn matches_part(path_part: &str, pattern_part: &str) -> bool {
if pattern_part.is_empty() || pattern_part == "*" {
@ -231,24 +230,33 @@ fn glob_match(path: &str, pattern: &str) -> bool {
if parts.len() == 1 {
// Simple glob pattern on filename only
let file_name = path_parts.last().unwrap_or(&"");
return matches_part(file_name, &pattern_lower);
return matches_part(file_name, &parts[0]);
}
// Multi-part glob
let mut pi = 0;
for part in &parts {
while pi < path_parts.len() {
if matches_part(path_parts[pi], part) {
pi += 1;
if *part == "**" {
// ** matches zero or more path segments
// If this is the last pattern part, consume all remaining path segments
if part == parts.last().unwrap() {
pi = path_parts.len();
break;
}
if *part != "**" {
return false;
// Try skipping segments until the next pattern part matches
let next_part = parts.iter().skip_while(|p| **p == "**").next().unwrap_or(&"*");
while pi < path_parts.len() && !matches_part(path_parts[pi], next_part) {
pi += 1;
}
pi += 1;
continue;
}
if pi >= path_parts.len() || !matches_part(path_parts[pi], part) {
return false;
}
pi += 1;
}
true
// All pattern parts consumed — check that all path segments were matched too
pi == path_parts.len()
}
pub fn register_grep_tools(registry: &mut ToolRegistry) {

View File

@ -154,7 +154,9 @@ async fn read_json_exec(
}
let text = String::from_utf8_lossy(data);
let is_jsonc = path.ends_with(".jsonc") || path.ends_with(".vscodeignore") || text.contains("//");
// Only treat as JSONC if the extension indicates it, or if we can
// confirm a comment-like pattern outside of a string context.
let is_jsonc = path.ends_with(".jsonc");
let json_text = if is_jsonc {
strip_jsonc_comments(&text)
@ -187,13 +189,15 @@ async fn read_json_exec(
"size_bytes": data.len(),
"schema": schema,
"data": if display.chars().count() > 5000 {
format!("{}... (truncated, {} chars total)", &display[..5000], display.chars().count())
let truncated: String = display.chars().take(5000).collect();
format!("{}... (truncated, {} chars total)", truncated, display.chars().count())
} else { display },
}))
}
/// Simple JSONPath-like query support.
/// Supports: $.key, $[0], $.key.nested, $.arr[0].field
/// Bracket notation ["key.with.dots"] allows accessing keys containing dots.
fn query_json(value: &JsonValue, query: &str) -> Result<JsonValue, String> {
let query = query.trim();
let query = if query.starts_with("$.") {
@ -206,43 +210,74 @@ fn query_json(value: &JsonValue, query: &str) -> Result<JsonValue, String> {
let mut current = value.clone();
for part in query.split('.') {
if part.is_empty() {
continue;
}
// Handle array index like [0]
if let Some(idx_start) = part.find('[') {
let key = &part[..idx_start];
// Parse into access segments: Key("name"), Index(0), BracketKey("key.with.dots")
enum Segment { Key(String), Index(usize), BracketKey(String) }
let mut segments: Vec<Segment> = Vec::new();
let mut i = 0;
let q_chars: Vec<char> = query.chars().collect();
while i < q_chars.len() {
if q_chars[i] == '[' {
// Find matching ]
let mut j = i + 1;
let mut bracket_content = String::new();
while j < q_chars.len() && q_chars[j] != ']' {
bracket_content.push(q_chars[j]);
j += 1;
}
if j < q_chars.len() && q_chars[j] == ']' {
let content = bracket_content.trim();
// Check if it's a quoted string key or a numeric index
if content.starts_with('"') && content.ends_with('"') {
let key = content[1..content.len()-1].to_string();
segments.push(Segment::BracketKey(key));
} else if content.starts_with("'") && content.ends_with("'") {
let key = content[1..content.len()-1].to_string();
segments.push(Segment::BracketKey(key));
} else if let Ok(idx) = content.parse::<usize>() {
segments.push(Segment::Index(idx));
} else {
return Err(format!("Invalid bracket notation: [{}]", content));
}
i = j + 1;
// Skip dot after bracket if present
if i < q_chars.len() && q_chars[i] == '.' {
i += 1;
}
} else {
return Err("Unmatched [ in query".into());
}
} else {
// Read key until . or [
let mut key = String::new();
while i < q_chars.len() && q_chars[i] != '.' && q_chars[i] != '[' {
key.push(q_chars[i]);
i += 1;
}
if !key.is_empty() {
// Check if key contains a numeric-only segment (array index shorthand)
segments.push(Segment::Key(key));
}
if i < q_chars.len() && q_chars[i] == '.' {
i += 1;
}
}
}
for seg in &segments {
match seg {
Segment::Key(key) | Segment::BracketKey(key) => {
if let JsonValue::Object(obj) = &current {
current = obj.get(key).cloned().unwrap_or(JsonValue::Null);
} else {
return Err(format!("cannot access property '{}' on non-object", key));
}
}
let rest = &part[idx_start..];
for bracket in rest.split_inclusive(']') {
if bracket.is_empty() || bracket == "]" {
continue;
Segment::Index(idx) => {
if let JsonValue::Array(arr) = &current {
current = arr.get(*idx).cloned().unwrap_or(JsonValue::Null);
} else {
return Err(format!("index {} on non-array", idx));
}
let inner = bracket.trim_end_matches(']');
if let Some(idx) = inner.strip_prefix('[') {
if let Ok(index) = idx.parse::<usize>() {
if let JsonValue::Array(arr) = &current {
current = arr.get(index).cloned().unwrap_or(JsonValue::Null);
} else {
return Err(format!("index {} on non-array", index));
}
}
}
}
} else {
if let JsonValue::Object(obj) = &current {
current = obj.get(part).cloned().unwrap_or(JsonValue::Null);
} else {
return Err(format!("property '{}' not found", part));
}
}
}

View File

@ -62,6 +62,7 @@ impl From<git::ConfigSnapshot> for ConfigSnapshotResponse {
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct GitUpdateRepoRequest {
pub default_branch: Option<String>,
pub ai_code_review_enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ConfigBoolResponse {
@ -459,6 +460,9 @@ impl AppService {
if let Some(default_branch) = params.default_branch {
active.default_branch = Set(default_branch);
}
if let Some(ai_enabled) = params.ai_code_review_enabled {
active.ai_code_review_enabled = Set(ai_enabled);
}
active.update(&txn).await?;
txn.commit().await?;
Ok(())

View File

@ -55,7 +55,15 @@ async fn git_branches_merged_exec(ctx: GitToolCtx, args: serde_json::Value) -> R
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))
// Resolve branch names to commit OIDs before calling merge_base
let branch_oid = domain.branch_target(branch)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("branch '{}' not found or has no target", branch))?;
let into_oid = domain.branch_target(&into)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("branch '{}' not found or has no target", into))?;
let merge_base = domain.merge_base(&branch_oid, &into_oid)
.map(|oid| oid.to_string()).ok();
Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base }))

View File

@ -22,7 +22,7 @@ async fn git_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_
// 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 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));
@ -63,7 +63,7 @@ async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
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 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));
@ -90,7 +90,9 @@ async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Re
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())?;
// Fetch extra commits to have enough candidates after filtering
let walk_limit = limit.saturating_mul(2).max(100);
let commits = domain.commit_log(Some("HEAD"), 0, walk_limit).map_err(|e| e.to_string())?;
let q = query.to_lowercase();
let result: Vec<_> = commits.iter()
@ -104,7 +106,7 @@ async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Re
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 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();
@ -160,7 +162,7 @@ async fn git_graph_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serd
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 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));

View File

@ -42,10 +42,19 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
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())?
// Bare repos have no working tree — use tree-to-tree diff instead
if domain.repo().is_bare() {
domain.diff_tree_to_tree(None, Some(&head_meta.oid), opts).map_err(|e| e.to_string())?
} else {
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())?
if domain.repo().is_bare() {
domain.diff_tree_to_tree(Some(base), None, opts).map_err(|e| e.to_string())?
} else {
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())?
@ -74,12 +83,20 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
let domain = ctx.open_repo(project_name, repo_name).await?;
let stats = if base.len() >= 40 || head.len() >= 40 {
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;
let b = if base.len() >= 40 {
git::commit::types::CommitOid::new(base)
} else {
domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid
};
let h = if head.len() >= 40 {
git::commit::types::CommitOid::new(head)
} else {
domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid
};
domain.diff_stats(&b, &h).map_err(|e| e.to_string())?
};

View File

@ -77,10 +77,13 @@ async fn git_file_history_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resu
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(String::from).unwrap_or_else(|| "HEAD".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(Some("HEAD"), 0, 500).map_err(|e| e.to_string())?;
// Fetch extra commits to have enough candidates after filtering
let walk_limit = limit.saturating_mul(2).max(200);
let commits = domain.commit_log(Some(&rev), 0, walk_limit).map_err(|e| e.to_string())?;
let result: Vec<_> = commits.iter()
.filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok())
@ -125,7 +128,7 @@ async fn git_blob_get_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
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 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();
@ -182,6 +185,7 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to trace history for".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to start history from (default: HEAD)".into()), required: false, properties: None, items: None }),
("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of commits to return (default: 20)".into()), required: false, properties: None, items: None }),
]);
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };

View File

@ -6,7 +6,7 @@
use base64::Engine;
use chrono::TimeZone;
use git::commit::types::{CommitMeta, CommitReflogEntry};
use git::diff::types::{DiffDelta, DiffStats};
use git::diff::types::{DiffDelta, DiffDeltaStatus, DiffStats};
use git::tree::types::TreeEntry;
use serde::{Deserialize, Serialize};
@ -121,7 +121,8 @@ pub struct ReflogEntryInfo {
impl ReflogEntryInfo {
pub fn from_entry(entry: &CommitReflogEntry) -> Self {
let ts = entry.time_secs;
let time = format_rfc3339(ts, 0);
let offset = entry.offset_minutes;
let time = format_rfc3339(ts, offset);
Self {
oid_new: entry.oid_new.to_string(),
oid_old: entry.oid_old.to_string(),
@ -216,8 +217,13 @@ pub struct DiffFileOut {
impl DiffFileOut {
pub fn from_delta(delta: &DiffDelta) -> Self {
// For deleted files, use old_file.path; for all others, use new_file.path.
let path = match delta.status {
DiffDeltaStatus::Deleted => delta.old_file.path.clone(),
_ => delta.new_file.path.clone(),
};
Self {
path: delta.new_file.path.clone(),
path,
status: format!("{:?}", delta.status),
is_binary: delta.new_file.is_binary,
size: delta.new_file.size,
@ -402,7 +408,8 @@ impl From<&git::commit::graph::CommitGraphLine> for GraphLineOut {
// ---------------------------------------------------------------------------
fn format_rfc3339(time_secs: i64, offset_minutes: i32) -> String {
let secs = time_secs + (offset_minutes as i64 * 60);
// Git stores local time + offset. To convert to UTC, subtract the offset.
let secs = time_secs - (offset_minutes as i64 * 60);
chrono::Utc
.timestamp_opt(secs, 0)
.single()

View File

@ -222,7 +222,7 @@ impl AppService {
ctx: &Session,
) -> Result<IssueResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let project = self.utils_find_project_by_name(project_name.clone()).await?;
// Any project member can create issues
let member = project_members::Entity::find()
@ -280,6 +280,15 @@ impl AppService {
)
.await;
// Run AI triage asynchronously
let project_name_clone = project_name.clone();
let issue_number = number;
let this = self.clone();
drop(project_name); // allow move below
tokio::spawn(async move {
let _ = this.triage_issue(project_name_clone, issue_number).await;
});
Ok(IssueResponse::from(model))
}

View File

@ -16,6 +16,38 @@ pub struct IssueAddLabelRequest {
pub label_id: i64,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct IssueAddLabelsByNamesRequest {
pub names: Vec<String>,
}
fn default_color_for_label(name: &str) -> String {
let lower = name.to_lowercase();
if lower.contains("bug") || lower.contains("critical") || lower.contains("security") {
"ef4444".to_string()
} else if lower.contains("enhancement") || lower.contains("feature") || lower.contains("improvement") {
"22c55e".to_string()
} else if lower.contains("documentation") || lower.contains("docs") {
"3b82f6".to_string()
} else if lower.contains("question") || lower.contains("help wanted") {
"a855f7".to_string()
} else if lower.contains("good first") || lower.contains("beginner") || lower.contains("easy") {
"10b981".to_string()
} else if lower.contains("priority") || lower.contains("high") {
"f97316".to_string()
} else if lower.contains("backend") || lower.contains("server") {
"6366f1".to_string()
} else if lower.contains("frontend") || lower.contains("ui") || lower.contains("ux") {
"ec4899".to_string()
} else if lower.contains("performance") || lower.contains("optimize") {
"eab308".to_string()
} else if lower.contains("dx") || lower.contains("dev") || lower.contains("tool") {
"14b8a6".to_string()
} else {
"6b7280".to_string()
}
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateLabelRequest {
pub name: String,
@ -285,6 +317,91 @@ impl AppService {
response
}
/// Add labels to an issue by name, creating missing labels automatically.
pub async fn issue_label_add_by_names(
&self,
project_name: String,
issue_number: i64,
request: IssueAddLabelsByNamesRequest,
ctx: &Session,
) -> Result<Vec<IssueLabelResponse>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self.utils_find_project_by_name(project_name).await?;
let _member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id))
.filter(project_members::Column::User.eq(user_uid))
.one(&self.db)
.await?
.ok_or(AppError::NoPower)?;
let issue = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id))
.filter(issue::Column::Number.eq(issue_number))
.one(&self.db)
.await?
.ok_or(AppError::NotFound("Issue not found".to_string()))?;
let mut added: Vec<IssueLabelResponse> = Vec::new();
for name in request.names {
let color = default_color_for_label(&name);
// Find or create label
let lbl = label::Entity::find()
.filter(label::Column::Project.eq(project.id))
.filter(label::Column::Name.eq(&name))
.one(&self.db)
.await?;
let lbl = match lbl {
Some(l) => l,
None => {
let active = label::ActiveModel {
id: Set(0),
project: Set(project.id),
name: Set(name.clone()),
color: Set(color),
..Default::default()
};
active.insert(&self.db).await?
}
};
// Check if already linked
let existing = issue_label::Entity::find()
.filter(issue_label::Column::Issue.eq(issue.id))
.filter(issue_label::Column::Label.eq(lbl.id))
.one(&self.db)
.await?;
if existing.is_some() {
continue;
}
let now = Utc::now();
let active = issue_label::ActiveModel {
issue: Set(issue.id),
label: Set(lbl.id),
relation_at: Set(now),
..Default::default()
};
let model = active.insert(&self.db).await?;
added.push(IssueLabelResponse {
issue: model.issue,
label_id: model.label,
label_name: Some(lbl.name.clone()),
label_color: Some(lbl.color.clone()),
relation_at: model.relation_at,
});
}
if !added.is_empty() {
self.invalidate_issue_cache(project.id, issue_number).await;
}
Ok(added)
}
/// Remove a label from an issue.
pub async fn issue_label_remove(
&self,

View File

@ -16,7 +16,7 @@ pub use comment::{
pub use issue::{
IssueCreateRequest, IssueListResponse, IssueResponse, IssueSummaryResponse, IssueUpdateRequest,
};
pub use label::{CreateLabelRequest, IssueAddLabelRequest, IssueLabelResponse, LabelResponse};
pub use label::{CreateLabelRequest, IssueAddLabelRequest, IssueAddLabelsByNamesRequest, IssueLabelResponse, LabelResponse};
pub use pull_request::{IssueLinkPullRequestRequest, IssuePullRequestResponse};
pub use reaction::{ReactionAddRequest, ReactionListResponse, ReactionResponse};
pub use repo::{IssueLinkRepoRequest, IssueRepoResponse};

View File

@ -36,6 +36,7 @@ pub struct ProjectRepositoryItem {
pub last_commit_at: Option<DateTime<Utc>>,
pub ssh_clone_url: String,
pub https_clone_url: String,
pub ai_code_review_enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
@ -226,6 +227,7 @@ impl AppService {
last_commit_at: last_commit_times.get(&r.id).and_then(|t| *t),
ssh_clone_url: format!("git@{}:{}", ssh_domain, path),
https_clone_url: format!("https://{}/{}", ssh_domain, path),
ai_code_review_enabled: r.ai_code_review_enabled,
}
})
.collect();

View File

@ -9,7 +9,7 @@ const DEFAULT_MAX_RESULTS: usize = 10;
const MAX_MAX_RESULTS: usize = 50;
/// arXiv API base URL (Atom feed).
const ARXIV_API: &str = "http://export.arxiv.org/api/query";
const ARXIV_API: &str = "https://export.arxiv.org/api/query";
/// arXiv Atom feed entry fields we care about.
#[derive(Debug, Deserialize)]

View File

@ -274,6 +274,8 @@ pub async fn create_board_card_exec(
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let board_id = args
.get("board_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())

View File

@ -1,11 +1,80 @@
//! Tool: project_curl — perform HTTP requests (GET/POST/PUT/DELETE)
//!
//! Security measures:
//! - SSRF protection: blocks private IPs and blocks redirects to private IPs
//! - Sensitive header injection: blocks Host, Authorization, Cookie, Proxy-*
//! - Connection pooling via a shared reqwest::Client
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use std::collections::HashMap;
use std::sync::OnceLock;
/// Maximum response body size: 1 MB.
const MAX_BODY_BYTES: usize = 1 << 20;
/// Headers that are blocked from user-supplied values to prevent injection attacks.
const BLOCKED_HEADERS: &[&str] = &[
"host", "authorization", "cookie", "proxy-authorization",
"proxy-connection", "proxy-authenticate",
];
/// Shared reqwest::Client for connection pooling.
static SHARED_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
fn shared_client() -> &'static reqwest::Client {
SHARED_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
// Block automatic redirect following so we can validate each hop
.redirect(reqwest::redirect::Policy::limited(0))
.build()
.expect("reqwest client build should not fail")
})
}
/// Check if a host string resolves to or is a private/internal IP.
fn is_private_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost")
|| host.eq_ignore_ascii_case("127.0.0.1")
|| host.eq_ignore_ascii_case("::1")
|| host.eq_ignore_ascii_case("0.0.0.0")
|| host.eq_ignore_ascii_case("metadata.google.internal")
|| host.eq_ignore_ascii_case("169.254.169.254")
|| host.starts_with("10.")
|| host.starts_with("172.16.")
|| host.starts_with("172.17.")
|| host.starts_with("172.18.")
|| host.starts_with("172.19.")
|| host.starts_with("172.20.")
|| host.starts_with("172.21.")
|| host.starts_with("172.22.")
|| host.starts_with("172.23.")
|| host.starts_with("172.24.")
|| host.starts_with("172.25.")
|| host.starts_with("172.26.")
|| host.starts_with("172.27.")
|| host.starts_with("172.28.")
|| host.starts_with("172.29.")
|| host.starts_with("172.30.")
|| host.starts_with("172.31.")
|| host.starts_with("192.168.")
}
/// Validate URL and any redirect hops against SSRF rules.
fn validate_url_against_ssrf(url_str: &str) -> Result<reqwest::Url, ToolError> {
let parsed = reqwest::Url::parse(url_str)
.map_err(|e| ToolError::ExecutionError(format!("Invalid URL: {}", e)))?;
if let Some(host) = parsed.host_str() {
if is_private_host(host) {
return Err(ToolError::ExecutionError(
"Requests to internal/private IPs are not allowed for security reasons".into(),
));
}
}
Ok(parsed)
}
/// Perform an HTTP request and return the response body and metadata.
/// Supports GET, POST, PUT, DELETE methods. Useful for fetching web pages,
/// calling external APIs, or downloading resources.
@ -13,11 +82,14 @@ pub async fn curl_exec(
_ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let url = args
let url_str = args
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("url is required".into()))?;
// SSRF protection: validate initial URL
validate_url_against_ssrf(url_str)?;
let method = args
.get("method")
.and_then(|v| v.as_str())
@ -36,104 +108,156 @@ pub async fn curl_exec(
})
.unwrap_or_default();
// Block sensitive headers that could be used for injection attacks
for (key, _) in &headers {
if BLOCKED_HEADERS.contains(&key.to_lowercase().as_str()) {
return Err(ToolError::ExecutionError(
format!("Header '{}' is not allowed for security reasons", key),
));
}
}
let timeout_secs = args
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(30)
.min(120);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| ToolError::ExecutionError(format!("Failed to build HTTP client: {}", e)))?;
let client = shared_client();
// Build a per-request client with the specific timeout by using the shared
// client's connection pool but overriding timeout per request via request builder.
// Since reqwest::Client::builder().redirect(Policy::limited(0)) disables auto-redirects,
// we manually follow up to 5 redirects with SSRF validation on each hop.
let mut request = match method.as_str() {
"GET" => client.get(url),
"POST" => client.post(url),
"PUT" => client.put(url),
"DELETE" => client.delete(url),
"PATCH" => client.patch(url),
"HEAD" => client.head(url),
_ => {
return Err(ToolError::ExecutionError(format!(
"Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.",
method
)))
let mut current_url = url_str.to_string();
let mut redirect_count = 0u32;
const MAX_REDIRECTS: u32 = 5;
loop {
let mut request = match method.as_str() {
"GET" => client.get(&current_url),
"POST" => client.post(&current_url),
"PUT" => client.put(&current_url),
"DELETE" => client.delete(&current_url),
"PATCH" => client.patch(&current_url),
"HEAD" => client.head(&current_url),
_ => {
return Err(ToolError::ExecutionError(format!(
"Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.",
method
)))
}
};
request = request.timeout(std::time::Duration::from_secs(timeout_secs));
for (key, value) in &headers {
request = request.header(key, value);
}
};
for (key, value) in &headers {
request = request.header(key, value);
}
// Set default Content-Type for POST/PUT/PATCH if not provided and body exists
if body.is_some() && !headers.iter().any(|(k, _)| k.to_lowercase() == "content-type") {
request = request.header("Content-Type", "application/json");
}
// Set default Content-Type for POST/PUT/PATCH if not provided and body exists
if body.is_some() && !headers.iter().any(|(k, _)| k.to_lowercase() == "content-type") {
request = request.header("Content-Type", "application/json");
}
if let Some(ref b) = body {
request = request.body(b.clone());
}
if let Some(ref b) = body {
request = request.body(b.clone());
}
let response = request
.send()
.await
.map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?;
let response = request
.send()
.await
.map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?;
let status = response.status().as_u16();
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("");
// Handle redirects manually with SSRF validation
if status >= 300 && status < 400 {
redirect_count += 1;
if redirect_count > MAX_REDIRECTS {
return Err(ToolError::ExecutionError(
format!("Too many redirects (max {})", MAX_REDIRECTS),
));
}
let location = response.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let location = match location {
Some(l) => l,
None => return Err(ToolError::ExecutionError("Redirect with no Location header".into())),
};
// Resolve relative redirect against current URL
let base = reqwest::Url::parse(&current_url)
.map_err(|e| ToolError::ExecutionError(format!("Invalid current URL: {}", e)))?;
let next_url = base.join(&location)
.map_err(|e| ToolError::ExecutionError(format!("Invalid redirect URL: {}", e)))?;
// Validate redirect target against SSRF rules
if let Some(host) = next_url.host_str() {
if is_private_host(host) {
return Err(ToolError::ExecutionError(
"Redirect to internal/private IP is not allowed".into(),
));
}
}
current_url = next_url.to_string();
continue;
}
let response_headers: std::collections::HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| {
(
k.to_string(),
v.to_str().unwrap_or("<binary>").to_string(),
let status_text = response.status().canonical_reason().unwrap_or("");
let response_headers: std::collections::HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| {
(
k.to_string(),
v.to_str().unwrap_or("<binary>").to_string(),
)
})
.collect();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let is_text = content_type.starts_with("text/")
|| content_type.contains("json")
|| content_type.contains("xml")
|| content_type.contains("javascript");
let body_bytes = response
.bytes()
.await
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response body: {}", e)))?;
let body_len = body_bytes.len();
let truncated = body_len > MAX_BODY_BYTES;
let body_text = if truncated {
String::from("[Response truncated — exceeds 1 MB limit]")
} else if is_text {
String::from_utf8_lossy(&body_bytes).to_string()
} else {
format!(
"[Binary body, {} bytes, Content-Type: {}]",
body_len, content_type
)
})
.collect();
};
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let is_text = content_type.starts_with("text/")
|| content_type.contains("json")
|| content_type.contains("xml")
|| content_type.contains("javascript");
let body_bytes = response
.bytes()
.await
.map_err(|e| ToolError::ExecutionError(format!("Failed to read response body: {}", e)))?;
let body_len = body_bytes.len();
let truncated = body_len > MAX_BODY_BYTES;
let body_text = if truncated {
String::from("[Response truncated — exceeds 1 MB limit]")
} else if is_text {
String::from_utf8_lossy(&body_bytes).to_string()
} else {
format!(
"[Binary body, {} bytes, Content-Type: {}]",
body_len, content_type
)
};
Ok(serde_json::json!({
"url": url,
"method": method,
"status": status,
"status_text": status_text,
"headers": response_headers,
"body": body_text,
"truncated": truncated,
"size_bytes": body_len,
}))
return Ok(serde_json::json!({
"url": current_url,
"method": method,
"status": status,
"status_text": status_text,
"headers": response_headers,
"body": body_text,
"truncated": truncated,
"size_bytes": body_len,
}));
}
}
// ─── tool definition ─────────────────────────────────────────────────────────

View File

@ -224,6 +224,17 @@ pub async fn create_issue_exec(
.sender_id()
.ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?;
// Membership check: only project members can create issues
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(author_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if member.is_none() {
return Err(ToolError::ExecutionError("You are not a member of this project".into()));
}
let number = next_issue_number(db, project_id).await?;
let now = Utc::now();
@ -248,7 +259,8 @@ pub async fn create_issue_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Add assignees
// Add assignees (collect errors for partial failure reporting)
let mut assignee_errors = Vec::new();
for uid in &assignee_ids {
let a = issue_assignee::ActiveModel {
issue: Set(model.id),
@ -256,10 +268,13 @@ pub async fn create_issue_exec(
assigned_at: Set(now),
..Default::default()
};
let _ = a.insert(db).await;
if let Err(e) = a.insert(db).await {
assignee_errors.push(format!("assignee {}: {}", uid, e));
}
}
// Add labels
let mut label_errors = Vec::new();
for lid in &label_ids {
let l = issue_label::ActiveModel {
issue: Set(model.id),
@ -267,7 +282,9 @@ pub async fn create_issue_exec(
relation_at: Set(now),
..Default::default()
};
let _ = l.insert(db).await;
if let Err(e) = l.insert(db).await {
label_errors.push(format!("label {}: {}", lid, e));
}
}
// Build assignee/label maps for response
@ -330,6 +347,11 @@ pub async fn create_issue_exec(
"updated_at": model.updated_at.to_rfc3339(),
"assignees": assignee_ids.iter().filter_map(|uid| assignee_map.get(uid)).collect::<Vec<_>>(),
"labels": label_ids.iter().filter_map(|lid| label_map.get(lid)).collect::<Vec<_>>(),
"warnings": if assignee_errors.is_empty() && label_errors.is_empty() {
None
} else {
Some([assignee_errors, label_errors].concat())
},
}))
}

View File

@ -4,6 +4,7 @@ use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use chrono::Utc;
use git::commit::types::CommitOid;
use git::commit::types::CommitSignature;
use git2;
use models::projects::{MemberRole, ProjectMember};
use models::projects::project_members;
use models::repos::repo;
@ -85,6 +86,16 @@ pub async fn create_repo_exec(
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
// Validate repo name: no path traversal, no special chars
if repo_name.contains("..") || repo_name.contains('/') || repo_name.contains('\\')
|| repo_name.is_empty() || repo_name.len() > 100
|| !repo_name.chars().next().map_or(false, |c| c.is_alphanumeric())
{
return Err(ToolError::ExecutionError(
"Invalid repository name: must start with alphanumeric, contain no path separators or '..', max 100 chars".into(),
));
}
let description = args
.get("description")
.and_then(|v| v.as_str())
@ -145,13 +156,16 @@ pub async fn create_repo_exec(
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Initialize the bare git repository on disk
git2::Repository::init_bare(&repo_dir)
.map_err(|e| ToolError::ExecutionError(format!("Failed to init bare repo: {}", e)))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"name": model.repo_name,
"description": model.description,
"default_branch": model.default_branch,
"is_private": model.is_private,
"storage_path": model.storage_path,
"created_at": model.created_at.to_rfc3339(),
}))
}
@ -294,6 +308,13 @@ pub async fn create_commit_exec(
.unwrap_or("main")
.to_string();
// Validate branch: no path traversal, no slashes
if branch.contains("..") || branch.contains('/') || branch.contains('\\') || branch.is_empty() {
return Err(ToolError::ExecutionError(
"Invalid branch name: must not contain path separators or '..'".into(),
));
}
let files = args
.get("files")
.and_then(|v| v.as_array())
@ -350,32 +371,75 @@ pub async fn create_commit_exec(
let repo = domain.repo();
// Get current head commit (parent)
// If the repo already has commits (has HEAD), the branch must exist.
// Only allow root commits on truly empty repos (no HEAD at all).
let has_head = repo.head().is_ok();
let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok();
if has_head && parent_oid.is_none() {
return Err(ToolError::ExecutionError(
format!("Branch '{}' does not exist in this repository", branch),
));
}
let parent_ids: Vec<CommitOid> = parent_oid
.map(|oid| CommitOid::from_git2(oid))
.into_iter()
.collect();
// Build index with new files
// Build index from existing tree first (preserves all previous files),
// then add/overwrite with the new files.
let mut index = repo
.index()
.map_err(|e| ToolError::ExecutionError(format!("Failed to get index: {}", e)))?;
// If repo has a parent commit, read its tree into the index so we don't
// lose existing files when write_tree() is called.
if let Some(oid) = &parent_oid {
let parent_commit = repo.find_commit(*oid)
.map_err(|e| ToolError::ExecutionError(format!("Failed to find parent commit: {}", e)))?;
let parent_tree = parent_commit.tree()
.map_err(|e| ToolError::ExecutionError(format!("Failed to get parent tree: {}", e)))?;
index.read_tree(&parent_tree)
.map_err(|e| ToolError::ExecutionError(format!("Failed to read parent tree into index: {}", e)))?;
}
for file in files_data {
let path = file
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("Each file must have a 'path'".into()))?;
// Validate path: no traversal, no absolute paths, no .git/ prefix
if path.contains("..") || path.starts_with('/') || path.starts_with('\\')
|| path.is_empty() || path.starts_with(".git/") || path == ".git"
{
return Err(ToolError::ExecutionError(
format!("Invalid file path '{}': must be relative, no '..' or absolute path components", path)
));
}
let content = file
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("Each file must have 'content'".into()))?;
let _oid = repo.blob(content.as_bytes()).map_err(|e| {
ToolError::ExecutionError(format!("Failed to write blob for '{}': {}", path, e))
})?;
index.add_path(path.as_ref()).map_err(|e| {
// add_frombuffer requires an IndexEntry with at minimum a path field set.
// It works for both bare and non-bare repos (add_path requires a working tree).
let mut entry = git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100644,
uid: 0,
gid: 0,
file_size: 0,
id: git2::Oid::zero(),
flags: 0,
flags_extended: 0,
path: path.as_bytes().to_vec(),
};
index.add_frombuffer(&mut entry, content.as_bytes()).map_err(|e| {
ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e))
})?;
}

View File

@ -5,6 +5,7 @@ use db::database::AppDatabase;
use models::issues::issue;
use models::projects::{project, project_members};
use models::repos::repo;
use models::rooms::{room, room_member};
use models::users::user;
use sea_orm::*;
use sea_query::{Expr as SqExpr, extension::postgres::PgExpr};
@ -113,6 +114,39 @@ pub struct UserSearchItem {
pub created_at: DateTime<Utc>,
}
// ─── Global message search ────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct GlobalMessageSearchQuery {
#[param(min_length = 1, max_length = 200)]
pub q: String,
pub page: Option<u32>,
pub per_page: Option<u32>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GlobalMessageSearchItem {
pub id: Uuid,
pub room_id: Uuid,
pub room_name: String,
pub sender_id: Option<Uuid>,
pub sender_type: String,
pub display_name: Option<String>,
pub content: String,
pub content_type: String,
pub send_at: DateTime<Utc>,
pub highlighted_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GlobalMessageSearchResponse {
pub query: String,
pub messages: Vec<GlobalMessageSearchItem>,
pub total: i64,
pub page: u32,
pub per_page: u32,
}
// ─── Per-type result set ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, ToSchema)]
@ -465,4 +499,157 @@ impl AppService {
Ok(SearchResultSet::new(items, total, page, per_page))
}
/// Search messages across all rooms the current user can access.
/// Uses PostgreSQL full-text search with ts_headline for result highlighting.
pub async fn global_message_search(
&self,
ctx: &Session,
params: GlobalMessageSearchQuery,
) -> Result<GlobalMessageSearchResponse, AppError> {
let user_id = ctx.user();
// Anonymous users cannot search messages
let Some(user_id) = user_id else {
return Err(AppError::Unauthorized);
};
if params.q.trim().is_empty() {
return Ok(GlobalMessageSearchResponse {
query: params.q.clone(),
messages: Vec::new(),
total: 0,
page: params.page.unwrap_or(1),
per_page: params.per_page.unwrap_or(20),
});
}
let page = std::cmp::max(1, params.page.unwrap_or(1));
let per_page = std::cmp::min(100, std::cmp::max(1, params.per_page.unwrap_or(20)));
let offset = (page - 1) * per_page;
let q = params.q.trim();
// Build the set of room IDs the user can access:
// 1. Direct room memberships
let direct_rooms: Vec<Uuid> = room_member::Entity::find()
.filter(room_member::Column::User.eq(user_id))
.select_only()
.column(room_member::Column::Room)
.into_tuple::<Uuid>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?;
// 2. Public rooms in projects the user is a member of
let project_ids = accessible_project_ids(&self.db, Some(user_id)).await?;
let public_rooms: Vec<Uuid> = room::Entity::find()
.filter(room::Column::Project.is_in(project_ids.clone()))
.filter(room::Column::Public.eq(true))
.select_only()
.column(room::Column::Id)
.into_tuple::<Uuid>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?;
// Merge and deduplicate accessible room IDs using a HashSet
use std::collections::HashSet;
let mut accessible_set: HashSet<Uuid> = direct_rooms.into_iter().collect();
for rid in public_rooms {
accessible_set.insert(rid);
}
let accessible_rooms: Vec<Uuid> = accessible_set.iter().cloned().collect();
if accessible_rooms.is_empty() {
return Ok(GlobalMessageSearchResponse {
query: q.to_string(),
messages: Vec::new(),
total: 0,
page,
per_page,
});
}
// Fetch room names for the accessible rooms
let room_names_map: std::collections::HashMap<Uuid, String> = room::Entity::find()
.filter(room::Column::Id.is_in(accessible_rooms.clone()))
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?
.into_iter()
.map(|r| (r.id, r.room_name))
.collect();
let tsquery = format!("plainto_tsquery('simple', $1)");
let sql = format!(
r#"
SELECT m.id, m.room, m.sender_type, m.sender_id,
m.content, m.content_type, m.send_at,
ts_headline('simple', m.content, {}, 'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=15') AS highlighted_content
FROM room_message m
WHERE m.room = ANY($2)
AND m.content_tsv @@ {}
AND m.revoked IS NULL
ORDER BY m.send_at DESC
LIMIT $3 OFFSET $4"#,
tsquery,
tsquery
);
// Results query
let results_sql = Statement::from_sql_and_values(
DbBackend::Postgres,
&sql,
vec![q.into(), accessible_rooms.clone().into(), per_page.into(), offset.into()],
);
let rows = self.db.query_all_raw(results_sql).await?;
let mut messages: Vec<GlobalMessageSearchItem> = Vec::new();
for row in rows {
let room_id: Uuid = row.try_get::<Uuid>("", "room").unwrap_or_default();
let sender_type_str = row.try_get::<String>("", "sender_type").unwrap_or_default();
let content_type_str = row.try_get::<String>("", "content_type").unwrap_or_default();
let highlighted = row
.try_get::<String>("", "highlighted_content")
.ok();
messages.push(GlobalMessageSearchItem {
id: row.try_get::<Uuid>("", "id").unwrap_or_default(),
room_id,
room_name: room_names_map.get(&room_id).cloned().unwrap_or_default(),
sender_id: row.try_get::<Option<Uuid>>("", "sender_id").ok().flatten(),
sender_type: sender_type_str,
display_name: None,
content: row.try_get::<String>("", "content").unwrap_or_default(),
content_type: content_type_str,
send_at: row.try_get::<DateTime<Utc>>("", "send_at").unwrap_or_default(),
highlighted_content: highlighted,
});
}
// Count total across all accessible rooms
let count_sql = format!(
"SELECT COUNT(*) AS count FROM room_message WHERE room = ANY($1) AND content_tsv @@ {} AND revoked IS NULL",
tsquery
);
let count_stmt = Statement::from_sql_and_values(
DbBackend::Postgres,
&count_sql,
vec![accessible_rooms.into(), q.into()],
);
let count_row = self.db.query_one_raw(count_stmt).await?;
let total: i64 = count_row
.and_then(|r| r.try_get::<i64>("", "count").ok())
.unwrap_or(0);
Ok(GlobalMessageSearchResponse {
query: q.to_string(),
messages,
total,
page,
per_page,
})
}
}

View File

@ -1334,6 +1334,53 @@
}
}
},
"/api/agents/{project}/triage": {
"get": {
"tags": [
"Agent"
],
"operationId": "triage_issue",
"parameters": [
{
"name": "project",
"in": "path",
"description": "Project name",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "issue_number",
"in": "query",
"description": "Issue number to triage",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "Issue triage result",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponse_IssueTriageResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Issue not found"
}
}
}
},
"/api/auth/captcha": {
"post": {
"tags": [
@ -3166,6 +3213,64 @@
}
}
},
"/api/issue/{project}/issues/{number}/labels/bulk": {
"post": {
"tags": [
"Issues"
],
"operationId": "issue_label_add_bulk",
"parameters": [
{
"name": "project",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "number",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IssueAddLabelsByNamesRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Add labels to issue by name",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponse_Vec_IssueLabelResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not found"
}
}
}
},
"/api/issue/{project}/issues/{number}/labels/{label_id}": {
"delete": {
"tags": [
@ -19946,6 +20051,67 @@
}
}
},
"/api/search/messages": {
"get": {
"tags": [
"Search"
],
"operationId": "search_messages",
"parameters": [
{
"name": "q",
"in": "query",
"description": "Search keyword",
"required": true,
"schema": {
"type": "string",
"maxLength": 200,
"minLength": 1
}
},
{
"name": "page",
"in": "query",
"description": "Page number, default 1",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
},
{
"name": "per_page",
"in": "query",
"description": "Results per page, default 20, max 100",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "Message search results across all accessible rooms",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponse_GlobalMessageSearchResponse"
}
}
}
},
"400": {
"description": "Bad request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/api/users/me/access-keys": {
"get": {
"tags": [
@ -24688,6 +24854,57 @@
}
}
},
"ApiResponse_GlobalMessageSearchResponse": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"type": "object",
"required": [
"query",
"messages",
"total",
"page",
"per_page"
],
"properties": {
"query": {
"type": "string"
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GlobalMessageSearchItem"
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"per_page": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
}
}
},
"ApiResponse_InvitationListResponse": {
"type": "object",
"required": [
@ -25283,6 +25500,43 @@
}
}
},
"ApiResponse_IssueTriageResponse": {
"type": "object",
"required": [
"code",
"message"
],
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"type": "object",
"required": [
"comment_posted"
],
"properties": {
"suggestions": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/IssueTriageSuggestion"
}
]
},
"comment_posted": {
"type": "boolean"
}
}
}
}
},
"ApiResponse_JoinAnswersListResponse": {
"type": "object",
"required": [
@ -34653,6 +34907,12 @@
"string",
"null"
]
},
"ai_code_review_enabled": {
"type": [
"boolean",
"null"
]
}
}
},
@ -34667,6 +34927,98 @@
}
}
},
"GlobalMessageSearchItem": {
"type": "object",
"required": [
"id",
"room_id",
"room_name",
"sender_type",
"content",
"content_type",
"send_at"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"room_id": {
"type": "string",
"format": "uuid"
},
"room_name": {
"type": "string"
},
"sender_id": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"sender_type": {
"type": "string"
},
"display_name": {
"type": [
"string",
"null"
]
},
"content": {
"type": "string"
},
"content_type": {
"type": "string"
},
"send_at": {
"type": "string",
"format": "date-time"
},
"highlighted_content": {
"type": [
"string",
"null"
]
}
}
},
"GlobalMessageSearchResponse": {
"type": "object",
"required": [
"query",
"messages",
"total",
"page",
"per_page"
],
"properties": {
"query": {
"type": "string"
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GlobalMessageSearchItem"
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"per_page": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"InvitationListResponse": {
"type": "object",
"required": [
@ -34811,6 +35163,20 @@
}
}
},
"IssueAddLabelsByNamesRequest": {
"type": "object",
"required": [
"names"
],
"properties": {
"names": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"IssueAssignUserRequest": {
"type": "object",
"required": [
@ -35280,6 +35646,49 @@
}
}
},
"IssueTriageResponse": {
"type": "object",
"required": [
"comment_posted"
],
"properties": {
"suggestions": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/IssueTriageSuggestion"
}
]
},
"comment_posted": {
"type": "boolean"
}
}
},
"IssueTriageSuggestion": {
"type": "object",
"required": [
"suggested_labels",
"priority",
"reasoning"
],
"properties": {
"suggested_labels": {
"type": "array",
"items": {
"type": "string"
}
},
"priority": {
"type": "string"
},
"reasoning": {
"type": "string"
}
}
},
"IssueUpdateRequest": {
"type": "object",
"properties": {
@ -37453,7 +37862,8 @@
"star_count",
"watch_count",
"ssh_clone_url",
"https_clone_url"
"https_clone_url",
"ai_code_review_enabled"
],
"properties": {
"uid": {
@ -37510,6 +37920,9 @@
},
"https_clone_url": {
"type": "string"
},
"ai_code_review_enabled": {
"type": "boolean"
}
}
},

View File

@ -1,290 +1,649 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Archive,
AtSign,
Bell,
BellOff,
Check,
CheckCheck,
Loader2,
Mail,
MessageSquare,
Shield,
} from "lucide-react";
import { notificationList, notificationMarkRead, notificationMarkAllRead, notificationArchive } from "@/client";
import type { NotificationResponse } from "@/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {getApiErrorMessage} from '@/lib/api-error';
'use client';
type Filter = "all" | "unread" | "archived";
/**
* Enhanced notifications page with:
* - WebSocket real-time updates via useNotification hook
* - Grouping by project/context
* - Quick actions (mark read, archive, preview)
* - Extended notification type support
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
Archive,
AtSign,
Bell,
BellOff,
Check,
CheckCheck,
ChevronDown,
ChevronRight,
Loader2,
Mail,
MessageSquare,
Settings,
Shield,
GitPullRequest,
CheckCircle,
Merge,
AlertCircle,
SlidersHorizontal,
} from 'lucide-react';
import {
notificationMarkRead,
notificationMarkAllRead,
notificationArchive,
} from '@/client';
import type { NotificationResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getApiErrorMessage } from '@/lib/api-error';
import { useNotification } from '@/hooks/useNotification';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
type Filter = 'all' | 'unread' | 'archived';
type GroupBy = 'none' | 'project' | 'type';
const NOTIFICATION_TYPE_CONFIG: Record<
string,
{ label: string; icon: React.ReactNode; color: string }
string,
{ label: string; icon: React.ReactNode; color: string }
> = {
mention: { label: "Mention", icon: <AtSign className="h-3.5 w-3.5" />, color: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
invitation: { label: "Invitation", icon: <Mail className="h-3.5 w-3.5" />, color: "bg-purple-500/10 text-purple-600 border-purple-500/20" },
role_change: { label: "Role Change", icon: <Shield className="h-3.5 w-3.5" />, color: "bg-orange-500/10 text-orange-600 border-orange-500/20" },
room_created: { label: "Room Created", icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-green-500/10 text-green-600 border-green-500/20" },
room_deleted: { label: "Room Deleted", icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-red-500/10 text-red-600 border-red-500/20" },
system_announcement: { label: "Announcement", icon: <Bell className="h-3.5 w-3.5" />, color: "bg-yellow-500/10 text-yellow-700 border-yellow-500/20" },
mention: {
label: 'Mention',
icon: <AtSign className="h-3.5 w-3.5" />,
color: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
},
invitation: {
label: 'Invitation',
icon: <Mail className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
project_invitation: {
label: 'Project Invite',
icon: <Mail className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
workspace_invitation: {
label: 'Workspace Invite',
icon: <Mail className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
role_change: {
label: 'Role Change',
icon: <Shield className="h-3.5 w-3.5" />,
color: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
},
room_created: {
label: 'Room Created',
icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'bg-green-500/10 text-green-600 border-green-500/20',
},
room_deleted: {
label: 'Room Deleted',
icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'bg-red-500/10 text-red-600 border-red-500/20',
},
system_announcement: {
label: 'Announcement',
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-yellow-500/10 text-yellow-700 border-yellow-500/20',
},
issue_opened: {
label: 'Issue Opened',
icon: <AlertCircle className="h-3.5 w-3.5" />,
color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
},
issue_commented: {
label: 'Issue Comment',
icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
},
issue_closed: {
label: 'Issue Closed',
icon: <CheckCircle className="h-3.5 w-3.5" />,
color: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
},
pr_review_requested: {
label: 'Review Requested',
icon: <GitPullRequest className="h-3.5 w-3.5" />,
color: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
},
pr_approved: {
label: 'PR Approved',
icon: <CheckCircle className="h-3.5 w-3.5" />,
color: 'bg-green-500/10 text-green-600 border-green-500/20',
},
pr_merged: {
label: 'PR Merged',
icon: <Merge className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
};
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();
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();
}
function NotificationItem({
n,
onMarkRead,
onArchive,
}: {
n: NotificationResponse;
onMarkRead: (id: string) => void;
onArchive: (id: string) => void;
}) {
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
label: n.notification_type,
icon: <Bell className="h-3.5 w-3.5" />,
color: "bg-muted text-muted-foreground border-border",
};
function getTypeLabel(type: string): string {
return NOTIFICATION_TYPE_CONFIG[type]?.label ?? type;
}
const handleClick = () => {
if (!n.is_read) onMarkRead(n.id);
};
interface NotificationItemProps {
n: NotificationResponse;
onMarkRead: (id: string) => void;
onArchive: (id: string) => void;
onNavigate: (n: NotificationResponse) => void;
}
return (
<div
className={`group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors border-b last:border-b-0 ${
!n.is_read ? "bg-primary/5" : ""
}`}
function NotificationItem({ n, onMarkRead, onArchive, onNavigate }: NotificationItemProps) {
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
label: n.notification_type,
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-muted text-muted-foreground border-border',
};
return (
<div
className={cn(
'group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors border-b last:border-b-0',
!n.is_read && 'bg-primary/5',
)}
>
{/* Unread dot */}
<div className="flex-shrink-0 pt-1">
{!n.is_read && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
{/* Icon */}
<div
className={cn(
'flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center',
config.color,
)}
>
{config.icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<button
type="button"
onClick={() => {
if (!n.is_read) onMarkRead(n.id);
onNavigate(n);
}}
className="text-left flex-1 min-w-0 w-full"
>
{/* Unread dot */}
<div className="flex-shrink-0 pt-1">
{!n.is_read && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
<p className={cn('text-sm truncate', !n.is_read ? 'font-semibold' : 'font-medium')}>
{n.title}
</p>
{n.content && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{n.content}
</p>
)}
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className={cn('text-xs border', config.color)}>
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">{formatTime(n.created_at)}</span>
</div>
</button>
</div>
{/* Icon */}
<div className={`flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center ${config.color}`}>
{config.icon}
</div>
{/* Actions */}
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!n.is_read && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onMarkRead(n.id);
}}
title="Mark as read"
>
<Check className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onArchive(n.id);
}}
title="Archive"
>
<Archive className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={handleClick}
className="text-left flex-1 min-w-0"
>
<p className={`text-sm truncate ${!n.is_read ? "font-semibold" : "font-medium"}`}>
{n.title}
</p>
{n.content && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{n.content}
</p>
)}
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className={`text-xs ${config.color} border`}>
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">
{formatTime(n.created_at)}
</span>
</div>
</button>
</div>
</div>
interface NotificationGroupProps {
title: string;
count: number;
defaultOpen?: boolean;
children: React.ReactNode;
}
{/* Actions */}
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!n.is_read && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={(e) => { e.stopPropagation(); onMarkRead(n.id); }}
title="Mark as read"
>
<Check className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onArchive(n.id); }}
title="Archive"
>
<Archive className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
function NotificationGroup({ title, count, defaultOpen = true, children }: NotificationGroupProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border rounded-lg bg-card overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-4 py-2.5 bg-muted/30 hover:bg-muted/50 transition-colors"
onClick={() => setOpen((o) => !o)}
>
{open ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">{title}</span>
<Badge variant="secondary" className="text-xs ml-auto">
{count}
</Badge>
</button>
{open && children}
</div>
);
}
export default function NotifyPage() {
const queryClient = useQueryClient();
const [filter, setFilter] = useState<Filter>("all");
const navigate = useNavigate();
const queryClient = useQueryClient();
const [filter, setFilter] = useState<Filter>('all');
const [groupBy, setGroupBy] = useState<GroupBy>('none');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchMode, setBatchMode] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["notifications", filter],
queryFn: async () => {
const resp = await notificationList({
query: {
only_unread: filter === "unread",
archived: filter === "archived" ? true : undefined,
limit: 100,
},
});
return resp.data?.data ?? null;
},
const {
notifications,
unreadCount,
markRead,
markAllRead,
archive,
isLive,
} = useNotification({
showToast: false,
});
const filteredNotifications = notifications.filter((n) => {
if (filter === 'unread') return !n.is_read;
if (filter === 'archived') return n.is_archived;
return !n.is_archived;
});
const handleMarkRead = async (id: string) => {
try {
await notificationMarkRead({ path: { notification_id: id } });
markRead(id);
queryClient.invalidateQueries({ queryKey: ['me'] });
} catch (err) {
toast.error(getApiErrorMessage(err, 'Failed to mark as read'));
}
};
const handleArchive = async (id: string) => {
try {
await notificationArchive({ path: { notification_id: id } });
archive(id);
toast.success('Notification archived');
} catch (err) {
toast.error(getApiErrorMessage(err, 'Failed to archive'));
}
};
const handleMarkAllRead = async () => {
try {
await notificationMarkAllRead();
markAllRead();
queryClient.invalidateQueries({ queryKey: ['me'] });
} catch (err) {
toast.error(getApiErrorMessage(err, 'Failed to mark all as read'));
}
};
const handleNavigate = (n: NotificationResponse) => {
// Determine navigation target from notification metadata
if (n.related_room_id && n.project) {
navigate(`/project/${n.project}/room`);
} else if (n.notification_type.includes('invitation')) {
navigate('/invitations');
} else if (n.notification_type.startsWith('issue')) {
// Navigate to project/issue if project is known
if (n.project) {
navigate(`/project/${n.project}/issues`);
}
} else if (n.notification_type.startsWith('pr')) {
if (n.project) {
navigate(`/project/${n.project}/pull-requests`);
}
} else {
navigate('/notify');
}
};
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const markReadMutation = useMutation({
mutationFn: async (notificationId: string) => {
await notificationMarkRead({ path: { notification_id: notificationId } });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to mark as read"));
},
});
const handleBatchArchive = async () => {
for (const id of selectedIds) {
try {
await notificationArchive({ path: { notification_id: id } });
archive(id);
} catch {
// Continue with others
}
}
setSelectedIds(new Set());
setBatchMode(false);
toast.success(`${selectedIds.size} notifications archived`);
};
const markAllReadMutation = useMutation({
mutationFn: async () => {
await notificationMarkAllRead();
},
onSuccess: () => {
toast.success("All notifications marked as read");
queryClient.invalidateQueries({ queryKey: ["notifications"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to mark all as read"));
},
});
const handleBatchMarkRead = async () => {
for (const id of selectedIds) {
try {
await notificationMarkRead({ path: { notification_id: id } });
markRead(id);
} catch {
// Continue
}
}
queryClient.invalidateQueries({ queryKey: ['me'] });
setSelectedIds(new Set());
setBatchMode(false);
};
const archiveMutation = useMutation({
mutationFn: async (notificationId: string) => {
await notificationArchive({ path: { notification_id: notificationId } });
},
onSuccess: () => {
toast.success("Notification archived");
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to archive"));
},
});
const handleMarkReadForGroup = async (ids: string[]) => {
for (const id of ids) {
await notificationMarkRead({ path: { notification_id: id } });
markRead(id);
}
queryClient.invalidateQueries({ queryKey: ['me'] });
};
const notifications: NotificationResponse[] = data?.notifications ?? [];
const total: number = data?.total ?? 0;
const unreadCount: number = data?.unread_count ?? 0;
// Group notifications
const groups = (() => {
if (groupBy === 'project') {
const byProject = new Map<string, NotificationResponse[]>();
for (const n of filteredNotifications) {
const key = n.project ?? 'Unknown Project';
if (!byProject.has(key)) byProject.set(key, []);
byProject.get(key)!.push(n);
}
return [...byProject.entries()].map(([title, items]) => ({
title,
items,
}));
}
if (groupBy === 'type') {
const byType = new Map<string, NotificationResponse[]>();
for (const n of filteredNotifications) {
const key = getTypeLabel(n.notification_type);
if (!byType.has(key)) byType.set(key, []);
byType.get(key)!.push(n);
}
return [...byType.entries()].map(([title, items]) => ({
title,
items,
}));
}
return [{ title: 'All', items: filteredNotifications }];
})();
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{unreadCount > 0
? `${unreadCount} unread · ${total} total`
: `${total} notification${total !== 1 ? "s" : ""}`}
</p>
</div>
{unreadCount > 0 && filter !== "archived" && (
<Button
size="sm"
variant="outline"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
{markAllReadMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<CheckCheck className="h-3.5 w-3.5 mr-1.5" />
)}
Mark all read
</Button>
)}
</div>
const total = filteredNotifications.length;
{/* Filter tabs */}
<div className="flex items-center gap-1 border-b">
{(["all", "unread", "archived"] as Filter[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
filter === f
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{f === "all" ? "All" : f === "unread" ? "Unread" : "Archived"}
{f === "unread" && unreadCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
{unreadCount}
</span>
)}
</button>
))}
</div>
{/* List */}
<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>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<BellOff className="h-10 w-10 mb-3 opacity-40" />
<p className="font-medium">
{filter === "unread" ? "No unread notifications" : filter === "archived" ? "No archived notifications" : "No notifications yet"}
</p>
<p className="text-sm mt-1">
{filter === "unread"
? "You're all caught up!"
: filter === "archived"
? "Archived notifications will appear here."
: "You'll see notifications here when something happens."}
</p>
</div>
) : (
notifications.map((n) => (
<NotificationItem
key={n.id}
n={n}
onMarkRead={(id) => markReadMutation.mutate(id)}
onArchive={(id) => archiveMutation.mutate(id)}
/>
))
)}
</div>
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
{isLive && (
<span className="flex items-center gap-1 text-xs font-normal text-green-600">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Live
</span>
)}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{unreadCount > 0
? `${unreadCount} unread · ${total} total`
: `${total} notification${total !== 1 ? 's' : ''}`}
</p>
</div>
);
<div className="flex items-center gap-2">
{/* Group by selector */}
<div className="flex items-center gap-1 border rounded-md px-1">
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground mx-1" />
<select
className="bg-transparent text-xs border-0 outline-none py-1 pr-1 cursor-pointer"
value={groupBy}
onChange={(e) => setGroupBy(e.target.value as GroupBy)}
>
<option value="none">No grouping</option>
<option value="project">Group by project</option>
<option value="type">Group by type</option>
</select>
</div>
{batchMode ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedIds(new Set());
setBatchMode(false);
}}
>
Cancel
</Button>
<Button
size="sm"
variant="outline"
onClick={handleBatchMarkRead}
disabled={selectedIds.size === 0}
>
<CheckCheck className="h-3.5 w-3.5 mr-1" />
Mark read ({selectedIds.size})
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleBatchArchive}
disabled={selectedIds.size === 0}
>
<Archive className="h-3.5 w-3.5 mr-1" />
Archive ({selectedIds.size})
</Button>
</>
) : (
<>
{unreadCount > 0 && filter !== 'archived' && (
<Button
size="sm"
variant="outline"
onClick={handleMarkAllRead}
disabled={markAllRead === undefined}
>
{false ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<CheckCheck className="h-3.5 w-3.5 mr-1.5" />
)}
Mark all read
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => navigate('/settings/preferences')}
title="Notification settings"
>
<Settings className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1 border-b">
{(['all', 'unread', 'archived'] as Filter[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
className={cn(
'px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
filter === f
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{f === 'all' ? 'All' : f === 'unread' ? 'Unread' : 'Archived'}
{f === 'unread' && unreadCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
{unreadCount}
</span>
)}
</button>
))}
{/* Batch mode toggle */}
<div className="ml-auto flex items-center">
<button
type="button"
onClick={() => {
setBatchMode((m) => !m);
setSelectedIds(new Set());
}}
className={cn(
'px-3 py-2 text-xs font-medium transition-colors',
batchMode
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
{batchMode ? '✓ Select mode active' : 'Select multiple'}
</button>
</div>
</div>
{/* List */}
<div className="space-y-3">
{total === 0 ? (
<div className="border rounded-lg bg-card overflow-hidden">
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<BellOff className="h-10 w-10 mb-3 opacity-40" />
<p className="font-medium">
{filter === 'unread'
? 'No unread notifications'
: filter === 'archived'
? 'No archived notifications'
: 'No notifications yet'}
</p>
<p className="text-sm mt-1">
{filter === 'unread'
? "You're all caught up!"
: filter === 'archived'
? 'Archived notifications will appear here.'
: "You'll see notifications here when something happens."}
</p>
</div>
</div>
) : groupBy !== 'none' ? (
groups.map(({ title, items }) => (
<NotificationGroup key={title} title={title} count={items.length}>
{items.map((n) => (
<div key={n.id} className="flex items-start">
{batchMode && (
<input
type="checkbox"
className="ml-4 mt-4 mr-0 flex-shrink-0 accent-primary"
checked={selectedIds.has(n.id)}
onChange={() => toggleSelect(n.id)}
/>
)}
<div className="flex-1">
<NotificationItem
n={n}
onMarkRead={handleMarkRead}
onArchive={handleArchive}
onNavigate={handleNavigate}
/>
</div>
</div>
))}
<div className="px-4 pb-2 flex justify-end">
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() => handleMarkReadForGroup(items.map((i) => i.id))}
>
<CheckCheck className="h-3 w-3 mr-1" />
Mark all read in group
</Button>
</div>
</NotificationGroup>
))
) : (
<div className="border rounded-lg bg-card overflow-hidden">
{filteredNotifications.map((n) => (
<div key={n.id} className="flex items-start">
{batchMode && (
<input
type="checkbox"
className="ml-4 mt-4 mr-0 flex-shrink-0 accent-primary"
checked={selectedIds.has(n.id)}
onChange={() => toggleSelect(n.id)}
/>
)}
<div className="flex-1">
<NotificationItem
n={n}
onMarkRead={handleMarkRead}
onArchive={handleArchive}
onNavigate={handleNavigate}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -1,14 +1,17 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
Check,
Edit,
Lightbulb,
MessageSquare,
Pencil,
SquarePen,
Trash2,
User,
X,
} from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
@ -19,15 +22,20 @@ import {
issueCommentList,
issueCommentUpdate,
issueGet,
issueLabelAddBulk,
issueReopen,
issueRepoList,
triageIssue,
} from '@/client';
import type { IssueCommentResponse } from '@/client/types.gen';
import { useProject } from '@/contexts';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {getApiErrorMessage} from '@/lib/api-error';
import { ContentRenderer } from '@/components/shared/ContentRenderer';
import { useTypingIndicator } from '@/hooks/useTypingIndicator';
const StatusBadge = ({ status }: { status: string }) => {
const styles =
@ -62,6 +70,9 @@ export function IssueDetail() {
const [newComment, setNewComment] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editingContent, setEditingContent] = useState('');
const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { typingUsers } = useTypingIndicator({});
const no = Number(issueNumber || 0);
@ -112,6 +123,37 @@ export function IssueDetail() {
const linkedRepos = (linkedReposRaw as { repos?: { repo: string; repo_name: string }[] })?.repos ?? [];
const [triageDismissed, setTriageDismissed] = useState(false);
const { data: triageData } = useQuery({
queryKey: ['issueTriage', project?.name, no],
queryFn: async () => {
const resp = await triageIssue({
path: { project: project!.name },
query: { issue_number: no },
});
return resp.data?.data ?? null;
},
enabled: !!project?.name && !!no && !triageDismissed,
});
const applyLabelsMutation = useMutation({
mutationFn: async (names: string[]) => {
await issueLabelAddBulk({
path: { project: project!.name, number: no },
body: { names },
});
},
onSuccess: () => {
toast.success('Labels applied');
setTriageDismissed(true);
refetchComments();
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, 'Failed to apply labels'));
},
});
const timeline: TimelineItem[] = [];
if (issue) {
@ -273,9 +315,72 @@ export function IssueDetail() {
{/* Description */}
{issue.body && (
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
<p className="text-sm whitespace-pre-wrap text-foreground/90 leading-relaxed">
{issue.body}
</p>
<ContentRenderer content={issue.body} />
</div>
)}
{/* AI Triage Suggestions */}
{triageData?.suggestions && !triageDismissed && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-4">
<div className="flex items-start gap-3">
<Lightbulb className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
AI Triage
</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
triageData.suggestions.priority === 'high'
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-400'
: triageData.suggestions.priority === 'medium'
? 'bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-400'
: 'bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-400'
}`}>
{triageData.suggestions.priority} priority
</span>
</div>
<p className="text-sm text-amber-800 dark:text-amber-300 mb-3">
{triageData.suggestions.reasoning}
</p>
{triageData.suggestions.suggested_labels.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{triageData.suggestions.suggested_labels.map((label) => (
<span
key={label}
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300"
>
{label}
</span>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="default"
className="h-7 px-3 text-xs bg-amber-600 hover:bg-amber-700 text-white"
onClick={() =>
applyLabelsMutation.mutate(
triageData.suggestions!.suggested_labels
)
}
disabled={applyLabelsMutation.isPending || triageData.suggestions.suggested_labels.length === 0}
>
<Check className="mr-1 h-3 w-3" />
{applyLabelsMutation.isPending ? 'Applying…' : 'Accept'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-amber-700 dark:text-amber-400"
onClick={() => setTriageDismissed(true)}
>
<X className="mr-1 h-3 w-3" />
Ignore
</Button>
</div>
</div>
</div>
</div>
)}
</div>
@ -395,9 +500,7 @@ export function IssueDetail() {
</div>
</div>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
{comment.body}
</p>
<ContentRenderer content={comment.body} />
)}
</div>
</div>
@ -415,7 +518,12 @@ export function IssueDetail() {
<div className="p-4">
<Textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onChange={(e) => {
const v = e.target.value;
setNewComment(v);
if (v && typingStopTimer.current) { clearTimeout(typingStopTimer.current); typingStopTimer.current = null; }
if (v && !typingStopTimer.current) { typingStopTimer.current = setTimeout(() => { typingStopTimer.current = null; }, 1500); }
}}
placeholder="Leave a comment…"
rows={4}
className="resize-none mb-3 text-sm"
@ -423,10 +531,8 @@ export function IssueDetail() {
<div className="flex justify-end">
<Button
onClick={() => {
if (!newComment.trim()) {
toast.error('Comment cannot be empty');
return;
}
if (!newComment.trim()) { toast.error('Comment cannot be empty'); return; }
if (typingStopTimer.current) { clearTimeout(typingStopTimer.current); typingStopTimer.current = null; }
createCommentMutation.mutate(newComment.trim());
}}
disabled={createCommentMutation.isPending || !newComment.trim()}
@ -434,6 +540,33 @@ export function IssueDetail() {
{createCommentMutation.isPending ? 'Posting…' : 'Comment'}
</Button>
</div>
{typingUsers.length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5">
<div className="flex -space-x-1.5">
{typingUsers.slice(0, 3).map((u) => (
<Avatar key={u.userId} className="h-5 w-5 border border-background">
{u.avatarUrl ? (
<img src={u.avatarUrl} alt={u.username} className="h-5 w-5 rounded-full object-cover" />
) : (
<AvatarFallback className="text-[10px]">{u.username[0]?.toUpperCase()}</AvatarFallback>
)}
</Avatar>
))}
</div>
<span className="text-xs text-muted-foreground">
{typingUsers.length === 1
? `${typingUsers[0].username} is typing…`
: typingUsers.length === 2
? `${typingUsers[0].username} and ${typingUsers[1].username} are typing…`
: `${typingUsers.length} people are typing…`}
</span>
<span className="flex gap-0.5 ml-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
)}
</div>
</div>
</div>

View File

@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Edit2,
@ -11,6 +12,7 @@ import {
Globe,
Loader2,
Lock,
Bot,
} from "lucide-react";
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
@ -129,6 +131,23 @@ export function RepoSettingsGeneral() {
},
});
// AI Code Review
const aiReviewMutation = useMutation({
mutationFn: async (enabled: boolean) => {
await gitUpdateRepo({
path: { namespace: ns, repo: rn },
body: { ai_code_review_enabled: enabled },
});
},
onSuccess: () => {
toast.success("AI code review setting updated");
queryClient.invalidateQueries({ queryKey: ["projectRepos", ns] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to update AI code review setting"));
},
});
const branches: Array<{ name: string }> = branchesData ?? [];
if (!repo) return null;
@ -280,6 +299,34 @@ export function RepoSettingsGeneral() {
/>
</div>
</div>
{/* AI Settings */}
<div className="border rounded-lg bg-card">
<div className="p-4 border-b">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Bot className="h-4 w-4" />
AI Collaborator
</h2>
</div>
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<Label className="text-sm font-medium">Automatic Code Review</Label>
<p className="text-xs text-muted-foreground mt-0.5">
When enabled, AI will automatically review pull requests when they are opened and post structured comments.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{aiReviewMutation.isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<Switch
checked={repo.ai_code_review_enabled ?? false}
onCheckedChange={(checked) => aiReviewMutation.mutate(checked)}
disabled={aiReviewMutation.isPending}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,15 +1,17 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
FolderGit2,
GitPullRequest,
Hexagon,
MessageSquare,
Search,
Users,
Loader2,
} from 'lucide-react';
import { client } from '@/client/client.gen';
import { search } from '@/client/sdk.gen';
import { messageSearch, search, searchMessages } from '@/client/sdk.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -21,12 +23,16 @@ import type {
RepoSearchItem,
IssueSearchItem,
UserSearchItem,
MessageSearchResponse,
RoomMessageResponse,
SearchResponse,
GlobalMessageSearchResponse,
GlobalMessageSearchItem,
} from '@/client/types.gen';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const ALL_TYPES = ['projects', 'repos', 'issues', 'users'] as const;
const ALL_TYPES = ['projects', 'repos', 'issues', 'users', 'messages'] as const;
type SearchType = typeof ALL_TYPES[number];
const TYPE_LABELS: Record<SearchType, string> = {
@ -34,6 +40,7 @@ const TYPE_LABELS: Record<SearchType, string> = {
repos: 'Repositories',
issues: 'Issues',
users: 'Users',
messages: 'Messages',
};
const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>> = {
@ -41,6 +48,7 @@ const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>
repos: FolderGit2,
issues: GitPullRequest,
users: Users,
messages: MessageSquare,
};
function getTotal(results: SearchResponse): number {
@ -150,6 +158,50 @@ function UserItem({ item }: { item: UserSearchItem }) {
);
}
type MessageSearchItem = RoomMessageResponse | GlobalMessageSearchItem;
function isGlobalMessage(item: MessageSearchItem): item is GlobalMessageSearchItem {
return 'room_id' in item && 'room_name' in item;
}
function MessageItem({ item }: { item: MessageSearchItem }) {
const isGlobal = isGlobalMessage(item);
const roomLabel = isGlobal ? item.room_name : item.room;
const roomHref = isGlobal
? `/rooms/${item.room_id}`
: `/project/${item.room.split(':')[0]}/room/${item.room.split(':')[1] ?? item.room}`;
const displayContent = isGlobal ? (item.highlighted_content ?? item.content) : item.content;
return (
<a
href={roomHref}
className="flex items-start gap-3 rounded-md p-3 hover:bg-muted/50 transition-colors -mx-3"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-semibold text-sm">
{(item.display_name ?? item.sender_id ?? '?')[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm hover:underline">
{item.display_name ?? item.sender_id ?? 'Unknown'}
</span>
<Badge variant="secondary" className="text-xs shrink-0 truncate max-w-[120px]">
{roomLabel}
</Badge>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{formatDistanceToNow(parseISO(item.send_at), { addSuffix: true })}
</span>
</div>
<p
className="text-xs mt-0.5 line-clamp-2 whitespace-pre-wrap"
style={{ color: 'var(--muted-foreground)' }}
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
</div>
</a>
);
}
function ResultSection<T>({
type,
result,
@ -192,6 +244,7 @@ function ResultSection<T>({
export default function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [roomIdInput, setRoomIdInput] = useState('');
const q = searchParams.get('q') ?? '';
const typeParam = searchParams.get('type') ?? '';
@ -203,6 +256,9 @@ export default function SearchPage() {
? (typeParam.split(',').filter((t): t is SearchType => ALL_TYPES.includes(t as SearchType)))
: [...ALL_TYPES];
const showMessages = activeTypes.includes('messages');
const useRoomScoped = roomIdInput.trim().length > 0;
const { data, isLoading, error } = useQuery({
queryKey: ['search', q, typeParam, page],
queryFn: async () => {
@ -220,6 +276,34 @@ export default function SearchPage() {
enabled: q.trim().length > 0,
});
// Global message search across all accessible rooms
const { data: globalMessagesData, isLoading: globalMessagesLoading } = useQuery({
queryKey: ['search-messages-global', q],
queryFn: async () => {
const resp = await searchMessages({
query: { q, page: 1, per_page: 20 },
});
return resp.data?.data as GlobalMessageSearchResponse;
},
enabled: q.trim().length > 0 && showMessages && !useRoomScoped,
});
// Room-scoped message search (when room ID is explicitly provided)
const { data: roomMessagesData, isLoading: roomMessagesLoading } = useQuery({
queryKey: ['search-messages-room', q, roomIdInput],
queryFn: async () => {
const resp = await messageSearch({
path: { room_id: roomIdInput.trim() },
query: { q, limit: 20 },
});
return resp.data?.data as MessageSearchResponse;
},
enabled: q.trim().length > 0 && showMessages && useRoomScoped,
});
const messagesData = useRoomScoped ? roomMessagesData : globalMessagesData;
const messagesLoading = useRoomScoped ? roomMessagesLoading : globalMessagesLoading;
const results = data ?? null;
function handleSearchSubmit(e: React.FormEvent<HTMLFormElement>) {
@ -280,12 +364,24 @@ export default function SearchPage() {
);
})}
</div>
{/* Room ID input for messages search */}
{showMessages && (
<div className="mt-2">
<Input
placeholder="Room ID to search messages in (e.g. workspace:general)..."
value={roomIdInput}
onChange={(e) => setRoomIdInput(e.target.value)}
className="h-8 text-xs"
/>
</div>
)}
</div>
</div>
{/* Results */}
<div className="mx-auto max-w-3xl px-6 py-6">
{isLoading && (
{isLoading && !showMessages && (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
@ -310,8 +406,10 @@ export default function SearchPage() {
<>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{getTotal(results) > 0
{results && getTotal(results) > 0
? `${getTotal(results)} results for "${q}"`
: showMessages && messagesData
? `${messagesData.total} message${messagesData.total === 1 ? '' : 's'} for "${q}"${useRoomScoped ? ` in room ${roomIdInput}` : ' across all accessible rooms'}`
: `No results for "${q}"`}
</p>
</div>
@ -345,6 +443,59 @@ export default function SearchPage() {
renderer={(item) => <UserItem item={item} />}
/>
)}
{activeTypes.includes('messages') && (
<>
{messagesLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!messagesLoading && messagesData && messagesData.messages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<MessageSquare className="h-4 w-4" />
Messages
<Badge variant="secondary" className="ml-auto text-xs">
{messagesData.total}
</Badge>
</CardTitle>
{useRoomScoped ? (
<p className="text-xs text-muted-foreground -mt-1">
in room <span className="font-mono font-medium">{roomIdInput}</span>
</p>
) : (
<p className="text-xs text-muted-foreground -mt-1">
across all accessible rooms
</p>
)}
</CardHeader>
<CardContent className="divide-y">
{messagesData.messages.map((msg) => (
<MessageItem key={msg.id} item={msg} />
))}
</CardContent>
</Card>
)}
{!messagesLoading && messagesData && messagesData.messages.length === 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<MessageSquare className="h-4 w-4" />
Messages
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
{useRoomScoped
? `No messages found in room "${roomIdInput}" matching "${q}"`
: `No messages found matching "${q}" across accessible rooms`}
</p>
</CardContent>
</Card>
)}
</>
)}
</div>
{getTotal(results) === 0 && (

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Bell, Loader2, Mail, Moon, Package, Shield, BellRing } from 'lucide-react';
import { Bell, Loader2, Mail, Moon, Package, Shield, BellRing, AlertCircle, GitPullRequest, MessageSquare, MessageCircle } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@ -36,6 +36,12 @@ export function SettingsPreferences() {
const [securityEnabled, setSecurityEnabled] = useState(true);
const [productEnabled, setProductEnabled] = useState(true);
// Development activity categories (stored locally until backend supports them)
const [issueActivityEnabled, setIssueActivityEnabled] = useState(true);
const [prActivityEnabled, setPrActivityEnabled] = useState(true);
const [mentionActivityEnabled, setMentionActivityEnabled] = useState(true);
const [chatActivityEnabled, setChatActivityEnabled] = useState(true);
// Fetch notification preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ['notificationPreferences'],
@ -322,6 +328,67 @@ export function SettingsPreferences() {
</CardContent>
</Card>
{/* Development Activity — Issue / PR / Mention / Chat */}
<Card>
<CardHeader>
<CardTitle>Development Activity</CardTitle>
<CardDescription>Control notifications for code review and collaboration activity.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="issue-activity" className="cursor-pointer">
Issues
</Label>
</div>
<p className="text-sm text-muted-foreground">Opened, closed, assigned, or commented on</p>
</div>
<Switch id="issue-activity" checked={issueActivityEnabled} onCheckedChange={setIssueActivityEnabled} />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<GitPullRequest className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="pr-activity" className="cursor-pointer">
Pull Requests
</Label>
</div>
<p className="text-sm text-muted-foreground">Review requested, approved, merged, or commented on</p>
</div>
<Switch id="pr-activity" checked={prActivityEnabled} onCheckedChange={setPrActivityEnabled} />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="mention-activity" className="cursor-pointer">
@Mentions
</Label>
</div>
<p className="text-sm text-muted-foreground">When someone @mentions you in a message, issue, or PR</p>
</div>
<Switch id="mention-activity" checked={mentionActivityEnabled} onCheckedChange={setMentionActivityEnabled} />
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="chat-activity" className="cursor-pointer">
Room Messages
</Label>
</div>
<p className="text-sm text-muted-foreground">New messages in channels you follow</p>
</div>
<Switch id="chat-activity" checked={chatActivityEnabled} onCheckedChange={setChatActivityEnabled} />
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex justify-end gap-2">
<Button
@ -336,6 +403,10 @@ export function SettingsPreferences() {
setMarketingEnabled(preferences.marketing_enabled ?? false);
setSecurityEnabled(preferences.security_enabled ?? true);
setProductEnabled(preferences.product_enabled ?? true);
setIssueActivityEnabled(true);
setPrActivityEnabled(true);
setMentionActivityEnabled(true);
setChatActivityEnabled(true);
}
}}
disabled={updatePreferencesMutation.isPending}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -826,6 +826,18 @@ export type ApiResponseGitReadmeResponse = {
};
};
export type ApiResponseGlobalMessageSearchResponse = {
code: number;
message: string;
data?: {
query: string;
messages: Array<GlobalMessageSearchItem>;
total: number;
page: number;
per_page: number;
};
};
export type ApiResponseInvitationListResponse = {
code: number;
message: string;
@ -975,6 +987,15 @@ export type ApiResponseIssueSummaryResponse = {
};
};
export type ApiResponseIssueTriageResponse = {
code: number;
message: string;
data?: {
suggestions?: null | IssueTriageSuggestion;
comment_posted: boolean;
};
};
export type ApiResponseJoinAnswersListResponse = {
code: number;
message: string;
@ -3374,6 +3395,7 @@ export type GitReadmeResponse = {
export type GitUpdateRepoRequest = {
default_branch?: string | null;
ai_code_review_enabled?: boolean | null;
};
export type GitWatchRequest = {
@ -3381,6 +3403,27 @@ export type GitWatchRequest = {
notify_email?: boolean;
};
export type GlobalMessageSearchItem = {
id: string;
room_id: string;
room_name: string;
sender_id?: string | null;
sender_type: string;
display_name?: string | null;
content: string;
content_type: string;
send_at: string;
highlighted_content?: string | null;
};
export type GlobalMessageSearchResponse = {
query: string;
messages: Array<GlobalMessageSearchItem>;
total: number;
page: number;
per_page: number;
};
export type InvitationListResponse = {
invitations: Array<InvitationResponse>;
total: number;
@ -3419,6 +3462,10 @@ export type IssueAddLabelRequest = {
label_id: number;
};
export type IssueAddLabelsByNamesRequest = {
names: Array<string>;
};
export type IssueAssignUserRequest = {
user_id: string;
};
@ -3540,6 +3587,17 @@ export type IssueSummaryResponse = {
closed: number;
};
export type IssueTriageResponse = {
suggestions?: null | IssueTriageSuggestion;
comment_posted: boolean;
};
export type IssueTriageSuggestion = {
suggested_labels: Array<string>;
priority: string;
reasoning: string;
};
export type IssueUpdateRequest = {
title?: string | null;
body?: string | null;
@ -4141,6 +4199,7 @@ export type ProjectRepositoryItem = {
last_commit_at?: string | null;
ssh_clone_url: string;
https_clone_url: string;
ai_code_review_enabled: boolean;
};
export type ProjectRepositoryPagination = {
@ -6155,6 +6214,43 @@ export type ModelPricingListResponses = {
export type ModelPricingListResponse = ModelPricingListResponses[keyof ModelPricingListResponses];
export type TriageIssueData = {
body?: never;
path: {
/**
* Project name
*/
project: string;
};
query: {
/**
* Issue number to triage
*/
issue_number: number;
};
url: '/api/agents/{project}/triage';
};
export type TriageIssueErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Issue not found
*/
404: unknown;
};
export type TriageIssueResponses = {
/**
* Issue triage result
*/
200: ApiResponseIssueTriageResponse;
};
export type TriageIssueResponse = TriageIssueResponses[keyof TriageIssueResponses];
export type ApiAuthCaptchaData = {
body: CaptchaQuery;
path?: never;
@ -7287,6 +7383,40 @@ export type IssueLabelAddResponses = {
export type IssueLabelAddResponse = IssueLabelAddResponses[keyof IssueLabelAddResponses];
export type IssueLabelAddBulkData = {
body: IssueAddLabelsByNamesRequest;
path: {
project: string;
number: number;
};
query?: never;
url: '/api/issue/{project}/issues/{number}/labels/bulk';
};
export type IssueLabelAddBulkErrors = {
/**
* Unauthorized
*/
401: unknown;
/**
* Forbidden
*/
403: unknown;
/**
* Not found
*/
404: unknown;
};
export type IssueLabelAddBulkResponses = {
/**
* Add labels to issue by name
*/
200: ApiResponseVecIssueLabelResponse;
};
export type IssueLabelAddBulkResponse = IssueLabelAddBulkResponses[keyof IssueLabelAddBulkResponses];
export type IssueLabelRemoveData = {
body?: never;
path: {
@ -17619,6 +17749,46 @@ export type SearchResponses = {
export type SearchResponse2 = SearchResponses[keyof SearchResponses];
export type SearchMessagesData = {
body?: never;
path?: never;
query: {
/**
* Search keyword
*/
q: string;
/**
* Page number, default 1
*/
page?: number;
/**
* Results per page, default 20, max 100
*/
per_page?: number;
};
url: '/api/search/messages';
};
export type SearchMessagesErrors = {
/**
* Bad request
*/
400: unknown;
/**
* Unauthorized
*/
401: unknown;
};
export type SearchMessagesResponses = {
/**
* Message search results across all accessible rooms
*/
200: ApiResponseGlobalMessageSearchResponse;
};
export type SearchMessagesResponse = SearchMessagesResponses[keyof SearchMessagesResponses];
export type ListAccessKeysData = {
body?: never;
path?: never;

View File

@ -10,8 +10,9 @@ import {
} from '@/components/ui/dropdown-menu';
import {useUser} from '@/contexts';
import {cn} from '@/lib/utils';
import {Mail, UserPlus} from 'lucide-react';
import {UserPlus, Bell} from 'lucide-react';
import {useNavigate} from 'react-router-dom';
import {NotificationDrawer} from '@/components/notify/NotificationDrawer';
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';
@ -29,19 +30,19 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) {
{!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">
<Mail className="h-4 w-4"/>
{user && user.has_unread_notifications > 0 && (
<span
className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{user.has_unread_notifications > 9 ? '9+' : user.has_unread_notifications}
</span>
)}
</span>
{!collapsed && <span className="text-sm leading-none">Notify</span>}
</button>
{!collapsed ? (
<button type="button" className={cn(btnClass, 'px-2')}
onClick={() => navigate('/notify')}>
<span className="flex h-6 items-center shrink-0 w-6">
<Bell className="h-4 w-4"/>
</span>
<span className="text-sm leading-none">Notify</span>
</button>
) : (
<div className="flex justify-center">
<NotificationDrawer />
</div>
)}
{user && (
<DropdownMenu>

View File

@ -0,0 +1,365 @@
'use client';
/**
* Global notification drawer accessible from anywhere in the app.
* Shows a bell button with an unread badge, and opens a Sheet
* with recent notifications and quick actions.
*
* Place this component in the root layout (e.g. WorkspaceLayout or App).
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Archive,
Bell,
BellOff,
Check,
CheckCheck,
Mail,
MessageSquare,
Shield,
GitPullRequest,
CheckCircle,
Merge,
AlertCircle,
Settings,
} from 'lucide-react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useNotification } from '@/hooks/useNotification';
import type { NotificationResponse } from '@/client/types.gen';
import { cn } from '@/lib/utils';
const MAX_DRAWER_NOTIFICATIONS = 20;
const NOTIFICATION_TYPE_CONFIG: Record<
string,
{ label: string; icon: React.ReactNode; color: string }
> = {
mention: {
label: 'Mention',
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
},
invitation: {
label: 'Invitation',
icon: <Mail className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
role_change: {
label: 'Role Change',
icon: <Shield className="h-3.5 w-3.5" />,
color: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
},
room_created: {
label: 'Room Created',
icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'bg-green-500/10 text-green-600 border-green-500/20',
},
room_deleted: {
label: 'Room Deleted',
icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'bg-red-500/10 text-red-600 border-red-500/20',
},
system_announcement: {
label: 'Announcement',
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-yellow-500/10 text-yellow-700 border-yellow-500/20',
},
// Extended types
issue_opened: {
label: 'Issue Opened',
icon: <AlertCircle className="h-3.5 w-3.5" />,
color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
},
issue_closed: {
label: 'Issue Closed',
icon: <CheckCircle className="h-3.5 w-3.5" />,
color: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
},
pr_review_requested: {
label: 'Review Requested',
icon: <GitPullRequest className="h-3.5 w-3.5" />,
color: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
},
pr_approved: {
label: 'PR Approved',
icon: <CheckCircle className="h-3.5 w-3.5" />,
color: 'bg-green-500/10 text-green-600 border-green-500/20',
},
pr_merged: {
label: 'PR Merged',
icon: <Merge className="h-3.5 w-3.5" />,
color: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
},
};
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();
}
interface NotificationItemProps {
n: NotificationResponse;
onMarkRead: (id: string) => void;
onArchive: (id: string) => void;
onNavigate: (n: NotificationResponse) => void;
}
function NotificationItem({ n, onMarkRead, onArchive, onNavigate }: NotificationItemProps) {
const config = NOTIFICATION_TYPE_CONFIG[n.notification_type] ?? {
label: n.notification_type,
icon: <Bell className="h-3.5 w-3.5" />,
color: 'bg-muted text-muted-foreground border-border',
};
return (
<div
className={cn(
'group flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-pointer border-b last:border-b-0',
!n.is_read && 'bg-primary/5',
)}
onClick={() => {
if (!n.is_read) onMarkRead(n.id);
onNavigate(n);
}}
>
{/* Unread dot */}
<div className="flex-shrink-0 pt-1">
{!n.is_read && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
{/* Icon */}
<div
className={cn(
'flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border flex items-center justify-center',
config.color,
)}
>
{config.icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={cn(
'text-sm truncate',
!n.is_read ? 'font-semibold' : 'font-medium',
)}
>
{n.title}
</p>
{n.content && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{n.content}
</p>
)}
<div className="flex items-center gap-2 mt-1.5">
<Badge variant="outline" className={cn('text-xs border', config.color)}>
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">{formatTime(n.created_at)}</span>
</div>
</div>
{/* Actions */}
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!n.is_read && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onMarkRead(n.id);
}}
title="Mark as read"
>
<Check className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onArchive(n.id);
}}
title="Archive"
>
<Archive className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
interface NotificationDrawerProps {
/** Provide the wsClient to receive real-time notification pushes */
wsClient?: unknown;
}
export function NotificationDrawer({ wsClient }: NotificationDrawerProps) {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const {
notifications,
unreadCount,
markRead,
markAllRead,
archive,
isLive,
} = useNotification({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wsClient: wsClient as any,
maxNotifications: MAX_DRAWER_NOTIFICATIONS,
showToast: false,
});
const drawerNotifications = notifications.slice(0, MAX_DRAWER_NOTIFICATIONS);
const handleNavigate = (n: NotificationResponse) => {
setOpen(false);
// Navigation based on notification type and related fields
if (n.related_room_id) {
// It's a room-related notification — navigate to the room
navigate(`/project/${n.project}/room`);
} else if (n.notification_type === 'invitation' || n.notification_type === 'project_invitation') {
navigate('/invitations');
} else {
// Default: go to notifications page
navigate('/notify');
}
};
return (
<>
{/* Bell trigger button */}
<button
type="button"
className="relative flex h-9 w-9 items-center justify-center rounded-md hover:bg-muted cursor-pointer bg-transparent border-0"
onClick={() => setOpen(true)}
title="Notifications"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white px-1">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
{isLive && (
<span
className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-green-500"
title="Live"
/>
)}
</button>
{/* Drawer sheet */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-96 max-w-full flex flex-col p-0">
<SheetHeader className="border-b px-4 py-3 flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<SheetTitle className="m-0 text-base">Notifications</SheetTitle>
{isLive && (
<span className="flex items-center gap-1 text-xs text-green-600">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Live
</span>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<Button
size="sm"
variant="ghost"
className="h-8 gap-1 text-xs"
onClick={() => markAllRead()}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => navigate('/notify')}
title="All settings"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</SheetHeader>
{/* Unread count */}
{unreadCount > 0 && (
<div className="px-4 py-2 bg-primary/5 border-b">
<span className="text-xs text-muted-foreground">
<span className="font-semibold text-foreground">{unreadCount}</span> unread
{unreadCount > 5 && (
<button
type="button"
className="ml-2 text-primary hover:underline"
onClick={() => markAllRead()}
>
Mark all read
</button>
)}
</span>
</div>
)}
{/* Notification list */}
<div className="flex-1 overflow-y-auto">
{drawerNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<BellOff className="h-10 w-10 mb-3 opacity-40" />
<p className="font-medium text-sm">No notifications yet</p>
<p className="text-xs mt-1">You'll see updates here when something happens.</p>
</div>
) : (
drawerNotifications.map((n) => (
<NotificationItem
key={n.id}
n={n}
onMarkRead={markRead}
onArchive={archive}
onNavigate={handleNavigate}
/>
))
)}
</div>
{/* Footer */}
<div className="border-t p-3 flex justify-center">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
setOpen(false);
navigate('/notify');
}}
>
View all notifications
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Send, Eye, Edit2 } from "lucide-react";
@ -22,6 +22,10 @@ interface PRCommentInputProps {
autoFocus?: boolean;
/** Minimum height of the textarea */
minRows?: number;
/** Called when user starts typing (for typing indicator) */
onTypingStart?: () => void;
/** Called when user stops typing (for typing indicator) */
onTypingStop?: () => void;
}
export function PRCommentInput({
@ -34,14 +38,30 @@ export function PRCommentInput({
onCancel,
autoFocus = false,
minRows = 3,
onTypingStart,
onTypingStop,
}: PRCommentInputProps) {
const [body, setBody] = useState(initialValue);
const [isPreview, setIsPreview] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const stopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleTypingChange = useCallback((value: string) => {
setBody(value);
if (value && onTypingStart) {
onTypingStart();
}
if (stopTimerRef.current) clearTimeout(stopTimerRef.current);
stopTimerRef.current = setTimeout(() => {
if (onTypingStop) onTypingStop();
}, 1500);
}, [onTypingStart, onTypingStop]);
const handleSubmit = async () => {
if (!body.trim()) return;
setIsSubmitting(true);
if (stopTimerRef.current) { clearTimeout(stopTimerRef.current); stopTimerRef.current = null; }
if (onTypingStop) onTypingStop();
try {
await onSubmit(body.trim());
setBody("");
@ -91,7 +111,7 @@ export function PRCommentInput({
) : (
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onChange={(e) => handleTypingChange(e.target.value)}
placeholder={placeholder}
rows={minRows}
autoFocus={autoFocus}

View File

@ -12,6 +12,9 @@ import {
} from "@/client";
import { PRCommentInput } from "./PRCommentInput";
import { PRInlineComment } from "./PRInlineComment";
import { ContentRenderer } from "@/components/shared/ContentRenderer";
import { useTypingIndicator } from "@/hooks/useTypingIndicator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -67,6 +70,9 @@ export function PRConversation({
const [addReviewerOpen, setAddReviewerOpen] = useState(false);
const [newReviewerUid, setNewReviewerUid] = useState("");
// Typing indicator for comment input
const { typingUsers, sendTypingStart, sendTypingStop } = useTypingIndicator({});
// Fetch review comments (general comments only — no path)
const { data: commentsData, isLoading: commentsLoading } = useQuery({
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
@ -316,9 +322,7 @@ export function PRConversation({
</span>
</div>
{review.body && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{review.body}
</p>
<ContentRenderer content={review.body} className="text-sm text-muted-foreground" />
)}
</div>
))}
@ -339,7 +343,36 @@ export function PRConversation({
placeholder="Leave a comment..."
buttonLabel="Comment"
onSubmit={(body) => createCommentMutation.mutate(body)}
onTypingStart={sendTypingStart}
onTypingStop={sendTypingStop}
/>
{typingUsers.length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex -space-x-1.5">
{typingUsers.slice(0, 3).map((u) => (
<Avatar key={u.userId} className="h-5 w-5 border border-background">
{u.avatarUrl ? (
<img src={u.avatarUrl} alt={u.username} className="h-5 w-5 rounded-full object-cover" />
) : (
<AvatarFallback className="text-[10px]">{u.username[0]?.toUpperCase()}</AvatarFallback>
)}
</Avatar>
))}
</div>
<span className="text-xs text-muted-foreground">
{typingUsers.length === 1
? `${typingUsers[0].username} is typing…`
: typingUsers.length === 2
? `${typingUsers[0].username} and ${typingUsers[1].username} are typing…`
: `${typingUsers.length} people are typing…`}
</span>
<span className="flex gap-0.5 ml-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
)}
</div>
{/* Comment list */}

View File

@ -10,6 +10,7 @@ import {
} from "@/client";
import { PRInlineComment } from "./PRInlineComment";
import { PRCommentInput } from "./PRCommentInput";
import { MiniChat } from "@/components/shared/MiniChat";
import {
ChevronDown,
ChevronRight,
@ -18,7 +19,10 @@ import {
Plus,
Loader2,
File,
MessageSquare,
X,
} from "lucide-react";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { toast } from "sonner";
interface PRDiffViewerProps {
@ -68,6 +72,7 @@ function FileSection({
repoName,
prNumber,
onRefresh,
onOpenChat,
}: {
file: SideBySideFileResponse;
commentMap: LineCommentMap;
@ -75,6 +80,7 @@ function FileSection({
repoName: string;
prNumber: number;
onRefresh: () => void;
onOpenChat: (path: string) => void;
}) {
const [collapsed, setCollapsed] = useState(false);
const [addingComment, setAddingComment] = useState<{
@ -158,6 +164,15 @@ function FileSection({
-{file.deletions}
</span>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpenChat(file.path); }}
className="ml-1 px-2 py-0.5 text-xs border border-transparent hover:border-border rounded flex items-center gap-1 transition-colors text-muted-foreground hover:text-foreground"
title="Discuss this file in chat"
>
<MessageSquare className="h-3 w-3" />
Chat
</button>
</div>
</button>
@ -267,6 +282,7 @@ export function PRDiffViewer({
head,
}: PRDiffViewerProps) {
const queryClient = useQueryClient();
const [chatFile, setChatFile] = useState<string | null>(null);
const { data: diffData, isLoading: diffLoading } = useQuery({
queryKey: ["pr-diff-side-by-side", namespace, repoName, prNumber, base, head],
@ -345,8 +361,43 @@ export function PRDiffViewer({
repoName={repoName}
prNumber={prNumber}
onRefresh={handleRefresh}
onOpenChat={setChatFile}
/>
))}
{/* Discuss-in-chat side panel */}
<Sheet open={!!chatFile} onOpenChange={(open) => !open && setChatFile(null)}>
<SheetContent side="right" className="w-[420px] p-0 flex flex-col">
{chatFile && (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm font-semibold truncate">
Discussion: {chatFile}
</span>
</div>
<button
type="button"
onClick={() => setChatFile(null)}
className="p-1 rounded hover:bg-muted shrink-0 cursor-pointer bg-transparent border-0"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-hidden">
<MiniChat
roomId={`pr:${namespace}/${repoName}#${prNumber}:${chatFile}`}
repoBaseUrl={`/repository/${namespace}/${repoName}`}
branch={head}
maxHeight={600}
className="h-full rounded-none border-0"
/>
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}

View File

@ -10,6 +10,7 @@ import {
} from "@/client";
import { Button } from "@/components/ui/button";
import { PRCommentInput } from "./PRCommentInput";
import { ContentRenderer } from "@/components/shared/ContentRenderer";
import {
Bot,
Check,
@ -217,7 +218,7 @@ export function PRInlineComment({
autoFocus
/>
) : (
<div className="text-sm whitespace-pre-wrap">{comment.body}</div>
<ContentRenderer content={comment.body} className="text-sm" />
)}
{/* Actions */}

View File

@ -1,10 +1,11 @@
import React, { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CornerUpLeft, Download, FileIcon, FileText, FolderIcon, HardDriveDownload, Loader2, X } from "lucide-react";
import { CornerUpLeft, Download, FileIcon, FileText, FolderIcon, HardDriveDownload, Loader2, MessageSquare, X } from "lucide-react";
import { gitBlobContent, gitCommitLog, gitTreeList } from "@/client";
import { useRepo } from "@/contexts";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { MiniChat } from "@/components/shared/MiniChat";
type TreeEntry = {
name: string;
@ -24,6 +25,13 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
const [currentPath, setCurrentPath] = useState(initialPath);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<{ path: string; name: string; oid: string } | null>(null);
const [previewTab, setPreviewTab] = useState<'content' | 'chat'>('content');
// Reset to content tab when a new file is opened
const handlePreviewFile = useCallback((file: { path: string; name: string; oid: string } | null) => {
setPreviewFile(file);
setPreviewTab('content');
}, []);
// Get the latest commit to find the tree_id for the current branch
const { data: commitsData, isLoading: commitsLoading } = useQuery({
@ -81,13 +89,13 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
} else {
// Open file preview for markdown or show download for others
if (/\.(md|mdx)$/i.test(entry.name)) {
setPreviewFile({ path: currentPath ? `${currentPath}/${entry.name}` : entry.name, name: entry.name, oid: entry.oid });
handlePreviewFile({ path: currentPath ? `${currentPath}/${entry.name}` : entry.name, name: entry.name, oid: entry.oid });
} else {
setSelectedFile(entry.name);
}
}
},
[currentPath, updatePath]
[currentPath, updatePath, handlePreviewFile]
);
const handleGoBack = useCallback(() => {
@ -225,24 +233,47 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<span className="text-base sm:text-lg truncate">{previewFile.name}</span>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end shrink-0">
<div className="flex items-center gap-1.5 flex-wrap justify-end shrink-0">
{/* Content / Chat tab toggle */}
<button
onClick={() => setPreviewTab('content')}
className={`px-2.5 py-1 text-xs border rounded-md transition-colors ${
previewTab === 'content'
? 'bg-primary/10 text-primary border-primary/30'
: 'hover:bg-muted border-transparent'
}`}
>
Content
</button>
<button
onClick={() => setPreviewTab('chat')}
className={`px-2.5 py-1 text-xs border rounded-md transition-colors flex items-center gap-1 ${
previewTab === 'chat'
? 'bg-primary/10 text-primary border-primary/30'
: 'hover:bg-muted border-transparent'
}`}
>
<MessageSquare className="h-3 w-3" />
Chat
</button>
<div className="w-px h-5 bg-border mx-1" />
<button
onClick={handleCopy}
disabled={blobLoading}
className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted disabled:opacity-50"
className="px-3 py-1 text-sm border rounded-md hover:bg-muted disabled:opacity-50"
>
Copy
</button>
<button
onClick={handleDownload}
disabled={blobLoading}
className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted flex items-center gap-1"
className="px-3 py-1 text-sm border rounded-md hover:bg-muted flex items-center gap-1"
>
<Download className="h-4 w-4" />
Download
</button>
<button
onClick={() => setPreviewFile(null)}
onClick={() => handlePreviewFile(null)}
className="p-1.5 border rounded-md hover:bg-muted"
>
<X className="h-4 w-4" />
@ -253,27 +284,42 @@ export const FileBrowser = ({ branch, initialPath = "" }: FileBrowserProps) => {
{previewFile.path}
</div>
</div>
<div className="flex-1 overflow-auto p-3 sm:p-6">
{blobLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : !blobContent ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
<p className="text-lg font-medium">Empty file</p>
<p className="text-sm text-muted-foreground">This file has no content</p>
{previewTab === 'content' ? (
<div className="flex-1 overflow-auto p-3 sm:p-6">
{blobLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
</div>
) : (
<pre className="text-sm font-mono bg-muted/50 p-3 sm:p-4 rounded-lg overflow-x-auto max-w-full whitespace-pre-wrap">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{blobContent}
</ReactMarkdown>
</pre>
)}
</div>
) : !blobContent ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
<p className="text-lg font-medium">Empty file</p>
<p className="text-sm text-muted-foreground">This file has no content</p>
</div>
</div>
) : (
<pre className="text-sm font-mono bg-muted/50 p-3 sm:p-4 rounded-lg overflow-x-auto max-w-full whitespace-pre-wrap">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{blobContent}
</ReactMarkdown>
</pre>
)}
</div>
) : (
<div className="flex-1 overflow-hidden p-0">
{repo && (
<MiniChat
roomId={`repo:${repo.namespace}:${repo.repo_name}:${previewFile.path}`}
repoBaseUrl={`/repository/${repo.namespace}/${repo.repo_name}`}
branch={branch ?? 'main'}
maxHeight={400}
className="h-full rounded-none border-0"
/>
)}
</div>
)}
</div>
</div>
)}

View File

@ -5,7 +5,7 @@ import type { RoomWithCategory } from '@/contexts/room-context';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight, Hash, Lock, Plus, X, GripVertical } from 'lucide-react';
import { ChevronDown, ChevronRight, Hash, Lock, Plus, X, GripVertical, BellOff, Archive } from 'lucide-react';
import {
DndContext,
closestCorners,
@ -29,6 +29,8 @@ import { CSS } from '@dnd-kit/utilities';
interface RoomWithUnread extends RoomWithCategory {
unread_count?: number;
muted?: boolean;
archived?: boolean;
}
interface DiscordChannelSidebarProps {
@ -40,12 +42,15 @@ interface DiscordChannelSidebarProps {
categories: Array<{ id: string; name: string }>;
onCreateCategory: (name: string) => Promise<void>;
onMoveRoomToCategory: (roomId: string, categoryId: string | null) => void;
onMuteRoom?: (roomId: string, muted: boolean) => void;
onArchiveRoom?: (roomId: string, archived: boolean) => void;
onOpenSettings?: () => void;
}
type CatName = string;
const DRAG_PREFIX = 'room:';
const CAT_PREFIX = 'cat:';
/* ── Draggable row ─────────────────────────────────────────────── */
@ -92,31 +97,66 @@ const RoomButton = memo(function RoomButton({
room,
selectedRoomId,
onSelectRoom,
onMute,
onArchive,
}: {
room: RoomWithCategory;
selectedRoomId: string | null;
onSelectRoom: (room: RoomWithCategory) => void;
onMute?: (roomId: string, muted: boolean) => void;
onArchive?: (roomId: string, archived: boolean) => void;
}) {
const isSelected = selectedRoomId === room.id;
const unreadCount = (room as RoomWithUnread).unread_count ?? 0;
const meta = room as RoomWithUnread;
const unreadCount = meta.unread_count ?? 0;
const muted = meta.muted ?? false;
const archived = meta.archived ?? false;
return (
<button
type="button"
onClick={() => onSelectRoom(room)}
className={cn('discord-channel-item w-full group', isSelected && 'active')}
className={cn('discord-channel-item w-full group', isSelected && 'active', archived && 'opacity-50')}
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-70 shrink-0 mr-1" />
<Hash className="discord-channel-hash" />
<span className="discord-channel-name">{room.room_name}</span>
{!room.public && (
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0 ml-auto" />
{!room.public && !muted && !archived && (
<Lock className="h-3.5 w-3.5 opacity-50 shrink-0" />
)}
{unreadCount > 0 && (
{muted && <BellOff className="h-3 w-3 opacity-50 shrink-0 text-amber-500" aria-label="Muted" />}
{archived && <Archive className="h-3 w-3 opacity-50 shrink-0 text-muted-foreground" aria-label="Archived" />}
{!muted && !archived && unreadCount > 0 && (
<span className="discord-mention-badge">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
{/* Mute / Archive context actions */}
{(onMute || onArchive) && (
<div className="ml-auto hidden group-hover:flex items-center gap-0.5">
{onMute && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onMute(room.id, !muted); }}
className="p-0.5 rounded hover:bg-muted cursor-pointer bg-transparent border-0"
title={muted ? 'Unmute' : 'Mute'}
>
<BellOff className={cn('h-3 w-3', muted ? 'text-amber-500' : 'text-muted-foreground')} />
</button>
)}
{onArchive && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onArchive(room.id, !archived); }}
className="p-0.5 rounded hover:bg-muted cursor-pointer bg-transparent border-0"
title={archived ? 'Unarchive' : 'Archive'}
>
<Archive className={cn('h-3 w-3', archived ? 'text-foreground' : 'text-muted-foreground')} />
</button>
)}
</div>
)}
</button>
);
});
@ -124,6 +164,7 @@ const RoomButton = memo(function RoomButton({
/* ── Category group ────────────────────────────────────────────── */
const ChannelGroup = memo(function ChannelGroup({
categoryId,
categoryName,
rooms,
selectedRoomId,
@ -131,7 +172,10 @@ const ChannelGroup = memo(function ChannelGroup({
isCollapsed,
onToggle,
canReceiveDrops,
onMute,
onArchive,
}: {
categoryId: string;
categoryName: string;
rooms: RoomWithCategory[];
selectedRoomId: string | null;
@ -139,31 +183,66 @@ const ChannelGroup = memo(function ChannelGroup({
isCollapsed?: boolean;
onToggle?: () => void;
canReceiveDrops?: true;
onMute?: (roomId: string, muted: boolean) => void;
onArchive?: (roomId: string, archived: boolean) => void;
}) {
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
// Category header is sortable (for drag-to-reorder categories)
const {
attributes: catAttrs,
listeners: catListeners,
setNodeRef: setCatRef,
transform: catTransform,
transition: catTransition,
isDragging: isCatDragging,
} = useSortable({ id: `${CAT_PREFIX}${categoryId}` });
const catStyle: React.CSSProperties = {
transform: CSS.Transform.toString(catTransform),
transition: catTransition,
opacity: isCatDragging ? 0.5 : 1,
};
// Make the category header a droppable zone so rooms can be dragged onto it
const { setNodeRef: setHeaderRef, isOver: isOverHeader } = useDroppable({ id: categoryName });
// Aggregate unread from all rooms in this category
const totalUnread = rooms.reduce((acc, r) => acc + ((r as RoomWithUnread).unread_count ?? 0), 0);
return (
<div
className="discord-channel-category"
onDragOver={(e) => e.preventDefault()}
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
>
<button
ref={setHeaderRef}
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed', isOverHeader && 'ring-1 ring-accent')}
onClick={onToggle}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<span className="flex-1 text-left">{categoryName}</span>
</button>
<div ref={setCatRef} style={catStyle} className="flex items-center">
<button
ref={setHeaderRef}
{...catAttrs}
{...catListeners}
className={cn(
'discord-channel-category-header w-full flex-1',
isCollapsed && 'collapsed',
isOverHeader && 'ring-1 ring-accent',
)}
onClick={onToggle}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<GripVertical className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-70 shrink-0" />
<span className="flex-1 text-left">{categoryName}</span>
{totalUnread > 0 && (
<span className="text-[10px] px-1 rounded-full bg-muted text-muted-foreground">
{totalUnread > 99 ? '99+' : totalUnread}
</span>
)}
</button>
</div>
{!isCollapsed && (
<ul className="space-y-0.5 pl-2">
@ -174,6 +253,8 @@ const ChannelGroup = memo(function ChannelGroup({
room={room}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
onMute={onMute}
onArchive={onArchive}
/>
</DraggableRow>
))}
@ -196,6 +277,8 @@ function ChannelListContent({
collapsedState,
toggleCategory,
onMoveRoom,
onMute,
onArchive,
}: {
rooms: RoomWithCategory[];
selectedRoomId: string | null;
@ -206,6 +289,8 @@ function ChannelListContent({
collapsedState: Record<string, boolean>;
toggleCategory: (name: string) => void;
onMoveRoom: (roomId: string, catId: string | null) => void;
onMute?: (roomId: string, muted: boolean) => void;
onArchive?: (roomId: string, archived: boolean) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@ -224,6 +309,14 @@ function ChannelListContent({
if (!over) return;
const dragId = String(event.active.id);
// Category reorder
if (dragId.startsWith(CAT_PREFIX)) {
// Category reordering — emit an event if the parent component manages order
// Currently categories don't have a backend order API; handled by parent
return;
}
if (!dragId.startsWith(DRAG_PREFIX)) return;
const draggedRoomId = dragId.slice(DRAG_PREFIX.length);
@ -254,26 +347,35 @@ function ChannelListContent({
{/* Uncategorized channels at top */}
{uncategorizedRooms.length > 0 && (
<ChannelGroup
categoryId="__uncategorized__"
categoryName="Channels"
rooms={uncategorizedRooms}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
onMute={onMute}
onArchive={onArchive}
/>
)}
{/* Categorized groups */}
{sortedCatNames.map((catName) => (
<ChannelGroup
key={catName}
categoryName={catName}
rooms={categorizedRooms.get(catName)!}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
isCollapsed={!!collapsedState[catName]}
onToggle={() => toggleCategory(catName)}
canReceiveDrops
/>
))}
{sortedCatNames.map((catName) => {
const cat = categories.find((c) => c.name === catName);
return (
<ChannelGroup
key={catName}
categoryId={cat?.id ?? catName}
categoryName={catName}
rooms={categorizedRooms.get(catName)!}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
isCollapsed={!!collapsedState[catName]}
onToggle={() => toggleCategory(catName)}
canReceiveDrops
onMute={onMute}
onArchive={onArchive}
/>
);
})}
</DndContext>
);
}
@ -289,6 +391,8 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
categories,
onCreateCategory,
onMoveRoomToCategory,
onMuteRoom,
onArchiveRoom,
onOpenSettings,
}: DiscordChannelSidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
@ -380,6 +484,8 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
collapsedState={collapsed}
toggleCategory={toggleCategory}
onMoveRoom={handleMoveRoom}
onMute={onMuteRoom}
onArchive={onArchiveRoom}
/>
{rooms.length === 0 && (

View File

@ -195,9 +195,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
[room.id, updateRoom],
);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
// Scroll handling is done entirely by MessageList
useEffect(() => {
setReplyingTo(null);

View File

@ -9,7 +9,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { useUser } from '@/contexts';
import { cn } from '@/lib/utils';
import { Copy, Edit, MoreHorizontal, Reply, Trash2 } from 'lucide-react';
import { AlertCircle, Copy, Edit, GitPullRequest, LayoutDashboard, MoreHorizontal, Reply, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { getSenderUserUid } from './sender';
@ -62,6 +62,76 @@ export function RoomMessageActions({ message, onEdit, onRevoke, onReply }: RoomM
Copy
</DropdownMenuItem>
)}
{message.content_type === 'text' && (
<DropdownMenuItem
onClick={() => {
toast.info('Creating issue from message…', {
description: 'This will open the issue creation form with the message content pre-filled.',
action: {
label: 'Create',
onClick: () => {
// TODO: wire to POST /api/issue/{project}/issues/from-message
const title = message.content.split('\n')[0].slice(0, 80);
const body = `Converted from room message (${message.id})\n\n${message.content}`;
const params = new URLSearchParams({ title, body });
window.open(`/project/-/issues/new?${params}`, '_blank');
},
},
});
setIsOpen(false);
}}
>
<AlertCircle className="mr-2 h-4 w-4" />
Create Issue
</DropdownMenuItem>
)}
{message.content_type === 'text' && /\n```[\s\S]*?\n```/.test(message.content) && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
toast.info('Creating PR from message…', {
description: 'Open the PR creation form with the code snippet pre-filled.',
action: {
label: 'Open',
onClick: () => {
// TODO: wire to POST /api/repo_pr/{ns}/{repo}/pulls/from-message
const params = new URLSearchParams({
body: `Converted from room message (${message.id})\n\n${message.content}`,
});
window.open(`/pulls/new?${params}`, '_blank');
},
},
});
}}
>
<GitPullRequest className="mr-2 h-4 w-4" />
Create PR
</DropdownMenuItem>
)}
{message.content_type === 'text' && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
toast.info('Adding to board…', {
description: 'Open the kanban board with this message as the card description.',
action: {
label: 'Open Board',
onClick: () => {
// TODO: wire to POST /api/board/cards
const params = new URLSearchParams({
title: message.content.split('\n')[0].slice(0, 80),
description: message.content,
});
window.open(`/boards/new?${params}`, '_blank');
},
},
});
}}
>
<LayoutDashboard className="mr-2 h-4 w-4" />
Add to Board
</DropdownMenuItem>
)}
{isOwner && onEdit && (
<>
<DropdownMenuSeparator />

View File

@ -1,12 +1,57 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Pin, X, Loader2 } from 'lucide-react';
import { Pin, X, Loader2, Search, Info, Link, BookMarked, Archive } from 'lucide-react';
import { pinList, pinRemove, type RoomPinResponse, type RoomMemberResponse } from '@/client';
import { toast } from 'sonner';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { getSenderDisplayName } from './sender';
import { formatMessageTime } from './shared/formatters';
import { cn } from '@/lib/utils';
// ── Category types ────────────────────────────────────────────────────────────
export type PinCategory = 'all' | 'info' | 'resources' | 'rules' | 'archive';
interface CategorizedPin extends RoomPinResponse {
category: PinCategory;
}
const CATEGORY_META: Record<Exclude<PinCategory, 'all'>, { label: string; Icon: React.ComponentType<{ className?: string }>; color: string }> = {
info: { label: 'Info', Icon: Info, color: 'text-blue-500' },
resources: { label: 'Resources', Icon: Link, color: 'text-green-500' },
rules: { label: 'Rules', Icon: BookMarked, color: 'text-amber-500' },
archive: { label: 'Archive', Icon: Archive, color: 'text-muted-foreground' },
};
// ── Smart categorization ─────────────────────────────────────────────────────
function categorizePin(pin: RoomPinResponse): PinCategory {
const content = (pin as unknown as { content?: string }).content ?? '';
const lower = content.toLowerCase();
if (
/https?:\/\/|github\.com|bitbucket\.org|gitlab\.com|docs\.|readme|wiki|spec|rfc|md\b/i.test(lower) ||
/@[a-z]+(?:\/[a-z]+){0,2}\b/.test(content) // repo/project mentions
) {
return 'resources';
}
if (
/\b(do not|must not|please remember|always|never|rule|guideline|policy|forbidden|required|ensure that|make sure)\b/i.test(lower)
) {
return 'rules';
}
// Archive pins older than 30 days
const age = Date.now() - new Date(pin.pinned_at).getTime();
if (age > 30 * 24 * 60 * 60 * 1000) {
return 'archive';
}
return 'info';
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface RoomPinPanelProps {
roomId: string;
@ -24,13 +69,21 @@ interface RoomPinPanelProps {
export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessage }: RoomPinPanelProps) {
const queryClient = useQueryClient();
const [activeCategory, setActiveCategory] = useState<PinCategory>('all');
const [search, setSearch] = useState('');
const { data: pins, isLoading } = useQuery({
queryKey: ['roomPins', roomId],
queryFn: async () => {
const resp = await pinList({ path: { room_id: roomId } });
return resp.data?.data ?? ([] as RoomPinResponse[]);
const raw = resp.data?.data ?? ([] as RoomPinResponse[]);
// Attach content from local messages for categorization
return raw.map((p) => ({
...p,
content: messages.find((m) => m.id === p.message)?.content ?? '',
})) as Array<RoomPinResponse & { content: string }>;
},
staleTime: 30_000,
});
const unpinMutation = useMutation({
@ -46,8 +99,35 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
},
});
// Build a map of message_id -> message content from local messages
const messageMap = new Map(messages.map(m => [m.id, m]));
const messageMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const categorizedPins = useMemo<CategorizedPin[]>(() => {
if (!pins) return [];
return pins.map((p) => ({
...p,
category: categorizePin(p as unknown as RoomPinResponse),
}));
}, [pins]);
const filteredPins = useMemo(() => {
return categorizedPins.filter((p) => {
const matchesCategory = activeCategory === 'all' || p.category === activeCategory;
const matchesSearch =
!search ||
(messageMap.get(p.message)?.content ?? '').toLowerCase().includes(search.toLowerCase());
return matchesCategory && matchesSearch;
});
}, [categorizedPins, activeCategory, search, messageMap]);
const countByCategory = useMemo(() => {
const counts: Record<PinCategory, number> = { all: categorizedPins.length, info: 0, resources: 0, rules: 0, archive: 0 };
for (const p of categorizedPins) {
if (p.category !== 'all') counts[p.category]++;
}
return counts;
}, [categorizedPins]);
const categories: PinCategory[] = ['all', 'info', 'resources', 'rules', 'archive'];
return (
<aside
@ -70,7 +150,7 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
<div className="flex items-center gap-2">
<Pin className="h-4 w-4" style={{ color: 'var(--room-accent)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--room-text)' }}>
Pinned Messages
Pinned
</span>
{pins && (
<span
@ -90,21 +170,72 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
</button>
</div>
{/* Search */}
<div className="px-4 py-2 border-b" style={{ borderColor: 'var(--room-border)' }}>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--room-text-muted)]" />
<input
type="text"
placeholder="Search pins…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full h-7 pl-7 pr-3 rounded text-[12px] bg-transparent border-0 outline-none placeholder:text-[var(--room-text-muted)]"
style={{ color: 'var(--room-text)', background: 'var(--room-channel-active)' }}
/>
</div>
</div>
{/* Category tabs */}
<div
className="flex items-center gap-1 px-4 py-1.5 border-b overflow-x-auto shrink-0"
style={{ borderColor: 'var(--room-border)' }}
>
{categories.map((cat) => {
const meta = cat === 'all' ? null : CATEGORY_META[cat];
const count = countByCategory[cat];
if (cat !== 'all' && count === 0) return null;
return (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium whitespace-nowrap transition-colors cursor-pointer border-0 bg-transparent',
activeCategory === cat
? 'text-[var(--room-accent)]'
: 'text-[var(--room-text-muted)] hover:text-[var(--room-text)]',
)}
>
{meta && <meta.Icon className={cn('h-3 w-3', meta.color)} />}
{cat === 'all' ? 'All' : meta?.label}
<span
className="ml-0.5 px-1 rounded-full text-[10px]"
style={{ background: activeCategory === cat ? 'var(--room-channel-active)' : 'transparent' }}
>
{count}
</span>
</button>
);
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-24">
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--room-text-muted)' }} />
</div>
) : pins && pins.length === 0 ? (
) : filteredPins.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 gap-2">
<Pin className="h-8 w-8 opacity-30" style={{ color: 'var(--room-text-muted)' }} />
<p className="text-sm" style={{ color: 'var(--room-text-muted)' }}>No pinned messages</p>
<p className="text-sm" style={{ color: 'var(--room-text-muted)' }}>
{search ? 'No matching pins' : 'No pinned messages'}
</p>
</div>
) : (
<div className="divide-y" style={{ borderColor: 'var(--room-border)' }}>
{pins?.map((pin) => {
{filteredPins.map((pin) => {
const localMsg = messageMap.get(pin.message);
const catMeta = pin.category === 'all' ? null : CATEGORY_META[pin.category];
return (
<div
key={pin.message}
@ -115,10 +246,13 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
}}
>
{/* Sender row */}
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-1.5 mb-1">
{catMeta && (
<catMeta.Icon className={cn('h-3 w-3 shrink-0', catMeta.color)} />
)}
<Avatar className="h-5 w-5">
{(() => {
const member = members.find(m => m.user === pin.pinned_by);
const member = members.find((m) => m.user === pin.pinned_by);
return (
<>
{member?.user_info?.avatar_url ? (
@ -133,12 +267,12 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
</Avatar>
<span className="text-xs font-medium" style={{ color: 'var(--room-text)' }}>
{(() => {
const member = members.find(m => m.user === pin.pinned_by);
const member = members.find((m) => m.user === pin.pinned_by);
return member?.user_info?.username ?? pin.pinned_by;
})()}
</span>
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
pinned {formatMessageTime(pin.pinned_at).split(':').slice(0, 2).join(':')}
{formatMessageTime(pin.pinned_at).split(':').slice(0, 2).join(':')}
</span>
<button
onClick={(e) => {
@ -154,7 +288,7 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
{/* Message preview */}
<p
className="text-[13px] line-clamp-2 pl-7"
className="text-[13px] line-clamp-2 pl-5"
style={{ color: 'var(--room-text-secondary)' }}
>
{localMsg
@ -166,9 +300,9 @@ export function RoomPinPanel({ roomId, messages, members, onClose, onJumpToMessa
{/* Original sender */}
{localMsg && (
<div className="mt-0.5 pl-7">
<div className="mt-0.5 pl-5">
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>
{getSenderDisplayName(localMsg as any)} {formatMessageTime(localMsg.send_at)}
{getSenderDisplayName(localMsg as Parameters<typeof getSenderDisplayName>[0])} {formatMessageTime(localMsg.send_at)}
</span>
</div>
)}

View File

@ -316,18 +316,46 @@ export const MessageBubble = memo(function MessageBubble({
<div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}>
{message.content_type === 'text' || message.content_type === 'Text' ? (
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
{functionCalls.length > 0 ? (
{/* Thinking phase — rendered as collapsible, muted style */}
{message.chunk_type === 'thinking' && !functionCalls.length && (
<div className="mb-1 rounded-md border px-3 py-2 text-sm italic" style={{ borderColor: 'var(--room-border)', color: 'var(--room-text-subtle)', background: 'var(--room-bg)' }}>
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--room-text-muted)' }}>Thinking</span>
<div className="mt-1">{displayContent}</div>
</div>
)}
{/* Tool call phase — rendered as compact badge */}
{message.chunk_type === 'tool_call' && (
<div className="mb-1 rounded-md border px-3 py-2 text-sm" style={{ borderColor: '#3b82f633', color: 'var(--room-text-secondary)', background: '#3b82f608' }}>
<span className="inline-flex items-center gap-1 text-[11px] font-medium" style={{ color: '#3b82f6' }}>
<span className="size-3 rounded-full bg-blue-500/30 border border-blue-500/60 inline-block" />
Tool Call
</span>
<div className="mt-1 font-mono text-xs" style={{ color: 'var(--room-text-subtle)' }}>{displayContent}</div>
</div>
)}
{/* Tool result phase — rendered as compact output */}
{message.chunk_type === 'tool_result' && (
<div className="mb-1 rounded-md border px-3 py-2 text-sm" style={{ borderColor: displayContent.includes('[Tool call failed') ? '#ef444433' : '#22c55e33', color: 'var(--room-text-secondary)', background: displayContent.includes('[Tool call failed') ? '#ef444408' : '#22c55e08' }}>
<span className="inline-flex items-center gap-1 text-[11px] font-medium" style={{ color: displayContent.includes('[Tool call failed') ? '#ef4444' : '#22c55e' }}>
<span className={cn('size-3 rounded-full inline-block', displayContent.includes('[Tool call failed') ? 'bg-red-500/30 border border-red-500/60' : 'bg-green-500/30 border border-green-500/60')} />
{displayContent.includes('[Tool call failed') ? 'Error' : 'Result'}
</span>
<div className="mt-1 font-mono text-xs whitespace-pre-wrap max-h-[120px] overflow-auto" style={{ color: 'var(--room-text-subtle)' }}>{displayContent}</div>
</div>
)}
{/* Normal answer or no chunk_type — default rendering */}
{!message.chunk_type && functionCalls.length > 0 ? (
functionCalls.map((call, index) => (
<div key={index} className="my-1 rounded-md border bg-white/5 p-2 max-w-xl" style={{ borderColor: 'var(--room-border)' }}>
<FunctionCallBadge functionCall={call} className="w-auto" />
</div>
))
) : (
) : !message.chunk_type ? (
<MessageContent
content={displayContent}
onMentionClick={handleMentionClick}
/>
)}
) : null}
{/* Streaming cursor */}
{isStreaming && <span className="discord-streaming-cursor" />}

View File

@ -1,14 +1,16 @@
'use client';
/**
* Renders message content markdown with @[type:id:label] mentions.
* Mentions are protected from markdown parsing by replacing them with
* placeholder tokens before rendering, then restored in custom text components.
* Renders room message content markdown with @[type:id:label] mentions,
* plus code-aware features: smart link previews and code references.
*/
import { memo, useMemo } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ContentRenderer } from '@/components/shared/ContentRenderer';
import { LinkPreview } from '@/components/shared/LinkPreview';
import { CodeReference } from '@/components/shared/CodeReference';
import { extractUrls, detectLinkType, type UnfurlResult } from '@/lib/link-unfurl';
import { parseCodeRef, type CodeRef } from '@/lib/code-ref-parser';
import { cn } from '@/lib/utils';
interface MessageContentProps {
@ -16,178 +18,74 @@ interface MessageContentProps {
onMentionClick?: (type: string, id: string, label: string) => void;
}
const MENTION_RE = /@\[([a-z]+):([^:\]]+):([^\]]+)\]/g;
interface MentionInfo {
type: string;
id: string;
label: string;
}
/** Replace @[type:id:label] with ◊MENTION_i◊ placeholders (◊ is unlikely in real content) */
function extractMentions(content: string): { safeContent: string; mentions: MentionInfo[] } {
const mentions: MentionInfo[] = [];
const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
const idx = mentions.length;
mentions.push({ type, id, label });
return `\u200BMENTION_${idx}\u200B`; // zero-width spaces prevent markdown parsing
});
return { safeContent, mentions };
}
function getMentionStyle(type: string): string {
switch (type) {
case 'user': return 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300';
case 'channel': return 'bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-300';
case 'ai': return 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-300';
case 'command': return 'bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300';
default: return 'bg-muted text-foreground';
}
}
/** Restore mention placeholders inside a text node into React elements */
function restoreMentions(text: string, mentions: MentionInfo[], onMentionClick?: (type: string, id: string, label: string) => void): React.ReactNode[] {
const MENTION_PLACEHOLDER_RE = /\u200BMENTION_(\d+)\u200B/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MENTION_PLACEHOLDER_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
export const MessageContent = memo(function MessageContent({
content,
onMentionClick,
}: MessageContentProps) {
// Extract standalone URLs for link preview rendering
const urlResults = useMemo<UnfurlResult[]>(() => {
const seen = new Set<string>();
const results: UnfurlResult[] = [];
for (const { url } of extractUrls(content)) {
if (seen.has(url)) continue;
const result = detectLinkType(url);
if (result && !result.isExternal) {
seen.add(url);
results.push(result);
}
}
const idx = parseInt(match[1], 10);
const m = mentions[idx];
if (m) {
parts.push(
<span
key={`mention-${idx}`}
role={onMentionClick ? 'button' : undefined}
tabIndex={onMentionClick ? 0 : undefined}
className={cn(
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
getMentionStyle(m.type),
)}
onClick={() => onMentionClick?.(m.type, m.id, m.label)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && onMentionClick) {
e.preventDefault();
onMentionClick(m.type, m.id, m.label);
}
}}
>
@{m.label}
</span>,
);
return results;
}, [content]);
// Extract code references (file.rs:42 style)
const codeRefs = useMemo<CodeRef[]>(() => {
const seen = new Set<string>();
const refs: CodeRef[] = [];
const matches = content.match(/[^\s:]+:\d+(?:-\d+)?/g) ?? [];
for (const match of matches) {
if (seen.has(match)) continue;
const parsed = parseCodeRef(match);
if (parsed) {
seen.add(match);
refs.push(parsed);
}
}
lastIndex = MENTION_PLACEHOLDER_RE.lastIndex;
}
return refs;
}, [content]);
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
export const MessageContent = memo(function MessageContent({ content, onMentionClick }: MessageContentProps) {
const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
const hasLinkPreviews = urlResults.length > 0;
const hasCodeRefs = codeRefs.length > 0;
return (
<div
className={cn(
'text-[15px] text-foreground',
'max-w-full min-w-0 break-words',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
'[&_p]:whitespace-pre-wrap [&_p]:leading-[1.4] [&_p]:my-1',
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
'[&_li]:my-0.5',
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
'[&_strong]:font-semibold',
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
'[&_hr]:border-foreground/20 [&_hr]:my-2',
'[&_table]:w-full [&_table]:border-collapse [&_table]:rounded-md [&_table]:border [&_table]:border-foreground/20 [&_table]:my-2',
'[&_th]:border [&_th]:border-foreground/20 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-bold',
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
'[&_tr]:border-t [&_tr]:even:bg-muted',
<div className={cn('space-y-2', !hasLinkPreviews && !hasCodeRefs && 'space-y-0')}>
<ContentRenderer
content={content}
onMentionClick={onMentionClick}
/>
{/* Code references */}
{hasCodeRefs && (
<div className="space-y-1">
{codeRefs.map((ref, i) => (
<CodeReference
key={`ref-${i}-${ref.raw}`}
ref={ref}
/>
))}
</div>
)}
{/* Link previews */}
{hasLinkPreviews && (
<div className="space-y-1">
{urlResults.map((result, i) => (
<LinkPreview
key={`link-${i}-${result.url}`}
result={result}
/>
))}
</div>
)}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => {
// Restore mentions in paragraph text nodes
if (typeof children === 'string') {
return <p>{restoreMentions(children, mentions, onMentionClick)}</p>;
}
// Children may be an array of strings/elements
if (Array.isArray(children)) {
const restored = children.map((child) => {
if (typeof child === 'string') {
return restoreMentions(child, mentions, onMentionClick);
}
return child;
});
return <p>{restored}</p>;
}
return <p>{children}</p>;
},
li: ({ children }) => {
if (typeof children === 'string') {
return <li>{restoreMentions(children, mentions, onMentionClick)}</li>;
}
if (Array.isArray(children)) {
const restored = children.map((child) => {
if (typeof child === 'string') {
return restoreMentions(child, mentions, onMentionClick);
}
return child;
});
return <li>{restored}</li>;
}
return <li>{children}</li>;
},
strong: ({ children }) => {
if (typeof children === 'string') {
return <strong>{restoreMentions(children, mentions, onMentionClick)}</strong>;
}
return <strong>{children}</strong>;
},
em: ({ children }) => {
if (typeof children === 'string') {
return <em>{restoreMentions(children, mentions, onMentionClick)}</em>;
}
return <em>{children}</em>;
},
code: ({ className, children, ...props }) => {
// Inline code — don't restore mentions inside code blocks
const isBlock = typeof className === 'string' && className.includes('language-');
if (isBlock) {
// Fenced code block — let the pre wrapper handle it
return <code className={className} {...props}>{children}</code>;
}
return (
<code
className="font-mono rounded bg-muted px-1 py-0.5 text-xs"
{...props}
>
{children}
</code>
);
},
pre: ({ children }) => {
// Preserve code blocks as-is, no mention restoration
return <pre className="rounded-md bg-muted p-3 overflow-x-auto">{children}</pre>;
},
}}
>
{safeContent}
</Markdown>
</div>
);
});
});

View File

@ -95,6 +95,14 @@ export const MessageList = memo(function MessageList({
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRestoringScrollRef = useRef(false);
const firstVisibleMessageIdRef = useRef<string | null>(null);
const isInitialLoadRef = useRef(true);
const wasNearBottomRef = useRef(true);
// Reset initial load flag when switching rooms
useEffect(() => {
isInitialLoadRef.current = true;
wasNearBottomRef.current = true;
}, [roomId]);
const replyMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>();
@ -146,8 +154,11 @@ export const MessageList = memo(function MessageList({
}, [messages, replyMap]);
const scrollToBottom = useCallback((smooth = true) => {
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
}, [messagesEndRef]);
const container = scrollContainerRef.current;
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
}
}, []);
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
@ -155,6 +166,7 @@ export const MessageList = memo(function MessageList({
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceFromBottom < 100;
wasNearBottomRef.current = nearBottom;
requestAnimationFrame(() => {
setShowScrollToBottom(!nearBottom);
@ -184,8 +196,24 @@ export const MessageList = memo(function MessageList({
if (messages.length === 0) return;
const container = scrollContainerRef.current;
if (!container) return;
// On initial load, jump to bottom instantly (no animation)
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false;
wasNearBottomRef.current = true;
// Use requestAnimationFrame to wait for virtualizer to layout
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToBottom(false);
});
});
return;
}
// For new messages: auto-scroll only if user was near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom < 100) {
if (distanceFromBottom < 150) {
wasNearBottomRef.current = true;
requestAnimationFrame(() => scrollToBottom(false));
}
}, [messages.length, scrollToBottom]);

View File

@ -5,25 +5,36 @@ export function getSenderUserUid(message: MessageWithMeta): string | undefined {
return message.sender_id ?? undefined;
}
/** Returns the model ID for AI messages */
/** Returns the model ID for AI messages.
* For AI messages, display_name is the model name; sender_id should be null.
* Returns undefined if sender_id looks like a UUID (which would be a user UID, not a model ID). */
export function getSenderModelId(message: MessageWithMeta): string | undefined {
if (message.sender_type === 'ai' && message.sender_id) {
return message.sender_id;
if (message.sender_type === 'ai') {
// Use display_name for model identification since sender_id is null for proper AI messages
if (message.display_name && !looksLikeUuid(message.display_name)) return message.display_name;
return undefined;
}
return undefined;
}
/** Display name for a message sender */
/** Display name for a message sender.
* For AI messages, prefers display_name (model name), falls back to 'AI'.
* Never returns a raw UUID for AI messages. */
export function getSenderDisplayName(message: MessageWithMeta): string {
if (message.sender_type === 'ai') {
if (message.display_name) return message.display_name;
if (message.display_name && !looksLikeUuid(message.display_name)) return message.display_name;
return 'AI';
}
if (message.display_name) return message.display_name;
if (message.sender_type === 'system') return 'System';
if (message.sender_type === 'tool') return 'Tool';
if (message.display_name) return message.display_name;
return message.sender_type;
}
function looksLikeUuid(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
/** Avatar URL from a MessageWithMeta.
* Callers should pass members to resolve the avatar.
* This helper returns undefined for now avatar resolution is done in components

View File

@ -0,0 +1,155 @@
'use client';
/**
* Enhanced code block component with:
* - Language badge (from code-lang-detect.ts)
* - Copy button
* - Optional line numbers
* - Whitespace toggle
*/
import { useState } from 'react';
import { Copy, Check, WrapText } from 'lucide-react';
import { cn } from '@/lib/utils';
import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect';
interface CodeBlockProps {
children: string;
/** Language tag from the fenced code block (e.g. "ts", "rust") */
language?: string;
/** Auto-detect language from content if no tag provided */
autoDetect?: boolean;
/** Show line numbers */
showLineNumbers?: boolean;
className?: string;
/** Additional header content (e.g. file name) */
filename?: string;
}
export function CodeBlock({
children,
language,
autoDetect = true,
showLineNumbers = false,
className,
filename,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const [wrap, setWrap] = useState(false);
// Resolve language
const langInfo = language
? getLanguageFromTag(language)
: autoDetect
? detectLanguage(children)
: null;
const displayLang = langInfo?.label ?? language ?? null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = children;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const lines = children.split('\n');
return (
<div
className={cn(
'rounded-lg border bg-muted overflow-hidden my-2',
className,
)}
>
{/* Header bar */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-muted/70">
{/* Traffic light dots (macOS style) */}
<div className="flex gap-1.5 -ml-1">
<div className="h-2.5 w-2.5 rounded-full bg-red-500/60" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/60" />
<div className="h-2.5 w-2.5 rounded-full bg-green-500/60" />
</div>
{filename && (
<span className="text-xs font-mono text-muted-foreground ml-1 truncate max-w-48">
{filename}
</span>
)}
<div className="ml-auto flex items-center gap-1">
{displayLang && (
<span className="text-xs font-mono bg-muted-foreground/10 text-muted-foreground px-1.5 py-0.5 rounded">
{displayLang}
</span>
)}
{showLineNumbers && (
<button
type="button"
onClick={() => setWrap((w) => !w)}
className={cn(
'p-1 rounded hover:bg-muted-foreground/10 transition-colors',
wrap && 'text-primary',
)}
title={wrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<WrapText className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={handleCopy}
className="p-1 rounded hover:bg-muted-foreground/10 transition-colors"
title={copied ? 'Copied!' : 'Copy code'}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</div>
</div>
{/* Code content */}
<div
className={cn(
'overflow-x-auto',
wrap && 'whitespace-pre-wrap',
)}
>
<pre className="p-3 font-mono text-sm leading-5 text-foreground/90">
{showLineNumbers ? (
<table className="w-full border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className="select-none pr-3 text-right text-muted-foreground/40 text-xs w-8 align-top">
{i + 1}
</td>
<td className="text-foreground/90 whitespace-pre">{line}</td>
</tr>
))}
</tbody>
</table>
) : (
children
)}
</pre>
</div>
</div>
);
}

View File

@ -0,0 +1,154 @@
'use client';
/**
* Renders a compact code reference block (file path + line range).
* Clicking navigates to the file in the repository browser.
*/
import { useState } from 'react';
import { FileCode2, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CodeRef } from '@/lib/code-ref-parser';
interface CodeReferenceProps {
ref: CodeRef;
/** API to fetch line content (optional — shows skeleton if not provided) */
getLineContent?: (filePath: string, startLine: number, endLine: number) => Promise<string[]>;
/** Called when the reference is clicked — navigate to file browser */
onClick?: (ref: CodeRef) => void;
/** Base URL for the repository file browser (e.g. /repository/ns/repo) */
repoBaseUrl?: string;
/** Branch name to use in the link */
branch?: string;
className?: string;
}
export function CodeReference({
ref,
getLineContent,
onClick,
repoBaseUrl,
branch = 'main',
className,
}: CodeReferenceProps) {
const [expanded, setExpanded] = useState(false);
const [lines, setLines] = useState<string[] | null>(null);
const [loading, setLoading] = useState(false);
const handleClick = () => {
if (onClick) {
onClick(ref);
return;
}
if (repoBaseUrl) {
// Navigate to file browser with line highlighted
window.location.href = `${repoBaseUrl}/blob/${branch}/${ref.filePath}#L${ref.startLine}`;
}
};
const loadLines = async () => {
if (!getLineContent || lines !== null) return;
setLoading(true);
try {
const content = await getLineContent(ref.filePath, ref.startLine, ref.endLine);
setLines(content);
} catch {
setLines([]);
} finally {
setLoading(false);
}
};
const toggleExpand = () => {
setExpanded((prev) => {
if (!prev) loadLines();
return !prev;
});
};
const lineLabel = ref.endLine === ref.startLine
? `L${ref.startLine}`
: `L${ref.startLine}L${ref.endLine}`;
return (
<div
className={cn(
'rounded-md border bg-muted/50 my-1.5 overflow-hidden',
className,
)}
>
{/* Header bar */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted transition-colors text-left"
onClick={handleClick}
title={ref.filePath ? `View ${ref.filePath}` : `Jump to ${lineLabel}`}
>
<FileCode2 className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{ref.filePath && (
<span className="text-xs font-mono text-muted-foreground truncate">
{ref.filePath}
</span>
)}
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{lineLabel}
</span>
</button>
{/* Code preview (optional) */}
{getLineContent && (
<>
<button
type="button"
className="w-full flex items-center gap-1 px-3 py-0.5 hover:bg-muted/70 transition-colors text-left"
onClick={toggleExpand}
>
{expanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground">
{expanded ? 'Hide preview' : 'Show preview'}
</span>
</button>
{expanded && (
<div className="border-t">
{loading ? (
<div className="px-3 py-2 space-y-1">
{Array.from({ length: Math.min(ref.endLine - ref.startLine + 1, 5) }).map((_, i) => (
<div key={i} className="flex gap-2">
<div className="h-3 w-6 bg-muted rounded animate-pulse" />
<div className="h-3 flex-1 bg-muted rounded animate-pulse" />
</div>
))}
</div>
) : lines && lines.length > 0 ? (
<div className="py-1">
{lines.map((line, i) => {
const lineNum = ref.startLine + i;
return (
<div key={i} className="flex gap-0 px-1 hover:bg-muted/50">
<span className="select-none w-10 text-right pr-2 text-xs text-muted-foreground/50 font-mono shrink-0">
{lineNum}
</span>
<span className="text-xs font-mono leading-5 text-foreground/90 whitespace-pre">
{line || ' '}
</span>
</div>
);
})}
</div>
) : (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
No content available
</div>
)}
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,277 @@
'use client';
/**
* Global command palette (Cmd+K / Ctrl+K).
* Dynamic: fetches real projects, repos, rooms from API.
* Actions: navigate to project/repo/room, create project, create repo.
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
} from '@/components/ui/command';
import {
FolderKanban,
GitBranch,
Hash,
Plus,
Bell,
Search,
} from 'lucide-react';
import { getRegisteredCommands } from '@/hooks/useCommandRegistry';
import type { CommandItem as RegistryCommandItem } from '@/hooks/useCommandRegistry';
import { formatShortcut } from '@/hooks/useKeyboardShortcut';
import { getCurrentUserProjects, projectRepos, roomList } from '@/client';
import type { UserProjectInfo, ProjectRepositoryItem, RoomResponse } from '@/client';
// ── Icons ────────────────────────────────────────────────────────────────────
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
FolderKanban,
GitBranch,
Hash,
Plus,
Bell,
Search,
};
// ── Command item type ────────────────────────────────────────────────────────
interface PaletteItem {
id: string;
label: string;
icon: string;
shortcut?: { key: string; meta?: boolean; shift?: boolean };
action: () => void;
group: string;
keywords: string[];
}
// ── Static quick actions ─────────────────────────────────────────────────────
function buildStaticActions(navigate: ReturnType<typeof useNavigate>): PaletteItem[] {
return [
{
id: 'goto-notifications',
label: 'Go to Notifications',
icon: 'Bell',
shortcut: { key: 'n', meta: true },
action: () => navigate('/notify'),
group: 'Navigation',
keywords: ['goto', 'notifications', 'inbox'],
},
{
id: 'create-project',
label: 'Create Project',
icon: 'Plus',
shortcut: { key: 'c', meta: true, shift: true },
action: () => navigate('/init/project'),
group: 'Create',
keywords: ['create', 'new', 'project'],
},
];
}
// ── Main palette ─────────────────────────────────────────────────────────────
export function CommandPalette() {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
// Fetch projects for the current user (no workspace dependency)
const { data: projectsData } = useQuery({
queryKey: ['commandPaletteProjects'],
queryFn: async () => {
const resp = await getCurrentUserProjects();
return resp.data?.data ?? { projects: [] as UserProjectInfo[], total_count: 0 };
},
});
const projects = projectsData?.projects ?? [];
// Derived project names for repo/room queries
const projectNames = useMemo(
() => projects.map(p => p.name),
[projects],
);
// Fetch repos per project (up to 5 projects to keep it fast)
const { data: reposData } = useQuery({
queryKey: ['commandPaletteRepos', projectNames],
queryFn: async () => {
const results: Record<string, ProjectRepositoryItem[]> = {};
for (const name of projectNames.slice(0, 5)) {
try {
const resp = await projectRepos({ path: { project_name: name } });
const data = resp.data?.data;
if (data?.items) results[name] = data.items;
} catch { /* skip */ }
}
return results;
},
enabled: projectNames.length > 0,
});
// Fetch rooms for each project (up to 5 to keep it fast)
const { data: roomsData } = useQuery({
queryKey: ['commandPaletteRooms', projectNames],
queryFn: async () => {
const results: Record<string, RoomResponse[]> = {};
for (const name of projectNames.slice(0, 5)) {
try {
const resp = await roomList({ path: { project_name: name } });
const data = resp.data?.data;
if (data) results[name] = (data as any)?.rooms ?? (Array.isArray(data) ? data : []);
} catch { /* skip */ }
}
return results;
},
enabled: projectNames.length > 0,
});
// Global shortcut: Ctrl+Alt+F (Windows/Linux) or Cmd+Ctrl+F (Mac)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const isMac = navigator.platform?.toUpperCase().includes('MAC') || navigator.userAgent?.includes('Mac');
const match = isMac
? e.metaKey && e.ctrlKey && e.key === 'f'
: e.ctrlKey && e.altKey && e.key === 'f';
if (match) {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Close on navigation
useEffect(() => {
const handler = () => setOpen(false);
window.addEventListener('popstate', handler);
return () => window.removeEventListener('popstate', handler);
}, []);
const handleSelect = useCallback((action: () => void) => {
setOpen(false);
action();
}, []);
// Build search text including keywords for cmdk filtering
const searchValue = useCallback((item: PaletteItem | RegistryCommandItem) => {
return [item.label, ...(item.keywords ?? [])].join(' ');
}, []);
// ── Build dynamic command list ──────────────────────────────────────────
const allRooms = roomsData ?? {};
const allRepos = reposData ?? {};
const projectItems: PaletteItem[] = projects.map(p => ({
id: `project-${p.name}`,
label: p.display_name || p.name,
icon: 'FolderKanban',
action: () => navigate(`/project/${p.name}`),
group: 'Projects',
keywords: ['project', p.name, p.display_name, ...(p.description ? [p.description] : [])],
}));
const repoItems: PaletteItem[] = [];
for (const [projName, repos] of Object.entries(allRepos)) {
for (const r of repos) {
repoItems.push({
id: `repo-${projName}-${r.repo_name}`,
label: `${r.repo_name} (${projName})`,
icon: 'GitBranch',
action: () => navigate(`/repository/${projName}/${r.repo_name}`),
group: 'Repositories',
keywords: ['repo', 'repository', r.repo_name, projName, ...(r.description ? [r.description] : [])],
});
}
}
const roomItems: PaletteItem[] = [];
for (const [projName, rooms] of Object.entries(allRooms)) {
for (const r of rooms) {
roomItems.push({
id: `room-${r.id}`,
label: `${r.room_name} (${projName})`,
icon: 'Hash',
action: () => navigate(`/project/${projName}/room/${r.id}`),
group: 'Rooms',
keywords: ['room', 'chat', 'channel', r.room_name, projName],
});
}
}
// Per-project "create repo" actions
const createRepoItems: PaletteItem[] = projects.slice(0, 10).map(p => ({
id: `create-repo-${p.name}`,
label: `Create Repo in ${p.display_name || p.name}`,
icon: 'Plus',
action: () => navigate(`/project/${p.name}/repositories`),
group: 'Create',
keywords: ['create', 'new', 'repo', 'repository', p.name, p.display_name],
}));
const staticActions = buildStaticActions(navigate);
const registered = getRegisteredCommands();
const allCommands = [
...staticActions,
...projectItems,
...repoItems,
...roomItems,
...createRepoItems,
...registered,
] as (PaletteItem | RegistryCommandItem)[];
// Group commands by their `group` field
const grouped = useMemo(() => {
const groups = new Map<string, (PaletteItem | RegistryCommandItem)[]>();
for (const cmd of allCommands) {
const g = groups.get(cmd.group) ?? [];
g.push(cmd);
groups.set(cmd.group, g);
}
return groups;
}, [allCommands]);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search projects, repos, rooms, commands…" autoFocus />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{Array.from(grouped.entries()).map(([group, cmds]) => (
<CommandGroup key={group} heading={group}>
{cmds.map((cmd) => {
const Icon = iconMap[(cmd as PaletteItem).icon ?? ''] ?? Search;
const shortcut = cmd.shortcut;
return (
<CommandItem
key={cmd.id}
value={searchValue(cmd)}
onSelect={() => handleSelect(cmd.action)}
>
<Icon className="mr-2 h-4 w-4 shrink-0" />
<span className="flex-1">{cmd.label}</span>
{shortcut && (
<CommandShortcut>{formatShortcut(shortcut)}</CommandShortcut>
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@ -0,0 +1,323 @@
'use client';
/**
* Unified content renderer for markdown + @[type:id:label] mentions.
* Used across room chat, issues, and PR comments.
*
* Features (enabled via props):
* - enableLinkPreviews: inline rich link previews for issues, PRs, commits, repos
* - enableCodeRefs: inline code reference blocks (file.rs:42 style)
*
* Mentions are protected from markdown parsing by replacing them with
* zero-width-space-delimited placeholder tokens before rendering,
* then restored as styled interactive elements after.
*/
import { memo, useMemo, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import {
extractMentions,
type MentionType,
type MentionSpan,
} from '@/lib/mention';
import { MentionBadge } from './MentionBadge';
import { detectLanguage, getLanguageFromTag } from '@/lib/code-lang-detect';
import { detectLinkType, extractUrls, type UnfurlResult } from '@/lib/link-unfurl';
import { parseCodeRef, type CodeRef } from '@/lib/code-ref-parser';
interface ContentRendererProps {
content: string;
/** Called when a mention is clicked. Provides (type, id, label). */
onMentionClick?: (type: MentionType, id: string, label: string) => void;
/** Show rich link preview cards for detected URLs */
enableLinkPreviews?: boolean;
/** Show inline code reference blocks for "file.rs:42" style references */
enableCodeRefs?: boolean;
/** Additional CSS classes for the wrapper div. */
className?: string;
/** Base URL for code reference navigation (e.g. /repository/ns/repo) */
repoBaseUrl?: string;
/** Branch for code reference navigation */
branch?: string;
}
export const ContentRenderer = memo(function ContentRenderer({
content,
onMentionClick,
enableLinkPreviews = false,
enableCodeRefs = false,
className,
repoBaseUrl,
branch = 'main',
}: ContentRendererProps) {
const { safeContent, mentions } = useMemo(() => extractMentions(content), [content]);
// Pre-detect standalone URLs and code refs for inline rendering
const { urls, codeRefs } = useMemo(() => {
if (!enableLinkPreviews && !enableCodeRefs) return { urls: new Map<string, UnfurlResult>(), codeRefs: [] };
const urlMap = new Map<string, UnfurlResult>();
const refs: CodeRef[] = [];
// Extract standalone URLs (not inside markdown links)
const urlMatches = extractUrls(content);
for (const { url } of urlMatches) {
const result = detectLinkType(url);
if (result && result.type !== 'external') {
urlMap.set(url, result);
}
}
// Extract code refs
if (enableCodeRefs) {
const refMatches = content.match(/[^\s:]+:\d+(?:-\d+)?/g) ?? [];
for (const match of refMatches) {
const parsed = parseCodeRef(match);
if (parsed) refs.push(parsed);
}
}
return { urls: urlMap, codeRefs: refs };
}, [content, enableLinkPreviews, enableCodeRefs]);
return (
<div
className={cn(
'text-[15px] text-foreground',
'max-w-full min-w-0 break-words',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs',
'[&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:overflow-x-auto',
'[&_p]:whitespace-pre-wrap [&_p]:leading-[1.4] [&_p]:my-1',
'[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-1',
'[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-1',
'[&_li]:my-0.5',
'[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-4 [&_blockquote]:my-1',
'[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:my-2',
'[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:my-2',
'[&_h3]:text-base [&_h3]:font-semibold [&_h3]:my-1.5',
'[&_strong]:font-semibold',
'[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2',
'[&_hr]:border-foreground/20 [&_hr]:my-2',
'[&_table]:w-full [&_table]:border-collapse [&_table]:rounded-md [&_table]:border [&_table]:border-foreground/20 [&_table]:my-2',
'[&_th]:border [&_th]:border-foreground/20 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-bold',
'[&_td]:border [&_td]:border-foreground/20 [&_td]:px-2 [&_td]:py-1 [&_td]:text-left',
'[&_tr]:border-t [&_tr]:even:bg-muted',
className,
)}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={buildComponents(mentions, onMentionClick, {
enableLinkPreviews,
enableCodeRefs,
urls,
codeRefs,
repoBaseUrl,
branch,
}) as Record<string, unknown>}
>
{safeContent}
</Markdown>
</div>
);
});
interface RendererOptions {
enableLinkPreviews: boolean;
enableCodeRefs: boolean;
urls: Map<string, UnfurlResult>;
codeRefs: CodeRef[];
repoBaseUrl?: string;
branch?: string;
}
function buildComponents(
mentions: MentionSpan[],
onMentionClick: ContentRendererProps['onMentionClick'],
opts: RendererOptions,
) {
const { repoBaseUrl, branch } = opts;
return {
p: ({ children }: { children: React.ReactNode }) => (
<p>{restoreInNode(children, mentions, onMentionClick)}</p>
),
li: ({ children }: { children: React.ReactNode }) => (
<li>{restoreInNode(children, mentions, onMentionClick)}</li>
),
strong: ({ children }: { children: React.ReactNode }) => (
<strong>{restoreInNode(children, mentions, onMentionClick)}</strong>
),
em: ({ children }: { children: React.ReactNode }) => (
<em>{restoreInNode(children, mentions, onMentionClick)}</em>
),
code: ({ className, children, ...props }: React.ComponentProps<'code'>) => {
const isBlock = typeof className === 'string' && className.includes('language-');
if (isBlock) {
// Enhanced fenced code block — try to detect language
const langTag = typeof className === 'string'
? className.replace('language-', '')
: undefined;
const langInfo = langTag
? getLanguageFromTag(langTag)
: detectLanguage(String(children));
const langLabel = langInfo?.label ?? langTag;
return (
<EnhancedCodeBlock
code={String(children)}
language={langLabel}
repoBaseUrl={repoBaseUrl}
branch={branch}
/>
);
}
return (
<code className="font-mono rounded bg-muted px-1 py-0.5 text-xs" {...props}>
{children}
</code>
);
},
pre: ({ children }: React.ComponentProps<'pre'>) => {
// Extract language from nested code element
const codeEl = children as React.ReactElement<{ className?: string; children?: string }>;
const langTag = codeEl?.props?.className?.replace('language-', '');
return (
<EnhancedCodeBlock
code={codeEl?.props?.children ?? ''}
language={langTag}
repoBaseUrl={repoBaseUrl}
branch={branch}
/>
);
},
};
}
/** Code block with language badge and optional line numbers */
function EnhancedCodeBlock({
code,
language,
repoBaseUrl: _repoBaseUrl,
branch: _branch,
}: {
code: string;
language?: string;
repoBaseUrl?: string;
branch?: string;
}) {
const [copied, setCopied] = useState(false);
const langInfo = language ? getLanguageFromTag(language) : detectLanguage(code);
const displayLang = langInfo?.label ?? language ?? null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const ta = document.createElement('textarea');
ta.value = code;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="rounded-lg border bg-muted overflow-hidden my-2">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-muted/70">
<div className="flex gap-1.5 -ml-1">
<div className="h-2.5 w-2.5 rounded-full bg-red-500/50" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/50" />
<div className="h-2.5 w-2.5 rounded-full bg-green-500/50" />
</div>
{displayLang && (
<span className="text-xs font-mono bg-muted-foreground/10 text-muted-foreground px-1.5 py-0.5 rounded">
{displayLang}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="ml-auto p-1 rounded hover:bg-muted-foreground/10 transition-colors"
title={copied ? 'Copied!' : 'Copy'}
>
{copied ? (
<span className="text-xs text-green-600 font-medium">Copied!</span>
) : (
<span className="text-xs text-muted-foreground">Copy</span>
)}
</button>
</div>
{/* Code */}
<pre className="p-3 overflow-x-auto font-mono text-sm leading-5 text-foreground/90">
<code>{code}</code>
</pre>
</div>
);
}
// ── Helpers ────────────────────────────────────────────────────────────────
/** Recursively restore mention placeholders inside text nodes. */
function restoreInNode(
children: React.ReactNode,
mentions: MentionSpan[],
onMentionClick?: (type: MentionType, id: string, label: string) => void,
): React.ReactNode {
if (typeof children === 'string') {
return restoreMentionsText(children, mentions, onMentionClick);
}
if (Array.isArray(children)) {
return children.map((child, i) => (
<span key={i}>{restoreInNode(child, mentions, onMentionClick)}</span>
));
}
return children;
}
/** Restore mention placeholders inside a single text string into React elements. */
function restoreMentionsText(
text: string,
mentions: MentionSpan[],
onMentionClick?: (type: MentionType, id: string, label: string) => void,
): React.ReactNode {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
const PLACEHOLDER_RE = /MENTION_(\d+)/g;
let match: RegExpExecArray | null;
while ((match = PLACEHOLDER_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const idx = parseInt(match[1], 10);
const m = mentions[idx];
if (m) {
parts.push(
<MentionBadge
key={`mention-${idx}`}
type={m.type}
id={m.id}
label={m.label}
onClick={onMentionClick}
/>,
);
}
lastIndex = PLACEHOLDER_RE.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
}

View File

@ -0,0 +1,98 @@
'use client';
/**
* Registers global navigation shortcuts at app startup.
* Shortcuts: g n /notify, g i /issues, g r /repos, g m /rooms
* Uses the two-key chord pattern (press g, then the key).
*/
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface ChordTarget {
key: string;
path: string;
description: string;
}
const CHORD_KEY = 'g';
const TARGETS: ChordTarget[] = [
{ key: 'n', path: '/notify', description: 'Go to Notifications' },
{ key: 'i', path: '/issues', description: 'Go to Issues' },
{ key: 'r', path: '/repos', description: 'Go to Repositories' },
{ key: 'm', path: '/rooms', description: 'Go to Messages' },
];
/** Show a brief indicator when a chord is active */
function showChordHint() {
const existing = document.getElementById('nav-chord-hint');
if (existing) existing.remove();
const el = document.createElement('div');
el.id = 'nav-chord-hint';
el.textContent = 'g _';
el.style.cssText = `
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--foreground);
color: var(--background);
padding: 4px 12px;
border-radius: 6px;
font-size: 13px;
font-family: monospace;
font-weight: 600;
z-index: 9999;
pointer-events: none;
opacity: 1;
transition: opacity 0.3s ease;
`;
document.body.appendChild(el);
setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => el.remove(), 300);
}, 600);
}
export function GlobalNavigationShortcuts() {
const navigate = useNavigate();
const pendingKeyRef = useRef<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isEditing =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
if (isEditing) return;
if (pendingKeyRef.current) {
const next = e.key.toLowerCase();
const match = TARGETS.find((t) => t.key === next);
if (match) {
e.preventDefault();
navigate(match.path);
}
pendingKeyRef.current = null;
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
return;
}
if (e.key.toLowerCase() === CHORD_KEY) {
pendingKeyRef.current = CHORD_KEY;
showChordHint();
timerRef.current = setTimeout(() => { pendingKeyRef.current = null; }, 600);
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [navigate]);
return null;
}

View File

@ -0,0 +1,146 @@
'use client';
/**
* Keyboard shortcuts help sheet triggered by pressing `?`.
* Shows all registered shortcuts from useKeyboardShortcut.
*/
import { useState, useEffect } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import { getRegisteredShortcuts, type ShortcutScope } from '@/hooks/useKeyboardShortcut';
import { registerGlobalKeyboardListener, unregisterGlobalKeyboardListener } from '@/hooks/useKeyboardShortcut';
import {
Keyboard,
Globe,
FileText,
Puzzle,
} from 'lucide-react';
const SCOPE_LABELS: Record<ShortcutScope, { label: string; Icon: React.ComponentType<{ className?: string }> }> = {
global: { label: 'Global', Icon: Globe },
page: { label: 'Page', Icon: FileText },
component: { label: 'Component', Icon: Puzzle },
};
export function KeyboardShortcutsSheet() {
const [open, setOpen] = useState(false);
useEffect(() => {
registerGlobalKeyboardListener();
const handleKey = (e: KeyboardEvent) => {
// Open on `?` when not in an input
const target = e.target as HTMLElement;
const isEditing =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
if (e.key === '?' && !isEditing) {
setOpen((v) => !v);
}
};
window.addEventListener('keydown', handleKey);
return () => {
window.removeEventListener('keydown', handleKey);
unregisterGlobalKeyboardListener();
};
}, []);
const shortcuts = getRegisteredShortcuts();
const byScope = shortcuts.reduce<Record<ShortcutScope, typeof shortcuts>>((acc, s) => {
(acc[s.scope] ??= []).push(s);
return acc;
}, {} as Record<ShortcutScope, typeof shortcuts>);
const scopes: ShortcutScope[] = ['global', 'page', 'component'];
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-80 overflow-y-auto">
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-2">
<Keyboard className="h-5 w-5" />
Keyboard Shortcuts
</SheetTitle>
<SheetDescription>
Press{' '}
<kbd className="inline-flex items-center rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
?
</kbd>{' '}
to toggle this panel.
</SheetDescription>
</SheetHeader>
<div className="space-y-6">
{scopes.map((scope) => {
const items = byScope[scope];
if (!items?.length) return null;
const { label, Icon } = SCOPE_LABELS[scope];
return (
<section key={scope}>
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{label}
</h3>
<div className="space-y-0.5">
{items.map((s, i) => (
<div
key={`${s.key}-${s.ctrl ?? ''}-${s.meta ?? ''}-${s.shift ?? ''}-${i}`}
className="flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/60"
>
<span className="text-sm text-foreground">
{s.description ?? s.key}
</span>
<kbd className="flex items-center gap-0.5 font-mono text-xs">
{s.meta && (
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
</span>
)}
{s.ctrl && (
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
Ctrl
</span>
)}
{s.alt && (
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
</span>
)}
{s.shift && (
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
</span>
)}
<span className="rounded border bg-muted px-1 py-0.5 text-[10px]">
{s.key.toUpperCase()}
</span>
</kbd>
</div>
))}
</div>
</section>
);
})}
{shortcuts.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No shortcuts registered yet.
</p>
)}
</div>
<div className="mt-6 rounded border border-dashed p-3">
<p className="text-xs text-muted-foreground text-center">
Cmd+K opens the command palette
</p>
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,303 @@
'use client';
/**
* Renders a rich preview card for detected URLs.
* Supports: Issue, PR, Commit, Repository, External.
*/
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
ExternalLink,
GitPullRequest,
GitCommit,
BookOpen,
AlertCircle,
CheckCircle2,
XCircle,
GitMerge,
ArrowUpRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { UnfurlResult } from '@/lib/link-unfurl';
interface LinkPreviewProps {
result: UnfurlResult;
/** Called when the preview is clicked */
onClick?: (result: UnfurlResult) => void;
className?: string;
}
// ── Issue Preview ─────────────────────────────────────────────────────────────
function IssuePreview({ result }: { result: UnfurlResult }) {
const navigate = useNavigate();
const { issueNumber, project } = result.ids;
const { data: issue, isLoading } = useQuery({
queryKey: ['issue-preview', project, issueNumber],
queryFn: async () => {
const { issueGet } = await import('@/client');
const resp = await issueGet({ path: { project: project!, number: issueNumber! } });
return resp.data?.data ?? null;
},
enabled: !!project && !!issueNumber,
staleTime: 5 * 60 * 1000,
});
const isOpen = issue?.state === 'open';
const Icon = isOpen ? AlertCircle : CheckCircle2;
return (
<button
type="button"
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
onClick={() => navigate(`/project/${project}/issues/${issueNumber}`)}
>
<div className="flex items-start gap-3 p-3">
<div
className={cn(
'mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border flex items-center justify-center',
isOpen ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600' : 'bg-violet-500/10 border-violet-500/20 text-violet-600',
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-0.5">
<span className="font-mono text-xs">#{issueNumber}</span>
{project && <span>in {project}</span>}
</div>
{isLoading ? (
<div className="space-y-1.5">
<div className="h-4 w-3/4 bg-muted rounded animate-pulse" />
<div className="h-3 w-1/4 bg-muted rounded animate-pulse" />
</div>
) : issue ? (
<>
<p className="text-sm font-medium text-foreground leading-tight truncate">
{issue.title}
</p>
<div className="flex items-center gap-2 mt-1">
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded font-medium border',
isOpen
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800'
: 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-950/40 dark:text-violet-400 dark:border-violet-800',
)}
>
{isOpen ? 'Open' : 'Closed'}
</span>
<span className="text-xs text-muted-foreground">
by {issue.author_username ?? issue.author}
</span>
</div>
</>
) : (
<p className="text-xs text-muted-foreground italic">Issue not found</p>
)}
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
</div>
</button>
);
}
// ── PR Preview ─────────────────────────────────────────────────────────────
function PRPreview({ result }: { result: UnfurlResult }) {
const navigate = useNavigate();
const { prNumber, namespace, repo } = result.ids;
const { data: pr, isLoading } = useQuery({
queryKey: ['pr-preview', namespace, repo, prNumber],
queryFn: async () => {
const { pullRequestGet } = await import('@/client');
const resp = await pullRequestGet({ path: { namespace: namespace!, repo: repo!, number: prNumber! } });
return resp.data?.data ?? null;
},
enabled: !!namespace && !!repo && !!prNumber,
staleTime: 5 * 60 * 1000,
});
const status = pr?.status ?? 'open';
const StatusIcon =
status === 'merged' ? GitMerge : status === 'closed' ? XCircle : GitPullRequest;
return (
<button
type="button"
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
onClick={() => navigate(`/repository/${namespace}/${repo}/pulls/${prNumber}`)}
>
<div className="flex items-start gap-3 p-3">
<div
className={cn(
'mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border flex items-center justify-center',
status === 'open'
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600'
: status === 'merged'
? 'bg-purple-500/10 border-purple-500/20 text-purple-600'
: 'bg-red-500/10 border-red-500/20 text-red-600',
)}
>
<StatusIcon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-0.5">
<span className="font-mono text-xs">#{prNumber}</span>
{namespace && repo && <span>in {namespace}/{repo}</span>}
</div>
{isLoading ? (
<div className="space-y-1.5">
<div className="h-4 w-3/4 bg-muted rounded animate-pulse" />
<div className="h-3 w-1/3 bg-muted rounded animate-pulse" />
</div>
) : pr ? (
<>
<p className="text-sm font-medium text-foreground leading-tight truncate">
{pr.title}
</p>
<div className="flex items-center gap-2 mt-1">
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded font-medium border capitalize',
status === 'open'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: status === 'merged'
? 'bg-purple-50 text-purple-700 border-purple-200'
: 'bg-red-50 text-red-700 border-red-200',
)}
>
{status}
</span>
<span className="text-xs text-muted-foreground">
{pr.author_username ?? pr.author}
</span>
</div>
</>
) : (
<p className="text-xs text-muted-foreground italic">PR not found</p>
)}
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
</div>
</button>
);
}
// ── Commit Preview ─────────────────────────────────────────────────────────
function CommitPreview({ result }: { result: UnfurlResult }) {
const navigate = useNavigate();
const { commitOid, namespace, repo } = result.ids;
const shortOid = commitOid?.slice(0, 7) ?? 'unknown';
return (
<button
type="button"
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
onClick={() => navigate(`/repository/${namespace}/${repo}/commit/${commitOid}`)}
>
<div className="flex items-start gap-3 p-3">
<div className="mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border bg-muted flex items-center justify-center text-muted-foreground">
<GitCommit className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-muted-foreground mb-0.5">
{namespace}/{repo}
</div>
<p className="text-sm font-mono text-foreground">
{shortOid}
</p>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
</div>
</button>
);
}
// ── Repository Preview ─────────────────────────────────────────────────────
function RepoPreview({ result }: { result: UnfurlResult }) {
const navigate = useNavigate();
const { namespace, repo } = result.ids;
return (
<button
type="button"
className="w-full text-left rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
onClick={() => navigate(`/repository/${namespace}/${repo}`)}
>
<div className="flex items-start gap-3 p-3">
<div className="mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border bg-muted flex items-center justify-center text-muted-foreground">
<BookOpen className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-0.5">
<span className="font-medium text-foreground">
{namespace}/{repo}
</span>
</div>
<p className="text-xs text-muted-foreground">Repository</p>
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5" />
</div>
</button>
);
}
// ── External URL Preview ────────────────────────────────────────────────────
function ExternalPreview({ result }: { result: UnfurlResult }) {
return (
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-start gap-3 p-3 rounded-lg border bg-card overflow-hidden hover:border-primary/40 transition-colors"
>
<div className="mt-0.5 flex-shrink-0 h-8 w-8 rounded-full border bg-muted flex items-center justify-center text-muted-foreground">
<ExternalLink className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-foreground truncate">{result.domain}</p>
<p className="text-xs text-muted-foreground truncate">{result.url}</p>
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
</a>
);
}
// ── Main LinkPreview component ───────────────────────────────────────────────
export function LinkPreview({ result, className }: LinkPreviewProps) {
switch (result.type) {
case 'issue':
return <IssuePreview result={result} />;
case 'pull_request':
return <PRPreview result={result} />;
case 'commit':
return <CommitPreview result={result} />;
case 'repository':
return <RepoPreview result={result} />;
case 'external':
return <ExternalPreview result={result} />;
default:
// Fallback: render as a simple link
return (
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'text-primary underline underline-offset-2 text-sm',
className,
)}
>
{result.url}
</a>
);
}
}

View File

@ -0,0 +1,84 @@
'use client';
import { cn } from '@/lib/utils';
import type { MentionType } from '@/lib/mention';
interface MentionBadgeProps {
type: MentionType;
label: string;
onClick?: (type: MentionType, id: string, label: string) => void;
id: string;
className?: string;
}
const TYPE_STYLE: Record<MentionType, { light: string; dark?: string; prefix: string }> = {
user: {
light: 'bg-blue-50 text-blue-600',
dark: 'dark:bg-blue-900/30 dark:text-blue-300',
prefix: '@',
},
channel: {
light: 'bg-gray-50 text-gray-600',
dark: 'dark:bg-gray-800 dark:text-gray-300',
prefix: '#',
},
ai: {
light: 'bg-green-50 text-green-600',
dark: 'dark:bg-green-900/30 dark:text-green-300',
prefix: '@',
},
command: {
light: 'bg-amber-50 text-amber-600',
dark: 'dark:bg-amber-900/30 dark:text-amber-300',
prefix: '/',
},
special_here: {
light: 'bg-orange-50 text-orange-600',
dark: 'dark:bg-orange-900/30 dark:text-orange-300',
prefix: '@',
},
special_channel: {
light: 'bg-orange-50 text-orange-600',
dark: 'dark:bg-orange-900/30 dark:text-orange-300',
prefix: '@',
},
};
export function MentionBadge({ type, label, onClick, id, className }: MentionBadgeProps) {
const style = TYPE_STYLE[type] ?? TYPE_STYLE['user'];
const isInteractive = !!onClick;
return (
<span
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
className={cn(
'inline-flex items-center gap-0.5 rounded px-1 py-0.5 font-medium text-xs mx-0.5',
style.light,
style.dark,
isInteractive && 'cursor-pointer hover:opacity-80 transition-opacity',
className,
)}
onClick={isInteractive ? () => onClick(type, id, label) : undefined}
onKeyDown={
isInteractive
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(type, id, label);
}
}
: undefined
}
>
{style.prefix}
{label}
</span>
);
}
/** Get mention style class names for direct use (without the component wrapper). */
export function getMentionStyleClasses(type: MentionType): string {
const style = TYPE_STYLE[type] ?? TYPE_STYLE['user'];
return cn(style.light, style.dark);
}

View File

@ -0,0 +1,218 @@
'use client';
/**
* Compact inline chat component for embedding in file browsers and PR diffs.
* Shows recent messages from a room + a text input for quick replies.
*
* Usage:
* <MiniChat roomId="room-123" repoBaseUrl="/repository/ns/repo" branch="main" />
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RoomWsClient, type RoomWsStatus } from '@/lib/room-ws-client';
import { ContentRenderer } from '@/components/shared/ContentRenderer';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Send } from 'lucide-react';
import { cn } from '@/lib/utils';
interface MiniChatProps {
/** Room ID to load messages from */
roomId: string;
/** WebSocket client for this room (optional — falls back to REST) */
wsClient?: RoomWsClient | null;
/** Base URL for code reference navigation */
repoBaseUrl?: string;
/** Branch for code references */
branch?: string;
/** Height of the message list */
maxHeight?: number;
/** Called when a message mention is clicked */
onMentionClick?: (type: string, id: string, label: string) => void;
className?: string;
}
interface ChatMessage {
id: string;
sender_type: string;
sender_id?: string | null;
sender_name?: string | null;
content: string;
content_type: string;
send_at: string;
display_name?: string | null;
}
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const diff = Math.floor((now.getTime() - d.getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return d.toLocaleDateString();
}
export function MiniChat({
roomId,
wsClient,
repoBaseUrl,
branch = 'main',
maxHeight = 320,
onMentionClick,
className,
}: MiniChatProps) {
const queryClient = useQueryClient();
const [input, setInput] = useState('');
const [wsReady, setWsReady] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Ensure WS is connected — use public getStatus() and onStatusChange callback
useEffect(() => {
if (!wsClient) return;
if (wsClient.getStatus() === 'open') { setWsReady(true); return; }
const onStatusChange = (status: RoomWsStatus) => setWsReady(status === 'open');
wsClient.updateCallbacks({ onStatusChange });
return () => {
wsClient.updateCallbacks({ onStatusChange: undefined });
};
}, [wsClient]);
const { data: messagesData, isLoading } = useQuery({
queryKey: ['mini-chat-messages', roomId],
queryFn: async () => {
if (wsClient && wsReady) {
try {
return await wsClient.messageListWs(roomId, { limit: 20 });
} catch {
// fall through to REST
}
}
// REST fallback
return wsClient?.messageList(roomId, { limit: 20 }) ?? null;
},
enabled: true,
staleTime: 10_000,
});
const messages: ChatMessage[] = messagesData?.messages ?? [];
// Auto-scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
const sendMutation = useMutation({
mutationFn: async (body: string) => {
if (wsClient) {
await wsClient.messageCreate(roomId, body, { contentType: 'text' });
} else {
const { messageCreate } = await import('@/client');
await messageCreate({ path: { room_id: roomId }, body: { content: body, content_type: 'text' } });
}
},
onSuccess: () => {
setInput('');
queryClient.invalidateQueries({ queryKey: ['mini-chat-messages', roomId] });
inputRef.current?.focus();
},
});
const handleSend = useCallback(() => {
const body = input.trim();
if (!body) return;
sendMutation.mutate(body);
}, [input, sendMutation]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className={cn('flex flex-col rounded-lg border bg-card overflow-hidden', className)}>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/40">
<span className="text-xs font-semibold text-foreground">Chat</span>
<span className="text-[10px] text-muted-foreground truncate">{roomId}</span>
{!wsReady && (
<span className="ml-auto text-[10px] text-muted-foreground italic">REST mode</span>
)}
</div>
{/* Messages */}
<div
className="flex-1 overflow-y-auto"
style={{ maxHeight }}
>
{isLoading ? (
<div className="flex items-center justify-center h-16">
<div className="h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-20 gap-1">
<p className="text-xs text-muted-foreground italic">No messages yet</p>
</div>
) : (
<div className="divide-y divide-border/50">
{messages.map((msg) => (
<div key={msg.id} className="px-3 py-2 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-1.5 mb-0.5">
<Avatar className="h-4 w-4">
<AvatarFallback className="text-[9px]">
{(msg.display_name ?? msg.sender_id ?? '?')[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-[11px] font-medium text-foreground">
{msg.display_name ?? msg.sender_id ?? 'Unknown'}
</span>
<span className="text-[10px] text-muted-foreground ml-auto">
{formatTime(msg.send_at)}
</span>
</div>
<div className="text-[12px] text-foreground/80 leading-snug ml-6">
<ContentRenderer
content={msg.content}
onMentionClick={onMentionClick}
repoBaseUrl={repoBaseUrl}
branch={branch}
className="text-[12px]"
/>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
)}
</div>
{/* Input */}
<div className="flex items-end gap-1.5 px-3 py-2 border-t bg-muted/20">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Reply in chat…"
rows={1}
className="flex-1 min-h-[28px] max-h-[80px] resize-none rounded border border-input bg-background px-2 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
style={{ scrollbarWidth: 'thin' }}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={handleSend}
disabled={!input.trim() || sendMutation.isPending}
>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}

View File

@ -58,7 +58,9 @@ function CommandDialog({
)}
showCloseButton={showCloseButton}
>
{children}
<Command className="flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground">
{children}
</Command>
</DialogContent>
</Dialog>
)

View File

@ -29,6 +29,8 @@ export interface RepoInfo {
ssh_clone_url: string;
/** HTTPS clone URL */
https_clone_url: string;
/** Whether AI auto-review is enabled for this repo */
ai_code_review_enabled: boolean;
}
export const RepositoryContext = React.createContext<RepoInfo | null>(null);
@ -101,6 +103,7 @@ export const RepositoryContextProvider = ({
is_watch: Boolean(watchCountResp),
ssh_clone_url: repoItem.ssh_clone_url,
https_clone_url: repoItem.https_clone_url,
ai_code_review_enabled: repoItem.ai_code_review_enabled,
};
}, [repoItem, namespace, starCountResp, watchCountResp]);

View File

@ -70,6 +70,8 @@ export type MessageWithMeta = RoomMessageResponse & {
reactions?: ReactionGroup[];
/** Attachment IDs for files uploaded with this message */
attachment_ids?: string[];
/** AI stream chunk type: "thinking", "tool_call", "tool_result", or undefined for normal text */
chunk_type?: string;
};
export type RoomWithCategory = RoomResponse & {
@ -432,6 +434,38 @@ export function RoomProvider({
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
// Streaming timeout: if no chunk received for 60s, force-end the stream
// to prevent UI hanging forever when done=true is never delivered.
const streamingTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearStreamingTimer = useCallback((msgId: string) => {
const timer = streamingTimersRef.current.get(msgId);
if (timer) {
clearTimeout(timer);
streamingTimersRef.current.delete(msgId);
}
}, []);
const startStreamingTimer = useCallback((msgId: string) => {
clearStreamingTimer(msgId);
const timer = setTimeout(() => {
// Force-end: mark message as not-streaming and keep whatever content we have
setStreamingContent((prev) => {
prev.delete(msgId);
return new Map(prev);
});
setMessages((prev) =>
prev.map((m) =>
m.id === msgId && m.is_streaming
? { ...m, is_streaming: false, content: m.content || '[Stream timed out — no completion signal received]' }
: m,
),
);
streamingTimersRef.current.delete(msgId);
}, 60000);
streamingTimersRef.current.set(msgId, timer);
}, []);
// Project repos for @repository: mention suggestions
const [projectRepos, setProjectRepos] = useState<ProjectRepositoryItem[]>([]);
const [reposLoading, setReposLoading] = useState(false);
@ -509,8 +543,10 @@ export function RoomProvider({
]);
}
},
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string }) => {
onAiStreamChunk: (chunk: { done: boolean; message_id: string; room_id: string; content: string; display_name?: string; chunk_type?: string }) => {
if (chunk.done) {
// Clear the timeout timer since stream completed normally
clearStreamingTimer(chunk.message_id);
// When done: clear streaming content, set is_streaming=false, and
// update seq so the subsequent RoomMessage event deduplicates correctly.
setStreamingContent((prev) => {
@ -520,11 +556,13 @@ export function RoomProvider({
setMessages((prev) =>
prev.map((m) =>
m.id === chunk.message_id
? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false }
? { ...m, content: chunk.content, display_content: chunk.content, is_streaming: false, chunk_type: chunk.chunk_type }
: m,
),
);
} else {
// Reset the timeout timer on each chunk — stream is still alive
startStreamingTimer(chunk.message_id);
// Single atomic update: accumulate in streamingContent AND update message.
// Backend sends CUMULATIVE content (text_accumulated.clone()), not delta.
// Use deduplication to only add the new delta portion.
@ -559,6 +597,7 @@ export function RoomProvider({
content_type: 'text',
send_at: new Date().toISOString(),
is_streaming: true,
chunk_type: chunk.chunk_type,
};
return [...msgs, newMsg];
});
@ -715,6 +754,11 @@ export function RoomProvider({
return () => {
client.disconnect(); // Intentional disconnect on unmount — no reconnect
wsClientRef.current = null;
// Clear all streaming timeout timers
for (const timer of streamingTimersRef.current.values()) {
clearTimeout(timer);
}
streamingTimersRef.current.clear();
};
}, []); // ← empty deps: create once on mount

View File

@ -0,0 +1,86 @@
'use client';
/**
* Global command registry for the command palette (Cmd+K).
* Pages register their commands on mount, unregister on unmount.
*
* Usage:
* useCommands([
* {
* id: 'nav-issues',
* label: 'Go to Issues',
* group: 'Navigation',
* action: () => navigate('/project/x/issues'),
* shortcut: { key: 'i', meta: true },
* keywords: ['goto', 'navigate', 'issues'],
* },
* ]);
*/
import { useEffect, useCallback, useRef } from 'react';
import type { ReactNode } from 'react';
export interface ShortcutDisplay {
key: string;
meta?: boolean;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
}
export interface CommandItem {
id: string;
/** Display label */
label: string;
/** Optional icon shown before the label */
icon?: ReactNode;
/** Optional keyboard shortcut hint */
shortcut?: ShortcutDisplay;
/** Group this command belongs to (used for section headers) */
group: string;
/** Called when the command is selected */
action: () => void;
/** Extra search keywords */
keywords?: string[];
}
const registeredCommands: CommandItem[] = [];
/** Get all registered commands */
export function getRegisteredCommands(): CommandItem[] {
return [...registeredCommands];
}
/**
* Register a batch of commands. Returns a cleanup function.
* Call inside useEffect (or component body since it auto-cleans on unmount).
*/
export function useCommands(commands: CommandItem[]): () => void {
const commandsRef = useRef(commands);
useEffect(() => {
// Remove any existing commands with the same ids (handles StrictMode double-mount)
const ids = new Set(commandsRef.current.map((c) => c.id));
for (let i = registeredCommands.length - 1; i >= 0; i--) {
if (ids.has(registeredCommands[i].id)) {
registeredCommands.splice(i, 1);
}
}
// Add new commands
registeredCommands.push(...commandsRef.current);
return () => {
for (const c of commandsRef.current) {
const idx = registeredCommands.findIndex((r) => r.id === c.id);
if (idx !== -1) registeredCommands.splice(idx, 1);
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return useCallback(() => {
for (const c of commandsRef.current) {
const idx = registeredCommands.findIndex((r) => r.id === c.id);
if (idx !== -1) registeredCommands.splice(idx, 1);
}
}, []);
}

View File

@ -0,0 +1,174 @@
'use client';
/**
* Global keyboard shortcut registration system.
* Registers keyboard shortcuts with scope awareness.
*
* Usage:
* useKeyboardShortcut({
* key: 'k',
* meta: true,
* scope: 'global',
* action: () => openCommandPalette(),
* });
*
* Scopes: 'global' > 'page' > 'component'
* Multiple shortcuts can be registered; global shortcuts work everywhere.
*/
import { useEffect, useRef } from 'react';
export type ShortcutScope = 'global' | 'page' | 'component';
interface Shortcut {
key: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
scope: ShortcutScope;
action: () => void;
description?: string;
}
const registeredShortcuts: Shortcut[] = [];
/** Normalize a key string to lowercase */
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/\s+/g, '');
}
/** Check if an event matches a shortcut */
function matchesShortcut(event: KeyboardEvent, shortcut: Shortcut): boolean {
const keyMatch = normalizeKey(event.key) === normalizeKey(shortcut.key);
const ctrlMatch = !!shortcut.ctrl === (event.ctrlKey || event.metaKey); // treat ctrl/meta as equivalent
const shiftMatch = !!shortcut.shift === event.shiftKey;
const altMatch = !!shortcut.alt === event.altKey;
return keyMatch && ctrlMatch && shiftMatch && altMatch;
}
/** Sort shortcuts by priority: global first, then page, then component */
function sortShortcuts(shortcuts: Shortcut[]): Shortcut[] {
const order = { global: 0, page: 1, component: 2 } as const;
return [...shortcuts].sort((a, b) => order[a.scope] - order[b.scope]);
}
function handleKeyDown(event: KeyboardEvent) {
// Don't fire shortcuts when typing in inputs/textareas (unless explicitly allowed)
const target = event.target as HTMLElement;
const isEditing = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
for (const shortcut of sortShortcuts(registeredShortcuts)) {
if (isEditing && shortcut.scope === 'global') {
// Global shortcuts still fire in inputs for accessibility (e.g. Cmd+K)
// but let components decide by checking the shortcut
}
if (matchesShortcut(event, shortcut)) {
event.preventDefault();
shortcut.action();
return;
}
}
}
export interface UseKeyboardShortcutOptions {
key: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
scope?: ShortcutScope;
action: () => void;
description?: string;
/** If true, fires even when typing in inputs. Default: false for 'component', true for 'global' */
forceInInput?: boolean;
}
/**
* Register a keyboard shortcut. Returns a cleanup function.
* Call this inside a useEffect (or component body, since it auto-cleans on unmount).
*/
export function useKeyboardShortcut({
key,
ctrl,
meta,
shift,
alt,
scope = 'component',
action,
description,
}: UseKeyboardShortcutOptions): () => void {
const shortcutRef = useRef<Shortcut>({
key,
ctrl,
meta,
shift,
alt,
scope,
action,
description,
});
useEffect(() => {
// Deduplicate: remove any existing shortcut with the same key+scope
const idx = registeredShortcuts.findIndex(
(s) =>
normalizeKey(s.key) === normalizeKey(key) &&
s.scope === scope,
);
if (idx !== -1) {
registeredShortcuts.splice(idx, 1);
}
registeredShortcuts.push(shortcutRef.current);
return () => {
const i = registeredShortcuts.indexOf(shortcutRef.current);
if (i !== -1) registeredShortcuts.splice(i, 1);
};
}, [key, scope]); // eslint-disable-line react-hooks/exhaustive-deps
return () => {
const i = registeredShortcuts.indexOf(shortcutRef.current);
if (i !== -1) registeredShortcuts.splice(i, 1);
};
}
/** Get all registered shortcuts (for help sheet display) */
export function getRegisteredShortcuts(scope?: ShortcutScope): Shortcut[] {
if (scope) return registeredShortcuts.filter((s) => s.scope === scope);
return [...registeredShortcuts];
}
// Initialize global listener once
let globalListenerRegistered = false;
export function registerGlobalKeyboardListener(): void {
if (globalListenerRegistered) return;
window.addEventListener('keydown', handleKeyDown);
globalListenerRegistered = true;
}
export function unregisterGlobalKeyboardListener(): void {
if (!globalListenerRegistered) return;
window.removeEventListener('keydown', handleKeyDown);
globalListenerRegistered = false;
}
/** Format a shortcut for display (e.g. "Cmd+K", "Ctrl+Shift+S") */
export function formatShortcut(options: {
key: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
}): string {
const parts: string[] = [];
if (options.meta) parts.push(navigator.platform.includes('Mac') ? '⌘' : 'Cmd');
if (options.ctrl) parts.push('Ctrl');
if (options.alt) parts.push(navigator.platform.includes('Mac') ? '⌥' : 'Alt');
if (options.shift) parts.push('⇧');
parts.push(options.key.toUpperCase());
return parts.join('+');
}

View File

@ -0,0 +1,229 @@
'use client';
/**
* React hook for real-time notifications via WebSocket.
*
* Provides:
* - In-memory notification list updated in real-time from WS events
* - Unread count (maintained as a ref to avoid stale closures)
* - TanStack Query integration (notificationList cache updated on WS push)
* - Toast notifications for new items (with deduplication)
* - REST fallback: polls on mount to bootstrap the list
*
* Usage:
* const { notifications, unreadCount, markRead, markAllRead, archive } = useNotification();
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { notificationList } from '@/client';
import type { NotificationResponse } from '@/client/types.gen';
import type { RoomWsClient } from '@/lib/room-ws-client';
import type { NotificationCreatedPayload } from '@/lib/ws-protocol';
interface UseNotificationOptions {
/** The WebSocket client to subscribe to notification events. */
wsClient?: RoomWsClient | null;
/** Polling interval in ms for REST fallback (when WS is unavailable). Default: 30s */
pollInterval?: number;
/** Maximum notifications to keep in memory. Default: 100 */
maxNotifications?: number;
/** Whether to show a toast for new notifications. Default: true */
showToast?: boolean;
/** Called when a notification is received. Return false to suppress the toast. */
onNotification?: (n: NotificationResponse, deepLinkUrl?: string) => boolean | void;
}
const NOTIFICATION_QUERY_KEY = ['notifications'] as const;
const SEEN_NOTIFICATION_IDS_KEY = 'seen_notification_ids';
/** Get IDs of notifications we've already shown a toast for (session memory). */
function getSeenIds(): Set<string> {
try {
const raw = sessionStorage.getItem(SEEN_NOTIFICATION_IDS_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
return new Set();
}
}
function saveSeenIds(ids: Set<string>): void {
try {
sessionStorage.setItem(SEEN_NOTIFICATION_IDS_KEY, JSON.stringify([...ids]));
} catch {
// Ignore storage errors
}
}
export interface UseNotificationReturn {
/** Current notification list (merged WS + REST). */
notifications: NotificationResponse[];
/** Unread notification count. */
unreadCount: number;
/** Total notification count. */
totalCount: number;
/** Mark a single notification as read. */
markRead: (id: string) => Promise<void>;
/** Mark all notifications as read. */
markAllRead: () => Promise<void>;
/** Archive a notification. */
archive: (id: string) => Promise<void>;
/** Reload notifications from REST API. */
refetch: () => void;
/** Whether the WS connection is receiving events. */
isLive: boolean;
}
export function useNotification(options: UseNotificationOptions = {}): UseNotificationReturn {
const {
wsClient,
pollInterval = 30_000,
maxNotifications = 100,
showToast = true,
onNotification,
} = options;
const queryClient = useQueryClient();
const [liveNotifications, setLiveNotifications] = useState<NotificationResponse[]>([]);
const [isLive, setIsLive] = useState(false);
const seenIdsRef = useRef<Set<string>>(getSeenIds());
const wsCallbackRef = useRef(onNotification);
wsCallbackRef.current = onNotification;
// Bootstrap from REST API on mount
const { data: restData, refetch } = useQuery({
queryKey: NOTIFICATION_QUERY_KEY,
queryFn: async () => {
const resp = await notificationList({ query: { limit: maxNotifications } });
return resp.data?.data ?? null;
},
staleTime: pollInterval,
refetchInterval: pollInterval,
});
const restNotifications: NotificationResponse[] = restData?.notifications ?? [];
const restUnreadCount: number = restData?.unread_count ?? 0;
// Merge WS notifications with REST data, preferring WS data (more fresh)
const notifications = liveNotifications.length > 0
? liveNotifications
: restNotifications;
const unreadCount = liveNotifications.length > 0
? liveNotifications.filter((n) => !n.is_read).length
: restUnreadCount;
// Register WS callback
useEffect(() => {
if (!wsClient) return;
const handleNotification = (payload: NotificationCreatedPayload) => {
const n = payload.notification as NotificationResponse;
const deepLinkUrl = payload.deep_link_url;
setIsLive(true);
setLiveNotifications((prev) => {
// Avoid duplicates
if (prev.some((existing) => existing.id === n.id)) return prev;
const updated = [n, ...prev].slice(0, maxNotifications);
return updated;
});
// Show toast for unread notifications
if (!n.is_read && showToast) {
const suppressToast = wsCallbackRef.current?.(n, deepLinkUrl) === false;
if (!suppressToast) {
// Deduplicate toasts within the session
const seenIds = seenIdsRef.current;
if (!seenIds.has(n.id)) {
seenIds.add(n.id);
saveSeenIds(seenIds);
toast.info(n.title, {
description: n.content ?? undefined,
action: deepLinkUrl
? {
label: 'View',
onClick: () => {
window.location.href = deepLinkUrl;
},
}
: undefined,
duration: 5000,
});
}
}
}
};
wsClient.updateCallbacks({ onNotification: handleNotification as (payload: NotificationCreatedPayload) => void });
return () => {
wsClient.updateCallbacks({ onNotification: undefined });
setIsLive(false);
};
}, [wsClient, maxNotifications, showToast]);
// Mutation helpers
const markRead = useCallback(
async (id: string) => {
const { notificationMarkRead } = await import('@/client');
await notificationMarkRead({ path: { notification_id: id } });
// Optimistically update both WS list and REST cache
setLiveNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
);
queryClient.setQueryData<typeof restData>(NOTIFICATION_QUERY_KEY, (old) => {
if (!old) return old;
return {
...old,
notifications: old.notifications.map((n) =>
n.id === id ? { ...n, is_read: true } : n,
),
unread_count: Math.max(0, (old.unread_count ?? 1) - 1),
};
});
},
[queryClient],
);
const markAllRead = useCallback(async () => {
const { notificationMarkAllRead } = await import('@/client');
await notificationMarkAllRead();
setLiveNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
queryClient.setQueryData<typeof restData>(NOTIFICATION_QUERY_KEY, (old) => {
if (!old) return old;
return { ...old, unread_count: 0, notifications: old.notifications.map((n) => ({ ...n, is_read: true })) };
});
toast.success('All notifications marked as read');
}, [queryClient]);
const archive = useCallback(
async (id: string) => {
const { notificationArchive } = await import('@/client');
await notificationArchive({ path: { notification_id: id } });
setLiveNotifications((prev) => prev.filter((n) => n.id !== id));
queryClient.setQueryData<typeof restData>(NOTIFICATION_QUERY_KEY, (old) => {
if (!old) return old;
return {
...old,
notifications: old.notifications.filter((n) => n.id !== id),
total: Math.max(0, (old.total ?? 1) - 1),
};
});
},
[queryClient],
);
return {
notifications,
unreadCount,
totalCount: notifications.length,
markRead,
markAllRead,
archive,
refetch,
isLive,
};
}

View File

@ -0,0 +1,136 @@
'use client';
/**
* Shared typing indicator hook for rooms, issues, and PRs.
* Wraps the room-context's typing logic for use in non-room contexts.
*
* Usage:
* const { typingUsers } = useTypingIndicator({ roomId, wsClient });
* // or in issue/PR context:
* const { typingUsers } = useTypingIndicator({ projectName, issueNumber });
*/
import { useState, useEffect, useRef } from 'react';
import type { RoomWsClient } from '@/lib/room-ws-client';
import type { TypingStartPayload, TypingStopPayload } from '@/lib/ws-protocol';
export interface TypingUser {
userId: string;
username: string;
avatarUrl?: string;
}
interface UseTypingIndicatorOptions {
/** Room ID for room-scoped typing (used in room context) */
roomId?: string;
/** WebSocket client */
wsClient?: RoomWsClient | null;
/** Timeout in ms before a typing user is considered idle. Default: 4000ms */
timeout?: number;
}
export interface UseTypingIndicatorReturn {
/** Users currently typing in this context */
typingUsers: TypingUser[];
/** Send a typing start event */
sendTypingStart: () => void;
/** Send a typing stop event */
sendTypingStop: () => void;
}
/** Debounce timer ref helper */
function createTypingTracker(timeoutMs: number) {
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function set(userId: string, onExpire: () => void) {
clear(userId);
timers.set(userId, setTimeout(onExpire, timeoutMs));
}
function clear(userId: string) {
const existing = timers.get(userId);
if (existing !== undefined) {
clearTimeout(existing);
timers.delete(userId);
}
}
function clearAll() {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
return { set, clear, clearAll };
}
export function useTypingIndicator({
roomId,
wsClient,
timeout = 4000,
}: UseTypingIndicatorOptions): UseTypingIndicatorReturn {
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]);
const trackerRef = useRef(createTypingTracker(timeout));
const roomIdRef = useRef(roomId);
// Keep roomId ref current
useEffect(() => {
roomIdRef.current = roomId;
}, [roomId]);
// Register WS callbacks
useEffect(() => {
if (!wsClient || !roomId) return;
const tracker = trackerRef.current;
const handleTypingStart = (payload: TypingStartPayload) => {
// Only care about typing in this room
if (payload.room_id !== roomIdRef.current) return;
tracker.set(payload.user_id, () => {
setTypingUsers((prev) => prev.filter((u) => u.userId !== payload.user_id));
});
setTypingUsers((prev) => {
if (prev.some((u) => u.userId === payload.user_id)) return prev;
return [
...prev,
{
userId: payload.user_id,
username: payload.username,
avatarUrl: payload.avatar_url,
},
];
});
};
const handleTypingStop = (payload: TypingStopPayload) => {
if (payload.room_id !== roomIdRef.current) return;
tracker.clear(payload.user_id);
setTypingUsers((prev) => prev.filter((u) => u.userId !== payload.user_id));
};
wsClient.updateCallbacks({
onTypingStart: handleTypingStart,
onTypingStop: handleTypingStop,
});
return () => {
tracker.clearAll();
wsClient.updateCallbacks({ onTypingStart: undefined, onTypingStop: undefined });
setTypingUsers([]);
};
}, [wsClient, roomId, timeout]);
const sendTypingStart = () => {
if (!wsClient || !roomId) return;
wsClient.sendTyping?.(roomId, 'start');
};
const sendTypingStop = () => {
if (!wsClient || !roomId) return;
wsClient.sendTyping?.(roomId, 'stop');
};
return { typingUsers, sendTypingStart, sendTypingStop };
}

167
src/lib/code-lang-detect.ts Normal file
View File

@ -0,0 +1,167 @@
/**
* Language detection for code blocks using simple heuristics (no heavy ML).
* Returns a language label for display badges.
*/
export interface LanguageMatch {
/** Detected language label (e.g. "TypeScript", "Rust") */
label: string;
/** Short label for badges (e.g. "TS", "Rust") */
short: string;
/** Confidence: 0-1 */
confidence: number;
}
/** Map language aliases to canonical labels */
const LANG_ALIASES: Record<string, { label: string; short: string }> = {
ts: { label: 'TypeScript', short: 'TS' },
tsx: { label: 'TypeScript (React)', short: 'TSX' },
js: { label: 'JavaScript', short: 'JS' },
jsx: { label: 'JavaScript (React)', short: 'JSX' },
rs: { label: 'Rust', short: 'Rust' },
py: { label: 'Python', short: 'Py' },
go: { label: 'Go', short: 'Go' },
rb: { label: 'Ruby', short: 'RB' },
java: { label: 'Java', short: 'Java' },
kt: { label: 'Kotlin', short: 'Kt' },
swift: { label: 'Swift', short: 'Swift' },
cs: { label: 'C#', short: 'C#' },
cpp: { label: 'C++', short: 'C++' },
c: { label: 'C', short: 'C' },
h: { label: 'C/C++ Header', short: 'H' },
hpp: { label: 'C++ Header', short: 'HPP' },
css: { label: 'CSS', short: 'CSS' },
scss: { label: 'SCSS', short: 'SCSS' },
html: { label: 'HTML', short: 'HTML' },
xml: { label: 'XML', short: 'XML' },
json: { label: 'JSON', short: 'JSON' },
yaml: { label: 'YAML', short: 'YAML' },
yml: { label: 'YAML', short: 'YAML' },
md: { label: 'Markdown', short: 'MD' },
sh: { label: 'Shell', short: 'SH' },
bash: { label: 'Bash', short: 'Bash' },
zsh: { label: 'Zsh', short: 'Zsh' },
sql: { label: 'SQL', short: 'SQL' },
graphql: { label: 'GraphQL', short: 'GQL' },
dockerfile: { label: 'Dockerfile', short: 'Docker' },
tf: { label: 'Terraform', short: 'TF' },
toml: { label: 'TOML', short: 'TOML' },
ini: { label: 'INI', short: 'INI' },
env: { label: 'Env', short: 'ENV' },
proto: { label: 'Protocol Buffer', short: 'Proto' },
};
/** Language detection rules: pattern → language key */
const DETECTION_RULES: Array<{ pattern: RegExp; lang: string; confidence: number }> = [
// Rust
{ pattern: /\bfn\s+\w+\s*[\({]/, lang: 'rs', confidence: 0.9 },
{ pattern: /\blet\s+(mut\s+)?\w+\s*:/, lang: 'rs', confidence: 0.7 },
{ pattern: /\bimpl\s+\w+/, lang: 'rs', confidence: 0.8 },
{ pattern: /\buse\s+\w+::/, lang: 'rs', confidence: 0.7 },
{ pattern: /\bmatch\s+\w+\s*\{/, lang: 'rs', confidence: 0.8 },
{ pattern: /\b->\s*\w+/, lang: 'rs', confidence: 0.6 },
// TypeScript / JavaScript
{ pattern: /\bimport\s+.*\s+from\s+['"]/, lang: 'ts', confidence: 0.9 },
{ pattern: /\bexport\s+(default\s+)?(function|const|class|type|interface)/, lang: 'ts', confidence: 0.9 },
{ pattern: /\bconst\s+\w+:\s*\w+\s*=/, lang: 'ts', confidence: 0.8 },
{ pattern: /\blet\s+\w+:\s*\w+\s*=/, lang: 'ts', confidence: 0.8 },
{ pattern: /interface\s+\w+\s*\{/, lang: 'ts', confidence: 0.9 },
{ pattern: /\btype\s+\w+\s*=/, lang: 'ts', confidence: 0.8 },
{ pattern: /=>\s*[{(]?\s*\w+:/, lang: 'ts', confidence: 0.7 },
{ pattern: /<\w+[^>]*>.*<\/\w+>/s, lang: 'tsx', confidence: 0.7 },
// Python
{ pattern: /\bdef\s+\w+\s*\([^)]*\)\s*(->\s*\w+)?:/, lang: 'py', confidence: 0.95 },
{ pattern: /\bclass\s+\w+.*:/, lang: 'py', confidence: 0.8 },
{ pattern: /\bimport\s+\w+\s*,?\s*(from\s+\w+\s*)?/i, lang: 'py', confidence: 0.6 },
{ pattern: /\bprint\s*\(/, lang: 'py', confidence: 0.5 },
{ pattern: /\bself\./, lang: 'py', confidence: 0.8 },
{ pattern: /@\w+\s*\n/, lang: 'py', confidence: 0.6 },
// Go
{ pattern: /\bpackage\s+\w+/, lang: 'go', confidence: 0.95 },
{ pattern: /\bfunc\s+\w+\s*\(/, lang: 'go', confidence: 0.9 },
{ pattern: /\bfunc\s*\(/, lang: 'go', confidence: 0.8 },
{ pattern: /:=\s*/, lang: 'go', confidence: 0.7 },
{ pattern: /\bgo\s+func/, lang: 'go', confidence: 0.9 },
{ pattern: /\bchan\s+\w+/, lang: 'go', confidence: 0.8 },
// Java
{ pattern: /\bpublic\s+(class|interface|enum)\s+\w+/, lang: 'java', confidence: 0.95 },
{ pattern: /\bSystem\.out\.print/, lang: 'java', confidence: 0.9 },
// CSS
{ pattern: /\b[a-z-]+\s*:\s*[^;{}\n]+;/, lang: 'css', confidence: 0.7 },
{ pattern: /\.[a-z][\w-]*\s*\{/, lang: 'css', confidence: 0.8 },
{ pattern: /@media\s*\(/, lang: 'css', confidence: 0.9 },
{ pattern: /@import\s+/, lang: 'css', confidence: 0.8 },
// HTML
{ pattern: /<html[\s>]/i, lang: 'html', confidence: 0.9 },
{ pattern: /<div[\s>]/i, lang: 'html', confidence: 0.7 },
{ pattern: /<\/\w+>/, lang: 'html', confidence: 0.5 },
// SQL
{ pattern: /\bSELECT\s+.+\s+FROM\b/i, lang: 'sql', confidence: 0.95 },
{ pattern: /\bINSERT\s+INTO\b/i, lang: 'sql', confidence: 0.95 },
{ pattern: /\bCREATE\s+TABLE\b/i, lang: 'sql', confidence: 0.95 },
// Shell
{ pattern: /^#!/m, lang: 'sh', confidence: 0.95 },
{ pattern: /\becho\s+/, lang: 'sh', confidence: 0.6 },
{ pattern: /\bexport\s+\w+=/, lang: 'sh', confidence: 0.6 },
// JSON
{ pattern: /^\s*\{[\s\S]*"[\w-]+":\s*/, lang: 'json', confidence: 0.8 },
{ pattern: /^\s*\[[\s\S]*\{/, lang: 'json', confidence: 0.7 },
// YAML
{ pattern: /^\s*[\w-]+:\s*$/m, lang: 'yaml', confidence: 0.6 },
];
/** Normalize a language label/alias to a canonical label+short */
function normalize(lang: string): { label: string; short: string } {
const lower = lang.toLowerCase().trim();
if (LANG_ALIASES[lower]) return LANG_ALIASES[lower];
// Capitalize first letter for unknown
return { label: lang.charAt(0).toUpperCase() + lang.slice(1), short: lang.slice(0, 4) };
}
/**
* Detect the programming language of a code snippet.
* Returns the best match or null if no patterns match.
*/
export function detectLanguage(code: string): LanguageMatch | null {
let best: { lang: string; confidence: number } | null = null;
for (const rule of DETECTION_RULES) {
if (rule.pattern.test(code)) {
if (!best || rule.confidence > best.confidence) {
best = { lang: rule.lang, confidence: rule.confidence };
}
}
}
if (!best) return null;
return { ...normalize(best.lang), confidence: best.confidence };
}
/**
* Get a language label from a fenced code block language tag.
* Handles aliases and returns canonical label+short.
*/
export function getLanguageFromTag(tag: string | undefined): LanguageMatch | null {
if (!tag) return null;
const lower = tag.toLowerCase().trim();
if (LANG_ALIASES[lower]) {
return { ...LANG_ALIASES[lower], confidence: 1 };
}
return { label: tag, short: tag.slice(0, 4), confidence: 1 };
}
/** Extract the language tag from a fenced code block marker (e.g. "```ts" → "ts") */
export function extractLanguageFromFence(fence: string): string | undefined {
const match = fence.match(/^```(\w*)/);
return match?.[1] || undefined;
}

View File

@ -0,0 +1,81 @@
/**
* Parser for line-level code references.
* Handles formats like:
* file.rs:42
* src/main.rs:10-20
* #L42
* #L42-L48
* src/app.tsx:5
*/
export interface CodeRef {
/** File path (e.g. "src/main.rs") */
filePath: string;
/** Start line number (1-based) */
startLine: number;
/** End line number (inclusive, same as startLine for single line */
endLine: number;
/** The original reference text */
raw: string;
}
/** Parse a code reference string into structured data. */
export function parseCodeRef(ref: string): CodeRef | null {
// Format: path:start[-end]
// Example: src/main.rs:42, src/main.rs:10-20
const lineRangeMatch = ref.match(/^(.+):(\d+)(?:-(\d+))?$/);
if (lineRangeMatch) {
const [, filePath, startStr, endStr] = lineRangeMatch;
const startLine = parseInt(startStr, 10);
const endLine = endStr ? parseInt(endStr, 10) : startLine;
if (startLine > 0 && endLine >= startLine) {
return { filePath: filePath.trim(), startLine, endLine, raw: ref };
}
}
// Format: #L42 or #L42-L48 (GitHub-style)
const ghMatch = ref.match(/^#L(\d+)(?:-L(\d+))?$/i);
if (ghMatch) {
const [, startStr, endStr] = ghMatch;
const startLine = parseInt(startStr, 10);
const endLine = endStr ? parseInt(endStr, 10) : startLine;
if (startLine > 0 && endLine >= startLine) {
return { filePath: '', startLine, endLine, raw: ref };
}
}
return null;
}
/** Extract all code references from a text string. */
export function extractCodeRefs(text: string): CodeRef[] {
const refs: CodeRef[] = [];
// Match file:line or file:line-line patterns
const linePattern = /([^\s:]+:\d+(?:-\d+)?)/g;
let match;
while ((match = linePattern.exec(text)) !== null) {
const parsed = parseCodeRef(match[1]);
if (parsed) refs.push(parsed);
}
// Match #Lnn or #Lnn-Lmm (GitHub-style)
const ghPattern = /#L(\d+)(?:-L(\d+))?/gi;
while ((match = ghPattern.exec(text)) !== null) {
const startLine = parseInt(match[1], 10);
const endLine = match[2] ? parseInt(match[2], 10) : startLine;
if (startLine > 0 && endLine >= startLine) {
refs.push({ filePath: '', startLine, endLine, raw: match[0] });
}
}
return refs;
}
/** Format a code reference as a display string. */
export function formatCodeRef(ref: CodeRef): string {
if (ref.endLine === ref.startLine) {
return `${ref.filePath}:${ref.startLine}`;
}
return `${ref.filePath}:${ref.startLine}-${ref.endLine}`;
}

142
src/lib/link-unfurl.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* URL pattern detection and unfurling for smart link previews.
* Supports internal URLs (issues, PRs, commits, repos) and external URLs.
*/
export type LinkType =
| 'issue'
| 'pull_request'
| 'commit'
| 'repository'
| 'room'
| 'project'
| 'external';
export interface UnfurlResult {
type: LinkType;
url: string;
/** Parsed entity ID fields */
ids: {
project?: string;
namespace?: string;
repo?: string;
issueNumber?: number;
prNumber?: number;
commitOid?: string;
roomId?: string;
};
/** Display title (populated after fetch) */
title?: string;
/** Extra metadata (populated after fetch) */
meta?: Record<string, unknown>;
/** Whether the URL is external */
isExternal?: boolean;
/** Domain for external URLs */
domain?: string;
}
/** Internal URL patterns */
const INTERNAL_PATTERNS: Array<{
type: LinkType;
regex: RegExp;
extract: (match: RegExpMatchArray) => UnfurlResult['ids'];
}> = [
{
type: 'issue',
regex: /^\/project\/([^/]+)\/issues\/(\d+)/,
extract: (m) => ({ project: m[1], issueNumber: parseInt(m[2], 10) }),
},
{
type: 'pull_request',
regex: /^\/repository\/([^/]+)\/([^/]+)\/pulls?\/(\d+)/,
extract: (m) => ({ namespace: m[1], repo: m[2], prNumber: parseInt(m[3], 10) }),
},
{
type: 'commit',
regex: /^\/repository\/([^/]+)\/([^/]+)\/commit\/([a-f0-9]+)/,
extract: (m) => ({ namespace: m[1], repo: m[2], commitOid: m[3] }),
},
{
type: 'repository',
regex: /^\/repository\/([^/]+)\/([^/]+)/,
extract: (m) => ({ namespace: m[1], repo: m[2] }),
},
{
type: 'project',
regex: /^\/project\/([^/]+)/,
extract: (m) => ({ project: m[1] }),
},
{
type: 'room',
regex: /^\/project\/([^/]+)\/room(?:\/([^/?#]+))?/,
extract: (m) => ({ project: m[1], roomId: m[2] }),
},
];
/** Detect the type and IDs of a URL */
export function detectLinkType(url: string): UnfurlResult | null {
// Remove trailing slashes and hash
const normalized = url.split('?')[0].split('#')[0].replace(/\/+$/, '');
// Check internal patterns
for (const pattern of INTERNAL_PATTERNS) {
const match = normalized.match(pattern.regex);
if (match) {
return {
type: pattern.type,
url: `/${normalized.replace(/^\//, '')}`,
ids: pattern.extract(match),
};
}
}
// External URL
try {
const parsed = new URL(url);
const isExternal = !parsed.hostname.includes(window.location.hostname);
return {
type: 'external',
url,
ids: {},
isExternal,
domain: parsed.hostname,
};
} catch {
return null;
}
}
/** Extract all URLs from a text string */
export function extractUrls(text: string): Array<{ url: string; index: number }> {
// Match URLs (including internal paths starting with /)
const urlPattern = /(?:https?:\/\/[^\s<>"]+|^\/[^\s<>"]+|\/[^\s<>"]+)/gm;
const results: Array<{ url: string; index: number }> = [];
let match;
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0];
// Filter out very short or malformed URLs
if (url.length > 3) {
results.push({ url, index: match.index });
}
}
return results;
}
/** Simple cache for unfurl results */
const unfurlCache = new Map<string, UnfurlResult & { expiresAt: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function getCachedUnfurl(url: string): (UnfurlResult & { expiresAt: number }) | undefined {
const cached = unfurlCache.get(url);
if (cached && cached.expiresAt > Date.now()) {
return cached;
}
return undefined;
}
export function setCachedUnfurl(url: string, result: UnfurlResult): void {
unfurlCache.set(url, { ...result, expiresAt: Date.now() + CACHE_TTL });
}

100
src/lib/mention.ts Normal file
View File

@ -0,0 +1,100 @@
/**
* Unified mention serialization and parsing utilities.
* Shared across room chat, issues, and PR comments.
*/
export type MentionType =
| 'user'
| 'channel'
| 'ai'
| 'command'
| 'special_here'
| 'special_channel';
export const MENTION_TYPES: MentionType[] = [
'user',
'channel',
'ai',
'command',
'special_here',
'special_channel',
];
export interface MentionSpan {
type: MentionType;
id: string;
label: string;
}
export interface ExtractedMentions {
safeContent: string;
mentions: MentionSpan[];
}
/**
* Parse @[type:id:label] tokens from message content.
* Returns the safe content (with mentions replaced by placeholders) and the parsed mention list.
*
* Placeholders use zero-width spaces to prevent markdown from interpreting the text.
*/
const PLACEHOLDER_PREFIX = 'MENTION_';
const PLACEHOLDER_SUFFIX = '';
const MENTION_RE = /@\[([a-z_]+):([^:\]]+):([^\]]+)\]/g;
const PLACEHOLDER_RE = /MENTION_(\d+)/g;
export function extractMentions(content: string): ExtractedMentions {
const mentions: MentionSpan[] = [];
const safeContent = content.replace(MENTION_RE, (_match, type, id, label) => {
const idx = mentions.length;
mentions.push({ type: type as MentionType, id, label });
return `${PLACEHOLDER_PREFIX}${idx}${PLACEHOLDER_SUFFIX}`;
});
return { safeContent, mentions };
}
export function restoreMentions(
text: string,
mentions: MentionSpan[],
): string {
return text.replace(PLACEHOLDER_RE, (match, idxStr) => {
const idx = parseInt(idxStr, 10);
const m = mentions[idx];
return m ? `@[${m.type}:${m.id}:${m.label}]` : match;
});
}
/** Build a mention token string for serialization. */
export function serializeMention(type: MentionType, id: string, label: string): string {
return `@[${type}:${id}:${label}]`;
}
/** Build a mention token from a user mention. */
export function serializeUserMention(userId: string, displayName: string): string {
return serializeMention('user', userId, displayName);
}
/** Build a mention token from a channel mention. */
export function serializeChannelMention(channelId: string, channelName: string): string {
return serializeMention('channel', channelId, channelName);
}
/** Build a mention token from an AI mention. */
export function serializeAiMention(aiId: string, aiName: string): string {
return serializeMention('ai', aiId, aiName);
}
/** Build a mention token from a command mention. */
export function serializeCommandMention(commandId: string, commandName: string): string {
return serializeMention('command', commandId, commandName);
}
/** Build a mention token for @here. */
export function serializeHereMention(): string {
return '@[special_here:here:here]';
}
/** Build a mention token for @channel. */
export function serializeChannelBroadcastMention(): string {
return '@[special_channel:channel:channel]';
}

View File

@ -29,6 +29,7 @@ import type {
UserInfo,
RoomReactionUpdatedPayload,
UserPresencePayload,
NotificationCreatedPayload,
} from './ws-protocol';
export type {
@ -53,10 +54,10 @@ export type {
UserInfo,
RoomReactionUpdatedPayload,
UserPresencePayload,
NotificationCreatedPayload,
};
export interface WsTokenResponse {
token: string;
expires_in_seconds: number;
}
@ -84,6 +85,8 @@ export interface RoomWsCallbacks {
onError?: (error: Error) => void;
/** Called each time the client sends a heartbeat ping */
onHeartbeat?: () => void;
/** Called when a new notification is pushed from the server via WebSocket */
onNotification?: (payload: import('./ws-protocol').NotificationCreatedPayload) => void;
}
export class RoomWsClient {
@ -133,6 +136,11 @@ export class RoomWsClient {
this.wsToken = token;
}
/** Update callbacks (e.g. to register onNotification after construction). */
updateCallbacks(callbacks: Partial<RoomWsCallbacks>): void {
Object.assign(this.callbacks, callbacks);
}
getWsToken(): string | null {
return this.wsToken;
}
@ -1059,6 +1067,11 @@ export class RoomWsClient {
user_id: (event.data as { user_id?: string })?.user_id ?? '',
});
break;
case 'notification_created':
this.callbacks.onNotification?.(
event.data as import('./ws-protocol').NotificationCreatedPayload,
);
break;
default:
// Unknown event type - ignore silently
break;

View File

@ -37,6 +37,7 @@ export type AiStreamChunkPayload = {
content: string;
done: boolean;
error?: string | null;
chunk_type?: string | null;
};
export type RoomWsStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed' | 'error';

View File

@ -170,8 +170,16 @@ export type WsEventPayload =
| { type: 'user_presence'; data: UserPresencePayload }
| { type: 'typing_start'; data: TypingStartPayload }
| { type: 'typing_stop'; data: TypingStopPayload }
| { type: 'notification_created'; data: NotificationCreatedPayload }
| { type: string; data: unknown }; // catch-all for unknown events
/** Payload for real-time notification push via WebSocket. */
export interface NotificationCreatedPayload {
notification: NotificationData;
/** URL to navigate to for this notification (e.g. /project/x/issues/42) */
deep_link_url?: string;
}
export interface RoomMessagePayload {
id: string;
room_id: string;
@ -205,6 +213,8 @@ export interface AiStreamChunkPayload {
error?: string;
/** Human-readable AI model name for display (e.g. "Claude 3.5 Sonnet"). */
display_name?: string;
/** What kind of content: "thinking", "answer", "tool_call", "tool_result". */
chunk_type?: string;
}
export interface RoomResponse {

View File

@ -6,6 +6,9 @@ import {UserProvider} from '@/contexts';
import {ThemeProvider} from '@/contexts/theme-context';
import './index.css';
import App from './App.tsx';
import {CommandPalette} from '@/components/shared/CommandPalette';
import {KeyboardShortcutsSheet} from '@/components/shared/KeyboardShortcutsSheet';
import {GlobalNavigationShortcuts} from '@/components/shared/GlobalNavigationShortcuts';
import {applyPaletteToDOM, loadActivePresetId} from '@/components/room/design-system';
// Restore custom palette on page load (before first render)
@ -30,6 +33,9 @@ createRoot(document.getElementById('root')!).render(
<UserProvider>
<ThemeProvider>
<App/>
<CommandPalette/>
<KeyboardShortcutsSheet/>
<GlobalNavigationShortcuts/>
<Toaster richColors position="bottom-right"/>
</ThemeProvider>
</UserProvider>