Compare commits
8 Commits
6b3b77384e
...
0a9dfef9b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9dfef9b4 | ||
|
|
76de013a60 | ||
|
|
99bc4eeb80 | ||
|
|
dfa5f7664a | ||
|
|
f7e087e066 | ||
|
|
7620f2f281 | ||
|
|
616c0c0e88 | ||
|
|
57d0fc371e |
@ -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;
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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()
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
37
libs/api/agent/issue_triage.rs
Normal file
37
libs/api/agent/issue_triage.rs
Normal 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())
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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() => {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -177,6 +177,7 @@ impl RoomService {
|
||||
content_type: content_type_str.clone(),
|
||||
send_at: now,
|
||||
seq,
|
||||
display_name: None,
|
||||
};
|
||||
|
||||
let db = &self.db;
|
||||
|
||||
@ -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(¬ification);
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
232
libs/service/agent/issue_triage.rs
Normal file
232
libs/service/agent/issue_triage.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) = ¤t {
|
||||
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) = ¤t {
|
||||
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) = ¤t {
|
||||
current = arr.get(index).cloned().unwrap_or(JsonValue::Null);
|
||||
} else {
|
||||
return Err(format!("index {} on non-array", index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let JsonValue::Object(obj) = ¤t {
|
||||
current = obj.get(part).cloned().unwrap_or(JsonValue::Null);
|
||||
} else {
|
||||
return Err(format!("property '{}' not found", part));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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())?
|
||||
};
|
||||
|
||||
|
||||
@ -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()]) };
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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(¤t_url),
|
||||
"POST" => client.post(¤t_url),
|
||||
"PUT" => client.put(¤t_url),
|
||||
"DELETE" => client.delete(¤t_url),
|
||||
"PATCH" => client.patch(¤t_url),
|
||||
"HEAD" => client.head(¤t_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(¤t_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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@ -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())
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
})?;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
415
openapi.json
415
openapi.json
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
365
src/components/notify/NotificationDrawer.tsx
Normal file
365
src/components/notify/NotificationDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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
|
||||
|
||||
155
src/components/shared/CodeBlock.tsx
Normal file
155
src/components/shared/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/components/shared/CodeReference.tsx
Normal file
154
src/components/shared/CodeReference.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
277
src/components/shared/CommandPalette.tsx
Normal file
277
src/components/shared/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
323
src/components/shared/ContentRenderer.tsx
Normal file
323
src/components/shared/ContentRenderer.tsx
Normal 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;
|
||||
}
|
||||
98
src/components/shared/GlobalNavigationShortcuts.tsx
Normal file
98
src/components/shared/GlobalNavigationShortcuts.tsx
Normal 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;
|
||||
}
|
||||
146
src/components/shared/KeyboardShortcutsSheet.tsx
Normal file
146
src/components/shared/KeyboardShortcutsSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
src/components/shared/LinkPreview.tsx
Normal file
303
src/components/shared/LinkPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
84
src/components/shared/MentionBadge.tsx
Normal file
84
src/components/shared/MentionBadge.tsx
Normal 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);
|
||||
}
|
||||
218
src/components/shared/MiniChat.tsx
Normal file
218
src/components/shared/MiniChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
86
src/hooks/useCommandRegistry.ts
Normal file
86
src/hooks/useCommandRegistry.ts
Normal 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);
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
174
src/hooks/useKeyboardShortcut.ts
Normal file
174
src/hooks/useKeyboardShortcut.ts
Normal 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('+');
|
||||
}
|
||||
229
src/hooks/useNotification.ts
Normal file
229
src/hooks/useNotification.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
136
src/hooks/useTypingIndicator.ts
Normal file
136
src/hooks/useTypingIndicator.ts
Normal 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
167
src/lib/code-lang-detect.ts
Normal 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;
|
||||
}
|
||||
81
src/lib/code-ref-parser.ts
Normal file
81
src/lib/code-ref-parser.ts
Normal 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
142
src/lib/link-unfurl.ts
Normal 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
100
src/lib/mention.ts
Normal 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]';
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user